Wizard Export Özelliği

This commit is contained in:
Sedat ÖZTÜRK 2026-08-18 13:33:50 +03:00
parent 3152cd681b
commit 3aa74318c2
7 changed files with 451 additions and 9 deletions

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Content;
namespace Sozsoft.Platform.ListForms;
@ -9,5 +10,8 @@ public interface IListFormWizardAppService
Task<List<WizardFileInfoDto>> GetFiles();
Task<WizardSeedFileDto> GetFile(string fileName);
Task DeleteFile(string fileName);
/// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary>
Task<IRemoteStreamContent> ExportFile(string fileName);
}

View file

@ -1,14 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Languages;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums;
using Volo.Abp;
using Volo.Abp.Content;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy;
using Volo.Abp.PermissionManagement;
@ -19,6 +22,7 @@ using Sozsoft.Languages;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.DynamicData;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using static Sozsoft.Platform.PlatformConsts;
@ -86,6 +90,11 @@ public class ListFormWizardAppService(
input.ListFormCode = listFormCode;
input.MenuCode = code;
// Ad ve kod catismalari, kayitlar uretilmeye baslamadan once reddedilir; aksi halde
// yarim kalan bir deploy mevcut menu/permission kayitlarini bozabilir.
ValidateWizardName(wizardName);
await ValidateWizardUniquenessAsync(wizardName, code, input.MenuParentCode);
// Yol ayrımı: Custom seçildiğinde ListForm ekosistemi (veri kaynağı, ListForm,
// alanlar, workflow) hiç üretilmez; menü doğrudan CustomComponent rotasına bağlanır.
var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom;
@ -821,6 +830,61 @@ public class ListFormWizardAppService(
}
}
/// <summary>
/// Wizard adi hem menu/permission/dil anahtarlarinin hem de seed dosyasi adinin kaynagidir;
/// bu yuzden yalnizca ASCII harf/rakam/alt cizgi kabul edilir. Bosluk ve Turkce karakter
/// iceren bir ad, uretilen anahtarlari ve dosya adini bozar.
/// </summary>
private static readonly Regex WizardNamePattern = new(@"^[A-Za-z][A-Za-z0-9_]*$", RegexOptions.Compiled);
private static void ValidateWizardName(string wizardName)
{
if (!WizardNamePattern.IsMatch(wizardName))
{
throw new UserFriendlyException(
$"'{wizardName}' geçerli bir Wizard adı değil. Ad bir harf ile başlamalı; yalnızca İngiliz alfabesindeki harfler, rakamlar ve alt çizgi (_) içerebilir. Boşluk ve Türkçe karakter (ş, ç, ğ, ü, ö, ı) kullanılamaz.");
}
}
/// <summary>
/// Wizard'in uretecegi menu kodunun bir baskasiyla catismadigini dogrular.
/// <para>
/// En kritik durum menu kodunun secilen ust menunun koduyla ayni olmasidir: bu durumda wizard
/// ust menuyu kendi kaydiyla ezer ve menu agacinda ust menu kaybolur. Ayni sekilde zaten var
/// olan bir menu koduna ya da daha once kaydedilmis bir Wizard adina izin verilmez; ikisi de
/// mevcut kayitlarin uzerine yazilmasi anlamina gelir.
/// </para>
/// <para>
/// Duzenleme akisi once <see cref="DeleteFile"/> cagirdigi icin (seed dosyasi ve wizard'in
/// olusturdugu menu kaydi silinir) ayni ad ile yeniden deploy edilebilir.
/// </para>
/// </summary>
private async Task ValidateWizardUniquenessAsync(string wizardName, string code, string menuParentCode)
{
if (string.Equals(code, menuParentCode?.Trim(), StringComparison.OrdinalIgnoreCase))
{
throw new UserFriendlyException(
$"Menü kodu ('{code}') üst menü kodu ile aynı olamaz. Farklı bir Wizard adı ya da menü kodu seçin; aksi halde üst menü bu kaydın üzerine yazılır.");
}
var existingFiles = await GetFiles();
var duplicateFile = existingFiles.FirstOrDefault(f =>
string.Equals(f.WizardName?.Trim(), wizardName, StringComparison.OrdinalIgnoreCase));
if (duplicateFile != null)
{
throw new UserFriendlyException(
$"'{wizardName}' adında bir Wizard zaten var ({duplicateFile.FileName}). Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin.");
}
var menuQueryable = await repoMenu.GetQueryableAsync();
if (await AsyncExecuter.AnyAsync(menuQueryable.Where(a => a.Code == code)))
{
throw new UserFriendlyException(
$"'{code}' kodlu bir menü zaten var. Farklı bir Wizard adı ya da menü kodu seçin.");
}
}
/// <summary>
/// Wizard seed dosyalarinin yazilacagi wizard dizinini dondurur; kapsam aktif tenant'i izler
/// (host baglaminda <c>host/wizard</c>, tenant baglaminda <c>tenants/{tenantId}/wizard</c>).
@ -831,6 +895,193 @@ public class ListFormWizardAppService(
return SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id, SeedPathResolver.WizardFolder);
}
/// <summary>
/// Wizard seed dosyasini ve bagli oldugu tum seed dosyalarini tek bir zip icinde dondurur.
/// Zip icindeki yollar seed kapsam klasoru (host / tenants/{tenantId}) koku baz alinarak yazilir;
/// dolayisiyla arsiv baska bir ortamda ayni klasor duzenine dogrudan acilabilir.
/// <para>
/// Custom yolunda: <c>wizard/{dosya}.json</c>, <c>custom/{component}.json</c> (bagimliliklari ile birlikte)
/// ve bu component'lerin data source'larinin isaret ettigi <c>crud/{entity}.json</c> dosyalari.
/// List yolunda: <c>wizard/{dosya}.json</c>, SelectCommand'a karsilik gelen
/// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>.
/// </para>
/// </summary>
[HttpGet("api/app/list-form-wizard/export")]
public async Task<IRemoteStreamContent> ExportFile(string fileName)
{
var filePath = ResolveWizardSeedFilePath(fileName);
var json = await File.ReadAllTextAsync(filePath);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions)
?? throw new UserFriendlyException("Dosya okunamadı.");
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
// Ayni dosyanin iki farkli yoldan (ornegin iki component ayni crud dosyasini kullanir)
// eklenmesini engellemek icin zip girdileri yola gore tekillestirilir.
var entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[$"{SeedPathResolver.WizardFolder}/{Path.GetFileName(filePath)}"] = filePath
};
if (seed.Wizard?.ComponentKind == WizardComponentKindEnum.Custom)
await CollectCustomComponentEntriesAsync(scopeRoot, seed.Wizard.CustomComponentName, entries);
else
CollectListComponentEntries(scopeRoot, seed.Wizard, entries);
var memory = new MemoryStream();
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var entry in entries.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
{
var zipEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal);
await using var source = File.OpenRead(entry.Value);
await using var target = zipEntry.Open();
await source.CopyToAsync(target);
}
}
memory.Position = 0;
var safeName = string.Concat((seed.Wizard?.WizardName ?? Path.GetFileNameWithoutExtension(fileName))
.Trim()
.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName))
safeName = "Wizard";
return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip");
}
/// <summary>
/// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin
/// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile
/// ziyaret edilen adlar takip edildigi icin sonsuz dongu olusmaz.
/// </summary>
private async Task CollectCustomComponentEntriesAsync(string scopeRoot, string? componentName, Dictionary<string, string> entries)
{
var pending = new Queue<string>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(componentName))
pending.Enqueue(componentName.Trim());
while (pending.Count > 0)
{
var name = pending.Dequeue();
if (!visited.Add(name))
continue;
var relativePath = $"{SeedPathResolver.CustomFolder}/{name}.json";
if (!TryAddSeedEntry(scopeRoot, relativePath, entries))
{
logger.LogWarning("Custom component seed file not found for export: {RelativePath}", relativePath);
continue;
}
CustomComponentSeedFileDto? customSeed;
try
{
var customJson = await File.ReadAllTextAsync(entries[relativePath]);
customSeed = JsonSerializer.Deserialize<CustomComponentSeedFileDto>(customJson, SeedJsonReadOptions);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Custom component seed file could not be parsed for export: {RelativePath}", relativePath);
continue;
}
foreach (var component in customSeed?.CustomComponents ?? [])
{
foreach (var dependency in component.Dependencies)
{
if (!string.IsNullOrWhiteSpace(dependency))
pending.Enqueue(dependency.Trim());
}
foreach (var dataSource in component.DataSources)
{
// SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur;
// elle yazilmis bir URL'in seed karsiligi yoktur.
if (string.IsNullOrWhiteSpace(dataSource.SeedFile))
continue;
if (!TryAddSeedEntry(scopeRoot, dataSource.SeedFile, entries))
logger.LogWarning("CRUD seed file not found for export: {RelativePath}", dataSource.SeedFile);
}
}
}
}
/// <summary>
/// List yolunda wizard'in SelectCommand'ina karsilik gelen .sql dosyasini ve ayni nesne adiyla
/// uretilmis crud seed dosyasini toplar. Query tipinde SelectCommand bir nesne adi degil ham SQL
/// oldugu icin dosya aramasi yapilmaz.
/// </summary>
private void CollectListComponentEntries(string scopeRoot, ListFormWizardDto? wizard, Dictionary<string, string> entries)
{
if (wizard == null || wizard.SelectCommandType == SelectCommandTypeEnum.Query)
return;
var objectName = NormalizeDatabaseObjectName(wizard.SelectCommand);
if (objectName == null)
return;
// Saglayici (sql / postgres) ve klasor turu (object / execute) wizard dosyasinda tutulmadigi
// icin bilinen dort kombinasyon sirayla denenir; hangisi varsa arsive eklenir.
string[] providerFolders = [SeedPathResolver.SqlFolder, SeedPathResolver.PostgresFolder];
string[] scriptFolders = [SeedPathResolver.ObjectFolder, SeedPathResolver.ExecuteFolder];
var sqlFound = false;
foreach (var provider in providerFolders)
{
foreach (var scriptFolder in scriptFolders)
{
if (TryAddSeedEntry(scopeRoot, $"{provider}/{scriptFolder}/{objectName}.sql", entries))
sqlFound = true;
}
}
if (!sqlFound)
logger.LogWarning("SQL seed file not found for export: {ObjectName}", objectName);
TryAddSeedEntry(scopeRoot, $"{SeedPathResolver.CrudFolder}/{objectName}.json", entries);
}
/// <summary>Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur.</summary>
private static string? NormalizeDatabaseObjectName(string? selectCommand)
{
var trimmed = selectCommand?.Trim();
if (string.IsNullOrWhiteSpace(trimmed))
return null;
var name = trimmed.Split('.')[^1].Trim('"', '[', ']', ' ');
if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
return null;
return name;
}
/// <summary>
/// Kapsam klasorune goreli bir seed dosyasini arsiv listesine ekler. Yol her zaman kapsam
/// klasoru icinde kalmali; disari cikan bir yol (seed dosyasindaki bozuk bir kayittan gelebilir)
/// sessizce reddedilir. Dosya yoksa false doner.
/// </summary>
private static bool TryAddSeedEntry(string scopeRoot, string relativePath, Dictionary<string, string> entries)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
if (normalized.Length == 0 || normalized.Contains("..", StringComparison.Ordinal))
return false;
var fullPath = Path.GetFullPath(Path.Combine(scopeRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
if (!fullPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return false;
if (!File.Exists(fullPath))
return false;
entries[normalized] = fullPath;
return true;
}
private async Task<HashSet<string>> GetTableColumnNamesAsync(string dataSourceCode, SelectCommandTypeEnum commandType, string selectCommand)
{
if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure)

View file

@ -16116,6 +16116,18 @@
"en": "Wizard file deleted successfully.",
"tr": "Wizard dosyası başarıyla silindi."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardFileExport",
"en": "Export as zip (wizard, custom/crud and sql seed files)",
"tr": "Zip olarak dışa aktar (wizard, custom/crud ve sql seed dosyaları)"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardFileExportError",
"en": "Failed to export wizard file.",
"tr": "Wizard dosyası dışa aktarılamadı."
},
{
"resourceName": "Platform",
"key": "App.Listforms.WizardManager",
@ -16164,6 +16176,30 @@
"en": "Used to generate ListForm Code and Menu Code",
"tr": "ListForm Kodu ve Menü Kodu oluşturmak için kullanılır"
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.WizardNameInvalid",
"en": "The name must start with a letter and may contain only English letters, digits and underscore (_). Spaces and Turkish characters are not allowed.",
"tr": "Ad bir harf ile başlamalı; yalnızca İngiliz alfabesindeki harfler, rakamlar ve alt çizgi (_) içerebilir. Boşluk ve Türkçe karakter kullanılamaz."
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.WizardNameDuplicate",
"en": "A wizard with this name already exists. Use a different name or edit the existing record from Wizard Manager.",
"tr": "Bu adda bir Wizard zaten var. Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin."
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.MenuCodeSameAsParent",
"en": "The menu code cannot be the same as the parent menu code; the parent menu would be overwritten by this record.",
"tr": "Menü kodu üst menü kodu ile aynı olamaz; üst menü bu kaydın üzerine yazılır."
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.MenuCodeDuplicate",
"en": "A menu with this code already exists. Choose a different wizard name or menu code.",
"tr": "Bu kodla bir menü zaten var. Farklı bir Wizard adı ya da menü kodu seçin."
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.Optional",

View file

@ -27,3 +27,28 @@ export const deleteWizardFile = (fileName: string) =>
url: `/api/app/list-form-wizard/file`,
params: { fileName },
})
/**
* Wizard dosyasini ve bagli seed dosyalarini (custom/crud/sql) tek bir zip olarak indirir.
* Zip icindeki klasor duzeni seed kapsam klasoru ile aynidir; baska bir ortamda dogrudan acilabilir.
*/
export const exportWizardFile = (fileName: string) =>
apiService.fetchData<Blob>({
method: 'GET',
url: `/api/app/list-form-wizard/export`,
params: { fileName },
responseType: 'blob',
})
/** Blob'u tarayicida indirme olarak tetikler. */
export const downloadBlob = (blob: Blob, fileName: string) => {
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.style.display = 'none'
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}

