From 3aa74318c2f8b5766c5cacf6aa3e84e69c37da38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:33:50 +0300 Subject: [PATCH] =?UTF-8?q?Wizard=20Export=20=C3=96zelli=C4=9Fi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Wizard/IListFormWizardAppService.cs | 4 + .../ListForms/ListFormWizardAppService.cs | 251 ++++++++++++++++++ .../Migrations/LanguagesData.json | 36 +++ ui/src/services/wizard.service.ts | 27 +- ui/src/views/admin/listForm/wizard/Wizard.tsx | 91 ++++++- .../listForm/wizard/WizardFileManager.tsx | 42 ++- .../admin/listForm/wizard/WizardStep1.tsx | 9 +- 7 files changed, 451 insertions(+), 9 deletions(-) diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs index c5b3f758..8fee504d 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs @@ -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> GetFiles(); Task GetFile(string fileName); Task DeleteFile(string fileName); + + /// Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur. + Task ExportFile(string fileName); } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index 3ac4cfe3..354888d4 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -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( } } + + /// + /// 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. + /// + 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."); + } + } + + /// + /// Wizard'in uretecegi menu kodunun bir baskasiyla catismadigini dogrular. + /// + /// 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. + /// + /// + /// Duzenleme akisi once cagirdigi icin (seed dosyasi ve wizard'in + /// olusturdugu menu kaydi silinir) ayni ad ile yeniden deploy edilebilir. + /// + /// + 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."); + } + } + /// /// Wizard seed dosyalarinin yazilacagi wizard dizinini dondurur; kapsam aktif tenant'i izler /// (host baglaminda host/wizard, tenant baglaminda tenants/{tenantId}/wizard). @@ -831,6 +895,193 @@ public class ListFormWizardAppService( return SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id, SeedPathResolver.WizardFolder); } + /// + /// 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. + /// + /// Custom yolunda: wizard/{dosya}.json, custom/{component}.json (bagimliliklari ile birlikte) + /// ve bu component'lerin data source'larinin isaret ettigi crud/{entity}.json dosyalari. + /// List yolunda: wizard/{dosya}.json, SelectCommand'a karsilik gelen + /// {sql|postgres}/{object|execute}/{nesne}.sql ve varsa crud/{nesne}.json. + /// + /// + [HttpGet("api/app/list-form-wizard/export")] + public async Task ExportFile(string fileName) + { + var filePath = ResolveWizardSeedFilePath(fileName); + var json = await File.ReadAllTextAsync(filePath); + var seed = JsonSerializer.Deserialize(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(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"); + } + + /// + /// 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. + /// + private async Task CollectCustomComponentEntriesAsync(string scopeRoot, string? componentName, Dictionary entries) + { + var pending = new Queue(); + var visited = new HashSet(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(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); + } + } + } + } + + /// + /// 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. + /// + private void CollectListComponentEntries(string scopeRoot, ListFormWizardDto? wizard, Dictionary 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); + } + + /// Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur. + 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; + } + + /// + /// 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. + /// + private static bool TryAddSeedEntry(string scopeRoot, string relativePath, Dictionary 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> GetTableColumnNamesAsync(string dataSourceCode, SelectCommandTypeEnum commandType, string selectCommand) { if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure) diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 62eb4931..bb4f980b 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -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", diff --git a/ui/src/services/wizard.service.ts b/ui/src/services/wizard.service.ts index 67ffe2ff..daac2eca 100644 --- a/ui/src/services/wizard.service.ts +++ b/ui/src/services/wizard.service.ts @@ -26,4 +26,29 @@ export const deleteWizardFile = (fileName: string) => method: 'DELETE', url: `/api/app/list-form-wizard/file`, params: { fileName }, - }) \ No newline at end of file + }) + +/** + * 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({ + 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) +} \ No newline at end of file diff --git a/ui/src/views/admin/listForm/wizard/Wizard.tsx b/ui/src/views/admin/listForm/wizard/Wizard.tsx index c36f00ab..09b007e9 100644 --- a/ui/src/views/admin/listForm/wizard/Wizard.tsx +++ b/ui/src/views/admin/listForm/wizard/Wizard.tsx @@ -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([]) + 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([]) 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} diff --git a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx index c7b4f956..ab15f91c 100644 --- a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx @@ -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([]) const [loading, setLoading] = useState(false) const [deletingFile, setDeletingFile] = useState(null) + const [exportingFile, setExportingFile] = useState(null) const [confirm, setConfirm] = useState(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( + + {translate('::App.ListForm.WizardFileExportError')}: {err?.message ?? 'Unknown error'} + , + { placement: 'bottom-end' }, + ) + } finally { + setExportingFile(null) + } + } + const handleDeleteConfirm = async () => { if (!confirm) return setDeletingFile(confirm.fileName) @@ -251,6 +280,17 @@ const WizardFileManager = () => { )} +