diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index 961ca119..06d22b42 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -3322,6 +3322,14 @@ Wizard Manager'ın kendi aksiyonları: liste/kart görünümü, arama, düzenlem silme, DB Migrate, Export (zip), Import (diff + rollback). Yetkiler `App.Listforms.Wizard{,.Create,.Update,.Delete,.Export,.Import}`. +Listede her ekranın üst menü zinciri (`WizardFileInfoDto.MenuPath`, Menu tablosundan; kayıt yoksa +wizard'ın `MenuParentCode` cevabı) görünür. Bağlı wizard'lar üst ekranın altında gruplanır +(`Dependencies`): List yolunda alt formlar, Custom yolunda component `Dependencies` zinciri — +export kapanışıyla aynı kural; wizard'ı olmayan ara component atlanır. Kök, hiçbir wizard'ın +bağımlılığı olmayan dosyadır; ortak bir alt form her üst ekranın altında tekrar görünür. +Gruplar `+`/`−` ile açılıp kapanır (varsayılan açık); arama sırasında eşleşme gizli kalmasın diye +hepsi açık gösterilir. + ### 9.9 Numaratör — `Sequence` (`App.Definitions.Sequence`) "Sipariş numarası otomatik gelsin", "talep numarası her yıl 1'den başlasın" istekleri için kod diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs index a985845a..61059452 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardSeedFileDto.cs @@ -381,4 +381,16 @@ public class WizardFileInfoDto /// Menu kaydina yazilan URL; Custom yolunda bilesenin rota yoludur. public string MenuUrl { get; set; } + + /// + /// Ekranin menu agacindaki ust menuleri, kokten baslayarak; her oge menunun dil anahtaridir. + /// Menusuz wizard'da bostur. + /// + public List MenuPath { get; set; } = []; + + /// + /// Bu wizard'in dogrudan bagli oldugu diger wizard dosyalari: List yolunda alt formlar, Custom + /// yolunda component bagimliliklari. Yalnizca kapsamda wizard dosyasi olanlar listelenir. + /// + public List Dependencies { get; set; } = []; } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index 4315641a..67d9e31c 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -593,6 +593,12 @@ public class ListFormWizardAppService( if (!Directory.Exists(outputPath)) return result; + var seeds = new Dictionary(StringComparer.OrdinalIgnoreCase); + var menus = (await repoMenu.GetListAsync()) + .Where(m => !string.IsNullOrWhiteSpace(m.Code)) + .GroupBy(m => m.Code, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName)) { var fileName = Path.GetFileName(file); @@ -600,6 +606,9 @@ public class ListFormWizardAppService( { var json = await File.ReadAllTextAsync(file); var seed = WizardSeedFileDto.FromSeedJson(json); + if (seed?.Wizard != null) + seeds[fileName] = seed; + result.Add(new WizardFileInfoDto { FileName = fileName, @@ -611,6 +620,7 @@ public class ListFormWizardAppService( CustomComponentName = seed?.Wizard?.CustomComponentName ?? string.Empty, MenuUrl = seed?.Wizard?.MenuUrl ?? string.Empty, CreatedAt = seed?.Wizard?.SeededAt.ToString("s") ?? string.Empty, + MenuPath = seed?.Wizard != null ? ResolveMenuPath(seed.Wizard, menus) : [], HasInsertedRecords = seed?.InsertedRecords != null && (seed.InsertedRecords.LanguageKeys.Count > 0 || seed.InsertedRecords.PermissionGroupNames.Count > 0 || @@ -626,12 +636,138 @@ public class ListFormWizardAppService( } } + await FillWizardDependenciesAsync(result, seeds); + // CreatedAt sortable ("s") bicimindedir; metin karsilastirmasi da kronolojiktir. return [.. result .OrderByDescending(x => x.CreatedAt, StringComparer.Ordinal) .ThenBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)]; } + /// + /// Ekranin ust menu zinciri, kokten baslayarak. Menu tablosu esastir — ekran sonradan baska bir + /// menuye tasinmis olabilir; menu kaydi hic yoksa wizard'in ust menu cevabina duser. + /// + private static List ResolveMenuPath(ListFormWizardDto wizard, Dictionary menus) + { + if (!wizard.CreateMenu) + return []; + + if (!menus.TryGetValue(wizard.ResolveMenuCode(), out var menu)) + { + return string.IsNullOrWhiteSpace(wizard.MenuParentCode) ? [] : [wizard.MenuParentCode.Trim()]; + } + + var path = new List(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase) { menu.Code }; + + // Bozuk bir ParentCode dongusu listeyi kilitlemesin diye ziyaret edilenler takip edilir. + while (!string.IsNullOrWhiteSpace(menu.ParentCode) + && visited.Add(menu.ParentCode) + && menus.TryGetValue(menu.ParentCode, out var parent)) + { + path.Insert(0, string.IsNullOrWhiteSpace(parent.DisplayName) ? parent.Code : parent.DisplayName); + menu = parent; + } + + return path; + } + + /// + /// Her wizard'in dogrudan bagli oldugu diger wizard dosyalarini doldurur. Bagimlilik kurallari + /// export kapanisiyla aynidir (): List yolunda alt formlar, + /// Custom yolunda component zinciri. Custom zincirinde wizard'i olmayan ara component'ler + /// atlanarak altlarindaki wizard'lara ulasilir; aksi halde grup ara halkada kopardi. + /// + private async Task FillWizardDependenciesAsync(List files, Dictionary seeds) + { + var fileByListFormCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + var fileByComponent = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (fileName, seed) in seeds) + { + if (seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom) + { + if (!string.IsNullOrWhiteSpace(seed.Wizard.CustomComponentName)) + fileByComponent.TryAdd(seed.Wizard.CustomComponentName.Trim(), fileName); + } + else + { + fileByListFormCode.TryAdd(seed.Wizard.ResolveListFormCode(), fileName); + } + } + + var componentDependencies = fileByComponent.Count > 0 + ? await new WizardExportCollector(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id)) + .ReadCustomComponentDependenciesAsync() + : []; + + foreach (var file in files) + { + if (!seeds.TryGetValue(file.FileName, out var seed)) + continue; + + var dependencies = seed.Wizard.ComponentKind == WizardComponentKindEnum.Custom + ? ResolveComponentDependencyFiles(seed.Wizard.CustomComponentName, componentDependencies, fileByComponent) + : ReadSubFormCodes(seed) + .Select(code => fileByListFormCode.GetValueOrDefault(code)) + .OfType(); + + file.Dependencies = [.. dependencies + .Where(d => !string.Equals(d, file.FileName, StringComparison.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + } + } + + private static IEnumerable ReadSubFormCodes(WizardSeedFileDto seed) + { + SubFormDto[] subForms; + try + { + subForms = seed.ListForm?.SubFormsDto ?? [.. seed.Wizard.SubForms ?? []]; + } + catch (JsonException) + { + return []; + } + + return subForms + .Select(s => s?.Code?.Trim()) + .Where(code => !string.IsNullOrWhiteSpace(code)) + .OfType(); + } + + private static List ResolveComponentDependencyFiles( + string componentName, + Dictionary> componentDependencies, + Dictionary fileByComponent) + { + var result = new List(); + if (string.IsNullOrWhiteSpace(componentName)) + return result; + + var visited = new HashSet(StringComparer.OrdinalIgnoreCase) { componentName.Trim() }; + var pending = new Queue(componentDependencies.GetValueOrDefault(componentName.Trim()) ?? []); + + while (pending.Count > 0) + { + var name = pending.Dequeue(); + if (!visited.Add(name)) + continue; + + if (fileByComponent.TryGetValue(name, out var fileName)) + { + result.Add(fileName); + continue; + } + + foreach (var next in componentDependencies.GetValueOrDefault(name) ?? []) + pending.Enqueue(next); + } + + return result; + } + public async Task GetFile(string fileName) { var filePath = ResolveWizardSeedFilePath(fileName); diff --git a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs index f1d21a53..fa7bc9be 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/WizardExportCollector.cs @@ -262,6 +262,25 @@ public sealed class WizardExportCollector(string scopeRoot) items[relativePath] = new WizardExportItem(relativePath, "Custom component tanımı", null, content); } + /// + /// Component adi -> dogrudan bagimliliklari. Wizard listesi gruplamayi export'un izledigi + /// zincirle ayni kaynaktan kurar; boylece listede birlikte gorunenler arsive de birlikte girer. + /// + public async Task>> ReadCustomComponentDependenciesAsync() + { + var rows = await ReadCustomComponentRowsAsync(); + if (rows == null) + return new(StringComparer.OrdinalIgnoreCase); + + return rows.ToDictionary( + x => x.Key, + x => ParseSeedRowList(GetRowText(x.Value, "Dependencies")) + .Where(d => !string.IsNullOrWhiteSpace(d)) + .Select(d => d.Trim()) + .ToList(), + StringComparer.OrdinalIgnoreCase); + } + private async Task>?> ReadCustomComponentRowsAsync() { var seedFile = await ReadCustomComponentFileAsync(); diff --git a/ui/src/proxy/admin/wizard/models.ts b/ui/src/proxy/admin/wizard/models.ts index 85959322..1aea9069 100644 --- a/ui/src/proxy/admin/wizard/models.ts +++ b/ui/src/proxy/admin/wizard/models.ts @@ -135,6 +135,10 @@ export interface WizardFileInfoDto { componentKind: WizardComponentKindEnum customComponentName: string menuUrl: string + /** Üst menü zinciri (kökten), menülerin dil anahtarları. */ + menuPath: string[] + /** Doğrudan bağlı diğer wizard dosyaları: alt formlar / component bağımlılıkları. */ + dependencies: string[] } export interface WizardInsertedRecordsDto { diff --git a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx index 600ea096..73ee4cd6 100644 --- a/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardFileManager.tsx @@ -22,6 +22,10 @@ import { FaFileImport, FaTh, FaList, + FaSitemap, + FaLevelUpAlt, + FaPlusSquare, + FaMinusSquare, } from 'react-icons/fa' import DbMigrateButton from '@/components/shared/DbMigrateButton' import { @@ -55,6 +59,77 @@ interface ConfirmState { wizardName: string } +interface WizardTreeNode { + file: WizardFileInfoDto + children: WizardTreeNode[] +} + +/** + * Bağlı wizard'ları üst ekranın altında toplar. Kök, hiçbir wizard'ın bağımlılığı olmayan + * dosyadır; birden fazla ekranın kullandığı bir alt form her birinin altında görünür. Döngüdeki + * dosyaların kökü olmadığı için hiç çizilmeyenler sonda kök olarak eklenir. + */ +const buildWizardTree = (files: WizardFileInfoDto[]): WizardTreeNode[] => { + const byName = new Map(files.map((f) => [f.fileName.toLowerCase(), f])) + const childNames = new Set( + files.flatMap((f) => (f.dependencies ?? []).map((d) => d.toLowerCase())), + ) + const rendered = new Set() + + const build = (file: WizardFileInfoDto, path: Set): WizardTreeNode => { + const key = file.fileName.toLowerCase() + rendered.add(key) + const nextPath = new Set(path).add(key) + const children = (file.dependencies ?? []) + .map((d) => byName.get(d.toLowerCase())) + .filter((c): c is WizardFileInfoDto => !!c && !nextPath.has(c.fileName.toLowerCase())) + .map((c) => build(c, nextPath)) + return { file, children } + } + + const roots = files + .filter((f) => !childNames.has(f.fileName.toLowerCase())) + .map((f) => build(f, new Set())) + // Döngünün bir üyesi çizildiğinde diğerleri de çizilir; kontrol her adımda tekrarlanır. + for (const f of files) { + if (!rendered.has(f.fileName.toLowerCase())) roots.push(build(f, new Set())) + } + return roots +} + +/** Aramada bir düğüm, kendisi ya da altındakilerden biri eşleşiyorsa kalır; grup bağlamı korunur. */ +const filterWizardTree = ( + nodes: WizardTreeNode[], + matches: (f: WizardFileInfoDto) => boolean, +): WizardTreeNode[] => + nodes.flatMap((node) => { + const children = filterWizardTree(node.children, matches) + return matches(node.file) || children.length > 0 ? [{ ...node, children }] : [] + }) + +interface WizardTreeRow { + file: WizardFileInfoDto + depth: number + /** Kökten gelen yol; ortak alt form her üst ekranın altında ayrı açılıp kapanabilsin diye. */ + key: string + hasChildren: boolean +} + +/** Kapalı grupların altı satıra dökülmez. */ +const flattenWizardTree = ( + nodes: WizardTreeNode[], + collapsed: Set, + depth = 0, + prefix = '', +): WizardTreeRow[] => + nodes.flatMap((node) => { + const key = `${prefix}${node.file.fileName}` + const row = { file: node.file, depth, key, hasChildren: node.children.length > 0 } + return collapsed.has(key) + ? [row] + : [row, ...flattenWizardTree(node.children, collapsed, depth + 1, `${key}/`)] + }) + const WizardFileManager = () => { const navigate = useNavigate() const { translate } = useLocalization() @@ -91,15 +166,42 @@ const WizardFileManager = () => { const canExport = checkPermission(WIZARD_PERMISSION.EXPORT) const canImport = checkPermission(WIZARD_PERMISSION.IMPORT) - const filteredFiles = useMemo(() => { + const menuPathText = useCallback( + (f: WizardFileInfoDto) => (f.menuPath ?? []).map((key) => translate('::' + key)).join(' › '), + [translate], + ) + + const tree = useMemo(() => { + const nodes = buildWizardTree(files) const q = search.trim().toLowerCase() - if (!q) return files - return files.filter( + if (!q) return nodes + return filterWizardTree( + nodes, (f) => (f.wizardName ?? '').toLowerCase().includes(q) || - (f.listFormCode ?? '').toLowerCase().includes(q), + (f.listFormCode ?? '').toLowerCase().includes(q) || + (f.customComponentName ?? '').toLowerCase().includes(q) || + menuPathText(f).toLowerCase().includes(q), ) - }, [files, search]) + }, [files, search, menuPathText]) + + const [collapsed, setCollapsed] = useState>(new Set()) + // Arama sırasında eşleşme kapalı bir grubun içinde kalmasın diye gruplar açık gösterilir. + const effectiveCollapsed = useMemo( + () => (search.trim() ? new Set() : collapsed), + [collapsed, search], + ) + const toggleGroup = (key: string) => + setCollapsed((prev) => { + const next = new Set(prev) + if (!next.delete(key)) next.add(key) + return next + }) + + const tableRows = useMemo( + () => flattenWizardTree(tree, effectiveCollapsed), + [tree, effectiveCollapsed], + ) const loadFiles = useCallback(async () => { setLoading(true) @@ -281,6 +383,67 @@ const WizardFileManager = () => { ) + const renderKindBadge = (f: WizardFileInfoDto) => { + const isCustom = f.componentKind === WizardComponentKindEnum.Custom + return ( + + {isCustom ? : } + {translate( + isCustom ? '::App.WizardStepComponent.CustomComponent' : '::App.Listforms.Listform', + )} + + ) + } + + const renderMenuPath = (f: WizardFileInfoDto, className?: string) => { + const text = menuPathText(f) + if (!text) return null + return ( + + + {text} + + ) + } + + const componentName = (f: WizardFileInfoDto) => + f.componentKind === WizardComponentKindEnum.Custom ? f.customComponentName : f.listFormCode + + /** Alt satırlardaki bağlantı işareti; derinlik kadar içeri itilir. */ + const renderDepthMarker = (depth: number) => + depth > 0 && ( + + ) + + /** Alt ekranı olan satırın aç/kapa düğmesi; olmayanlarda hizayı koruyan boşluk. */ + const renderGroupToggle = (row: Pick) => { + if (!row.hasChildren) return + const isCollapsed = effectiveCollapsed.has(row.key) + return ( + + ) + } + return ( @@ -384,7 +547,7 @@ const WizardFileManager = () => { loading={loading} className={classNames('mt-4', loading && 'min-h-[240px]')} > - {filteredFiles.length === 0 && !loading && ( + {tree.length === 0 && !loading && (