View file

@ -34,7 +34,12 @@ import WizardStep6 from './WizardStep6'
import { Container } from '@/components/shared'
import { sqlDataTypeToDbType } from '../edit/options'
import { useStoreActions } from '@/store/store'
import { deleteWizardFile, getWizardFile, postListFormWizard } from '@/services/wizard.service'
import {
deleteWizardFile,
getWizardFile,
getWizardFiles,
postListFormWizard,
} from '@/services/wizard.service'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import WizardStepComponent from './WizardStepComponent'
import WizardStepCustomComponent from './WizardStepCustomComponent'
@ -250,6 +255,13 @@ const deriveListFormCode = (name: string) => {
return sanitized ? `App.Wizard.${sanitized}` : ''
}
/**
* Wizard adı; menü kodu, permission adı, dil anahtarı ve seed dosyası adının tek kaynağıdır.
* Bu yüzden yalnızca ASCII harf/rakam/alt çizgi kabul edilir boşluk ya da Türkçe karakter
* üretilen anahtarları ve dosya adını bozar. Backend aynı kuralı `WizardNamePattern` ile uygular.
*/
const WIZARD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
/**
* Adım kimlikleri. `resolvedStep` geri düşerken `id < currentStep` karşılaştırması
* yaptığı için kimlikler görüntüleme sırasıyla artan olmak zorundadır.
@ -320,6 +332,8 @@ const Wizard = () => {
const isEditMode = !!editFileName
const [isLoadingEditData, setIsLoadingEditData] = useState(false)
const editDataLoadedRef = useRef(false)
// Düzenleme modunda açılan kaydın kendi adı ve menü kodu; çakışma kontrolünde muaf tutulur.
const editedIdentityRef = useRef<{ wizardName: string; menuCode: string } | null>(null)
// ── Data Source ──
const [isLoadingDataSource, setIsLoadingDataSource] = useState(false)
@ -435,13 +449,15 @@ const Wizard = () => {
// Auto-check isTenant / isBranch based on column presence
formikRef.current?.setFieldValue('isTenant', hasTenantColumn)
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
// Auto-select first column as key field
if (cols.length > 0) {
const first = cols[0]
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
// Anahtar alan: tabloda Id sutunu varsa her zaman odur; sutun sirasi anahtari
// belirlemez. Id yoksa son care olarak ilk sutuna dusulur.
const keyColumn =
cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
if (keyColumn) {
formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName)
formikRef.current?.setFieldValue(
'keyFieldDbSourceType',
sqlDataTypeToDbType(first.dataType),
sqlDataTypeToDbType(keyColumn.dataType),
)
}
} catch {
@ -506,6 +522,18 @@ const Wizard = () => {
}
// ── Menu ──
// Deploy'dan önce ad çakışmasını gösterebilmek için kayıtlı wizard dosyalarının adları.
const [existingWizardNames, setExistingWizardNames] = useState<string[]>([])
const loadExistingWizardNames = async () => {
try {
const res = await getWizardFiles()
setExistingWizardNames((res.data ?? []).map((f) => (f.wizardName ?? '').trim()))
} catch {
// Liste okunamazsa istemci tarafı uyarı verilemez; deploy'da backend doğrulaması devrede kalır.
setExistingWizardNames([])
}
}
const [isLoadingMenu, setIsLoadingMenu] = useState(false)
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
const [rawMenuItems, setRawMenuItems] = useState<(MenuItem & { id?: string })[]>([])
@ -540,6 +568,7 @@ const Wizard = () => {
getDataSourceList()
getMenuList()
getPermissionGroupList()
loadExistingWizardNames()
}, [])
// ── Load edit data after reference lists are ready ──
@ -559,6 +588,12 @@ const Wizard = () => {
if (!seed?.wizard) return
const w = seed.wizard
// Düzenlenen kaydın kendi adı ve menü kodu çakışma sayılmaz; deploy sırasında
// eski dosya ve menü kaydı zaten siliniyor.
editedIdentityRef.current = {
wizardName: (w.wizardName ?? '').trim(),
menuCode: (w.menuCode ?? '').trim(),
}
// Populate formik values
formikRef.current?.setValues({
@ -725,6 +760,49 @@ const Wizard = () => {
setAutoText('languageTextDescTr')
}
/**
* Wizard adının deploy'u bozacak bir ada dönüşmediğini kontrol eder ve ilk hatayı döndürür.
* Aynı kurallar backend'de `ValidateWizardName` / `ValidateWizardUniquenessAsync` ile de
* uygulanır; buradaki kontrol yalnızca kullanıcıyı 11 adım sonra değil, adı yazarken uyarır.
*/
const getWizardNameError = (values: ListFormWizardDto): string | null => {
const name = (values.wizardName ?? '').trim()
if (!name) return null
if (!WIZARD_NAME_PATTERN.test(name)) {
return translate('::App.WizardStep1.WizardNameInvalid')
}
const edited = editedIdentityRef.current
const isSameAsEdited = (value: string, editedValue?: string) =>
!!editedValue && value.toLowerCase() === editedValue.toLowerCase()
if (
!isSameAsEdited(name, edited?.wizardName) &&
existingWizardNames.some((existing) => existing.toLowerCase() === name.toLowerCase())
) {
return translate('::App.WizardStep1.WizardNameDuplicate')
}
const menuCode = (values.menuCode ?? '').trim()
if (!menuCode) return null
// En kritik durum: menü kodu üst menü kodu ile aynıysa wizard üst menüyü kendi
// kaydıyla ezer ve menü ağacında üst menü kaybolur.
if (menuCode.toLowerCase() === (values.menuParentCode ?? '').trim().toLowerCase()) {
return translate('::App.WizardStep1.MenuCodeSameAsParent')
}
if (
!isSameAsEdited(menuCode, edited?.menuCode) &&
rawMenuItems.some((item) => (item.code ?? '').toLowerCase() === menuCode.toLowerCase())
) {
return translate('::App.WizardStep1.MenuCodeDuplicate')
}
return null
}
const applyPermissionGroupFromRoot = (rootCode: string) => {
const rootItem = rawMenuItems.find((i) => i.code === rootCode)
@ -1100,6 +1178,7 @@ const Wizard = () => {
errors={errors}
touched={touched}
wizardName={values.wizardName}
wizardNameError={getWizardNameError(values)}
onWizardNameChange={handleWizardNameChange}
rawMenuItems={rawMenuItems}
menuTree={menuTree}

View file

@ -16,9 +16,15 @@ import {
FaListUl,
FaLink,
FaExternalLinkAlt,
FaFileExport,
} from 'react-icons/fa'
import DbMigrateButton from '@/components/shared/DbMigrateButton'
import { deleteWizardFile, getWizardFiles } from '@/services/wizard.service'
import {
deleteWizardFile,
downloadBlob,
exportWizardFile,
getWizardFiles,
} from '@/services/wizard.service'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
@ -41,6 +47,7 @@ const WizardFileManager = () => {
const [files, setFiles] = useState<WizardFileInfoDto[]>([])
const [loading, setLoading] = useState(false)
const [deletingFile, setDeletingFile] = useState<string | null>(null)
const [exportingFile, setExportingFile] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const [search, setSearch] = useState('')
@ -75,6 +82,28 @@ const WizardFileManager = () => {
loadFiles()
}, [loadFiles])
// Zip'i sunucu uretir; istemci yalnizca indirmeyi tetikler. Dosya adi Content-Disposition
// basligindan okunur, okunamazsa wizard adina duser.
const handleExport = async (file: WizardFileInfoDto) => {
setExportingFile(file.fileName)
try {
const res = await exportWizardFile(file.fileName)
const disposition = String(res.headers?.['content-disposition'] ?? '')
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
const fallback = `${file.wizardName || file.fileName.replace(/\.json$/i, '')}.zip`
downloadBlob(res.data, match ? decodeURIComponent(match[1]) : fallback)
} catch (err: any) {
toast.push(
<Notification type="danger">
{translate('::App.ListForm.WizardFileExportError')}: {err?.message ?? 'Unknown error'}
</Notification>,
{ placement: 'bottom-end' },
)
} finally {
setExportingFile(null)
}
}
const handleDeleteConfirm = async () => {
if (!confirm) return
setDeletingFile(confirm.fileName)
@ -251,6 +280,17 @@ const WizardFileManager = () => {
<FaExternalLinkAlt />
</Button>
)}
<Button
size="sm"
variant="plain"
className="text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-900/20"
type="button"
title={translate('::App.ListForm.WizardFileExport')}
loading={exportingFile === f.fileName}
onClick={() => handleExport(f)}
>
<FaFileExport />
</Button>
<Button
size="sm"
variant="plain"

View file

@ -359,6 +359,8 @@ export interface WizardStep1Props {
errors: FormikErrors<ListFormWizardDto>
touched: FormikTouched<ListFormWizardDto>
wizardName: string
/** Ad kuralı ya da çakışma hatası; dolu ise adım ilerletilemez. */
wizardNameError: string | null
onWizardNameChange: (name: string) => void
rawMenuItems: (MenuItem & { id?: string })[]
menuTree: MenuTreeNode[]
@ -386,6 +388,7 @@ const WizardStep1 = ({
errors,
touched,
wizardName,
wizardNameError,
onWizardNameChange,
rawMenuItems,
menuTree,
@ -449,7 +452,8 @@ const WizardStep1 = ({
!values.languageTextMenuTr && translate('::App.WizardStep4.MenuTr'),
!values.menuIcon && translate('::App.WizardStep4.MenuIcon'),
].filter(Boolean) as string[]
const step1CanGo = step1Missing.length === 0
// Geçersiz ya da çakışan ad deploy'da menü/permission kayıtlarını bozar; adım burada durur.
const step1CanGo = step1Missing.length === 0 && !wizardNameError
return (
<div className="pb-20">
@ -457,6 +461,8 @@ const WizardStep1 = ({
<FormItem
label={translate('::App.WizardStep1.WizardName')}
asterisk={true}
invalid={!!wizardNameError}
errorMessage={wizardNameError ?? undefined}
extra={
<span className="text-xs ml-2 text-gray-400">
{translate('::App.WizardStep1.WizardNameHint')}
@ -469,6 +475,7 @@ const WizardStep1 = ({
placeholder="Routes, Products, Orders"
value={wizardName}
autoFocus
invalid={!!wizardNameError}
onChange={(e) => onWizardNameChange(e.target.value.replace(/\s/g, ''))}
/>
</FormItem>