@@ -395,9 +558,15 @@ const WizardFileManager = () => { {viewMode === 'card' ? (

- {filteredFiles.map((f) => { + {tree.map((node) => { + const f = node.file const Icon = navigationIcon[f.menuIcon] ?? FaDatabase const isCustom = f.componentKind === WizardComponentKindEnum.Custom + const prefix = `${f.fileName}/` + const dependencyCount = flattenWizardTree(node.children, new Set(), 1, prefix).length + const children = effectiveCollapsed.has(f.fileName) + ? [] + : flattenWizardTree(node.children, effectiveCollapsed, 1, prefix) return (
{ {f.fileName}
- {/* 3. satır: yol ayrımı (Custom / List), layout ve menü adresi */} + {/* 3. satır: yol ayrımı (Custom / List) ve bileşen kodu */}
- - {isCustom ? : } - {translate( - isCustom - ? '::App.WizardStepComponent.CustomComponent' - : '::App.Listforms.Listform', - )} - -
- {isCustom ? f.customComponentName : f.listFormCode} -
+ {renderKindBadge(f)} +
{componentName(f)}
+ {/* 4. satır: ekranın bulunduğu menü */} + {renderMenuPath(f, 'pt-1 text-xs text-gray-600 dark:text-gray-300')} +
{/* Custom yolunda layout kavramı yoktur. */} {!isCustom && f.defaultLayout && ( @@ -466,6 +622,40 @@ const WizardFileManager = () => { f, 'flex items-center justify-end gap-1 pt-1 border-t border-gray-200 dark:border-gray-700', )} + + {/* Bağlı wizard'lar (alt formlar / component bağımlılıkları) aynı kartta kalır. */} + {dependencyCount > 0 && ( +
+
+ {renderGroupToggle({ key: f.fileName, hasChildren: true })} + {translate('::App.Platform.Dependencies')} ({dependencyCount}) +
+ {children.map((row) => { + const { file: child, depth, key } = row + const ChildIcon = navigationIcon[child.menuIcon] ?? FaDatabase + return ( +
+ {renderDepthMarker(depth)} + {renderGroupToggle(row)} + +
+
+ {child.wizardName || child.fileName} +
+ {renderMenuPath(child, 'text-[11px] text-gray-500')} +
+ {renderActions(child, 'flex shrink-0 items-center gap-0.5')} +
+ ) + })} +
+ )}
) })} @@ -487,6 +677,9 @@ const WizardFileManager = () => { {translate('::App.WizardStepComponent.ComponentType')} + + {translate('::App.Platform.MenuParent')} + {translate('::App.WizardStepComponent.MenuUrl')} @@ -496,16 +689,24 @@ const WizardFileManager = () => { - {filteredFiles.map((f) => { + {tableRows.map((row) => { + const { file: f, depth, key } = row const Icon = navigationIcon[f.menuIcon] ?? FaDatabase - const isCustom = f.componentKind === WizardComponentKindEnum.Custom return (
+ {renderDepthMarker(depth)} + {renderGroupToggle(row)} { {f.fileName} - {isCustom ? f.customComponentName : f.listFormCode} + {componentName(f)} - - - {isCustom ? : } - {translate( - isCustom - ? '::App.WizardStepComponent.CustomComponent' - : '::App.Listforms.Listform', - )} - + {renderKindBadge(f)} + + {renderMenuPath(f)}