ListFormWizarda Custom Component yapısı eklendi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-18 11:00:53 +03:00
parent a0121f8e11
commit 7870ebb7bc
18 changed files with 1594 additions and 537 deletions

View file

@ -6,11 +6,28 @@ namespace Sozsoft.Platform.ListForms;
public class ListFormWizardDto
{
/// <summary>
/// Wizard'in yol ayrimi. Varsayilan <see cref="WizardComponentKindEnum.List"/> oldugu icin
/// bu alani icermeyen eski seed dosyalari mevcut ListForm davranisiyla calismaya devam eder.
/// Seed dosyasinda ilk alan olarak yer alir; dosyaya bakan kisi once yolu gorur.
/// </summary>
public WizardComponentKindEnum ComponentKind { get; set; } = WizardComponentKindEnum.List;
public string WizardName { get; set; }
public string ListFormCode { get; set; }
public string MenuCode { get; set; }
public int MenuOrder { get; set; }
/// <summary>Custom yolunda menuye baglanacak CustomComponent'in adi.</summary>
public string CustomComponentName { get; set; }
/// <summary>
/// Menu kaydina yazilan URL; Custom yolunda ayni zamanda rotanin tek kaynagidir.
/// List yolunda <c>/admin/list/{menuCode}</c> olarak hesaplanir, Custom yolunda
/// secilen CustomComponent'in RoutePath degeri buraya yazilir.
/// </summary>
public string MenuUrl { get; set; }
public bool IsTenant { get; set; }
public bool IsBranch { get; set; }
public bool IsOrganizationUnit { get; set; }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Collections.Generic;
using Sozsoft.Platform.Enums;
namespace Sozsoft.Platform.ListForms;
@ -56,4 +57,13 @@ public class WizardFileInfoDto
public string MenuIcon { get; set; }
public string CreatedAt { get; set; }
public bool HasInsertedRecords { get; set; }
/// <summary>Wizard'in List mi Custom Component mi urettigi; listede rozet olarak gosterilir.</summary>
public WizardComponentKindEnum ComponentKind { get; set; }
/// <summary>Custom yolunda bagli CustomComponent adi.</summary>
public string CustomComponentName { get; set; }
/// <summary>Menu kaydina yazilan URL; Custom yolunda bilesenin rota yoludur.</summary>
public string MenuUrl { get; set; }
}

View file

@ -86,6 +86,11 @@ public class ListFormWizardAppService(
input.ListFormCode = listFormCode;
input.MenuCode = code;
// 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;
var customRoutePath = NormalizeCustomRoutePath(input, isCustomComponent);
var titleLangKey = $"{listFormCode}.Title";
var nameLangKey = code;
var descLangKey = $"{listFormCode}.Desc";
@ -95,8 +100,13 @@ public class ListFormWizardAppService(
//Dil - Language Keys
await CreateLangKey(nameLangKey, input.LanguageTextMenuEn, input.LanguageTextMenuTr, inserted);
await CreateLangKey(titleLangKey, input.LanguageTextTitleEn, input.LanguageTextTitleTr, inserted);
await CreateLangKey(descLangKey, input.LanguageTextDescEn, input.LanguageTextDescTr, inserted);
// Title/Desc anahtarları yalnızca ListForm tarafından tüketilir; Custom yolunda
// öksüz kayıt bırakmamak için üretilmez.
if (!isCustomComponent)
{
await CreateLangKey(titleLangKey, input.LanguageTextTitleEn, input.LanguageTextTitleTr, inserted);
await CreateLangKey(descLangKey, input.LanguageTextDescEn, input.LanguageTextDescTr, inserted);
}
//Permission Group
var groupName = input.PermissionGroupName ?? PlatformConsts.AppName;
@ -133,19 +143,23 @@ public class ListFormWizardAppService(
("Note", WizardConsts.LangKeyNote),
};
// Custom yolunda kayıt üzerinde CRUD yapılmadığı için yalnızca read izni üretilir.
var permissionNames = new List<string> { permRead.Name };
foreach (var (suffix, langKey) in childPermissions)
if (!isCustomComponent)
{
var permissionName = $"{code}.{suffix}";
permissionNames.Add(permissionName);
foreach (var (suffix, langKey) in childPermissions)
{
var permissionName = $"{code}.{suffix}";
permissionNames.Add(permissionName);
if (existingPerms.Any(a => a.Name == permissionName))
continue;
if (existingPerms.Any(a => a.Name == permissionName))
continue;
await repoPerm.InsertAsync(
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
autoSave: false);
inserted.PermissionNames.Add(permissionName);
await repoPerm.InsertAsync(
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
autoSave: false);
inserted.PermissionNames.Add(permissionName);
}
}
// Permission Grants - Bulk Insert (only missing ones)
@ -196,6 +210,11 @@ public class ListFormWizardAppService(
var maxChildOrder = menuQueryable.Where(a => a.ParentCode == menuParent.Code).Select(a => (int?)a.Order).Max() ?? 0;
var menuOrder = input.MenuOrder > 0 ? input.MenuOrder : maxChildOrder + 1;
input.MenuOrder = menuOrder;
// Menünün açacağı adres: Custom yolunda CustomComponent'in kendi rotası (bu rota
// mapCustomComponentRoutes tarafından zaten kayıtlıdır), aksi halde ListForm adresi.
// Seed dosyasından da görülebilmesi için input'a geri yazılır.
var menuUrl = isCustomComponent ? customRoutePath : WizardConsts.MenuUrl(code);
input.MenuUrl = menuUrl;
var existingMenu = await AsyncExecuter.FirstOrDefaultAsync(menuQueryable.Where(a => a.Code == code));
if (existingMenu == null)
{
@ -209,18 +228,28 @@ public class ListFormWizardAppService(
Target = null,
ElementId = null,
CssClass = null,
Url = WizardConsts.MenuUrl(code),
Url = menuUrl,
RequiredPermissionName = permRead.Name,
Order = menuOrder,
}, autoSave: false);
inserted.MenuCodes.Add(code);
}
else if (existingMenu.Order != menuOrder)
else if (existingMenu.Order != menuOrder || existingMenu.Url != menuUrl)
{
existingMenu.Order = menuOrder;
existingMenu.Url = menuUrl;
await repoMenu.UpdateAsync(existingMenu, autoSave: false);
}
// Custom yolunda ListForm ekosistemi üretilmez: veri kaynağı, ListForm, alanlar,
// subform/widget/workflow kayıtlarının hiçbirine ihtiyaç yoktur.
if (isCustomComponent)
{
await _languageTextAppService.ClearRedisCacheAsync();
await SaveWizardSeedFileAsync(input, false, false, inserted);
return;
}
//DataSource kodu ile iligli kod blogu
var dataSourceQueryable = await repoDataSource.GetQueryableAsync();
var dataSource = await AsyncExecuter.FirstOrDefaultAsync(dataSourceQueryable.Where(a => a.Code == input.DataSourceCode));
@ -572,6 +601,36 @@ public class ListFormWizardAppService(
}
}
/// <summary>
/// Custom yolunda component adı ve rota zorunludur. Rota <see cref="ListFormWizardDto.MenuUrl"/>
/// üzerinden taşınır ve CustomComponent sözleşmesiyle (<c>CreateUpdateCustomComponentDto.RoutePath</c>)
/// aynı şekilde '/' ile başlamalıdır; aksi halde menü, router'a kayıtlı olmayan bir adrese bağlanır.
/// List yolunda bileşen adı temizlenerek seed dosyasında artık veri bırakılmaz.
/// </summary>
private static string? NormalizeCustomRoutePath(ListFormWizardDto input, bool isCustomComponent)
{
if (!isCustomComponent)
{
input.CustomComponentName = null;
return null;
}
var componentName = input.CustomComponentName?.Trim();
if (string.IsNullOrWhiteSpace(componentName))
throw new UserFriendlyException("Custom Component seçimi zorunludur.");
var routePath = input.MenuUrl?.Trim();
if (string.IsNullOrWhiteSpace(routePath))
throw new UserFriendlyException($"'{componentName}' bileşeninin rota yolu (Route Path) boş olamaz.");
if (!routePath.StartsWith('/'))
routePath = "/" + routePath;
input.CustomComponentName = componentName;
input.MenuUrl = routePath;
return routePath;
}
public async Task<List<WizardFileInfoDto>> GetFiles()
{
var outputPath = ResolveWizardSeedOutputPath();
@ -594,6 +653,9 @@ public class ListFormWizardAppService(
DefaultLayout = seed?.Wizard?.DefaultLayout ?? string.Empty,
MenuIcon = seed?.Wizard?.MenuIcon ?? string.Empty,
ListFormCode = seed?.Wizard?.ListFormCode ?? string.Empty,
ComponentKind = seed?.Wizard?.ComponentKind ?? WizardComponentKindEnum.List,
CustomComponentName = seed?.Wizard?.CustomComponentName ?? string.Empty,
MenuUrl = seed?.Wizard?.MenuUrl ?? string.Empty,
CreatedAt = fileName.Length >= 12 ? fileName[..12] : fileName,
HasInsertedRecords = seed?.InsertedRecords != null &&
(seed.InsertedRecords.LanguageKeys.Count > 0 ||
@ -675,8 +737,10 @@ public class ListFormWizardAppService(
var listFormCode = seed.Wizard?.ListFormCode;
var menuParentCode = seed.Wizard?.MenuParentCode;
// ListForm ve alanları her zaman sil (wizard bunları her zaman oluşturur)
if (!string.IsNullOrWhiteSpace(listFormCode))
// ListForm ve alanlarını sil. Custom yolunda wizard hiç ListForm üretmediği için
// bu blok atlanır; aksi halde aynı kodu taşıyan bir List wizard'ının ListForm'u silinirdi.
var isCustomComponent = seed.Wizard?.ComponentKind == WizardComponentKindEnum.Custom;
if (!isCustomComponent && !string.IsNullOrWhiteSpace(listFormCode))
{
var lf = await repoListForm.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode);
if (lf != null) await repoListForm.DeleteAsync(lf, autoSave: true);

View file

@ -281,6 +281,73 @@ public class MenuAppService : CrudAppService<
return entityDtos;
}
/// <summary>
/// Menüyü tüm alanlarıyla günceller ve görünen adın dil metinlerini de yazar.
/// Kod (Code) dil anahtarı ve yetki eşleşmesi olduğu için değiştirilemez; gönderilen
/// değer yok sayılıp mevcut kod korunur.
/// </summary>
public async Task<MenuDto> UpdateWithLanguageKeyTextAsync(Guid id, MenuDto input)
{
await CheckUpdatePolicyAsync();
var menu = await _menuRepository.GetAsync(id);
// Kod değiştirilemez: dil anahtarı, alt menülerin ParentCode'u ve permission
// kayıtları bu değere bağlıdır.
input.Code = menu.Code;
await UpsertMenuLanguageTextsAsync(menu.Code, input.MenuTextEn, input.MenuTextTr);
return await base.UpdateAsync(id, input);
}
/// <summary>
/// Menü koduna karşılık gelen dil anahtarını ve EN/TR metinlerini oluşturur veya günceller.
/// </summary>
private async Task UpsertMenuLanguageTextsAsync(string code, string menuTextEn, string menuTextTr)
{
var keyExists = await _repositoryKey.AnyAsync(
a => a.Key == code &&
a.ResourceName == PlatformConsts.AppName);
if (!keyExists)
{
await _repositoryKey.InsertAsync(new LanguageKey
{
Key = code,
ResourceName = PlatformConsts.AppName
}, autoSave: true);
}
foreach (var (culture, value) in new[] { ("en", menuTextEn), ("tr", menuTextTr) })
{
if (value == null)
continue;
var existing = await _repositoryText.FirstOrDefaultAsync(
a => a.CultureName == culture &&
a.Key == code &&
a.ResourceName == PlatformConsts.AppName);
if (existing != null)
{
existing.Value = value;
await _repositoryText.UpdateAsync(existing, autoSave: true);
}
else
{
await _repositoryText.InsertAsync(new LanguageText
{
Key = code,
CultureName = culture,
Value = value,
ResourceName = PlatformConsts.AppName
}, autoSave: true);
}
}
await _languageTextAppService.ClearRedisCacheAsync();
}
public async Task<MenuDto> CreateWithLanguageKeyTextAsync(MenuDto input)
{
await CheckCreatePolicyAsync();

View file

@ -16254,6 +16254,12 @@
"en": "Clear All",
"tr": "Tümünü Temizle"
},
{
"resourceName": "Platform",
"key": "App.WizardStep2.DeployUrl",
"en": "Deploy URL",
"tr": "Yayınlama URL'si"
},
{
"resourceName": "Platform",
"key": "App.WizardStep2.ListFormCodeHint",
@ -16674,6 +16680,120 @@
"en": "Deploying to server…",
"tr": "Sunucuya deploy ediliyor…"
},
{
"resourceName": "Platform",
"key": "App.Step4Log.LinkingCustomComponent",
"en": "Linking custom component '{0}'…",
"tr": "'{0}' özel bileşeni bağlanıyor…"
},
{
"resourceName": "Platform",
"key": "App.Platform.EditMenu",
"en": "Edit Menu",
"tr": "Menüyü Düzenle"
},
{
"resourceName": "Platform",
"key": "App.Platform.Status",
"en": "Status",
"tr": "Durum"
},
{
"resourceName": "Platform",
"key": "App.WizardStep1.RootMenu",
"en": "Root menu (no parent)",
"tr": "Kök menü (üst menü yok)"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.MenuUrl",
"en": "Menu URL",
"tr": "Menü URL"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.MenuUrlFromComponent",
"en": "Comes from the selected custom component",
"tr": "Seçilen özel bileşenden geliyor"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.MenuUrlFromList",
"en": "Derived from the list form to be generated",
"tr": "Üretilecek liste formundan türetiliyor"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.ComponentType",
"en": "Component Type",
"tr": "Bileşen Tipi"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.ComponentTypeHint",
"en": "Decides whether the wizard generates a list form or links an existing custom component",
"tr": "Wizard'ın liste formu mu üreteceğini yoksa mevcut bir özel bileşene mi bağlanacağını belirler"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.ListComponent",
"en": "List Component",
"tr": "Liste Bileşeni"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.ListComponentHint",
"en": "Generates the platform list form: data source, fields, sub forms, widgets and workflow",
"tr": "Platform liste formunu üretir: veri kaynağı, alanlar, alt formlar, widget'lar ve iş akışı"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.CustomComponent",
"en": "Custom Component",
"tr": "Özel Bileşen"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.CustomComponentHint",
"en": "Links the menu to a component built in Developer Kit; no list form is generated",
"tr": "Menüyü Developer Kit'te üretilmiş bir bileşene bağlar; liste formu üretilmez"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.ComponentName",
"en": "Component Name",
"tr": "Bileşen Adı"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.SelectComponent",
"en": "Select component…",
"tr": "Bileşen seçin…"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.RoutePath",
"en": "Route Path",
"tr": "Rota Yolu"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.RoutePathHint",
"en": "The menu item created after deploy opens this address",
"tr": "Deploy sonrası oluşan menü bu adresi açar"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.Preview",
"en": "Preview",
"tr": "Önizleme"
},
{
"resourceName": "Platform",
"key": "App.WizardStepComponent.NoComponents",
"en": "No active custom component with a route path was found. Create one in Developer Kit → Components first.",
"tr": "Rota yolu tanımlı aktif bir özel bileşen bulunamadı. Önce Developer Kit → Bileşenler ekranından oluşturun."
},
{
"resourceName": "Platform",
"key": "App.Step4Log.Completed",

View file

@ -115,8 +115,13 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
await BackfillMenuParentShortNameAsync(seedFile.Wizard);
// Zaten seeded mi kontrol et (ListForm var mı?)
if (await _repoListForm.AnyAsync(a => a.ListFormCode == seedFile.Wizard.ListFormCode))
// Zaten seeded mi kontrol et. Custom yolunda ListForm üretilmediği için
// idempotency menü kaydı üzerinden değerlendirilir.
var alreadySeeded = seedFile.Wizard.ComponentKind == WizardComponentKindEnum.Custom
? await _repoMenu.AnyAsync(a => a.Code == ResolveMenuCode(seedFile.Wizard))
: await _repoListForm.AnyAsync(a => a.ListFormCode == seedFile.Wizard.ListFormCode);
if (alreadySeeded)
{
_logger.LogInformation("[{File}] '{WizardName}' already exists, skipped.", fileName, wizardName);
continue;
@ -163,10 +168,17 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
var permImportName = $"{code}.Import";
var permNoteName = $"{code}.Note";
// Yol ayrımı: Custom yolunda ListForm ekosistemi üretilmez (AppService.Create ile aynı davranış).
var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom;
var customRoutePath = NormalizeSeedCustomRoutePath(input);
// Dil - Language Keys
await CreateLangKeyAsync(nameLangKey, input.LanguageTextMenuEn, input.LanguageTextMenuTr);
await CreateLangKeyAsync(titleLangKey, input.LanguageTextTitleEn, input.LanguageTextTitleTr);
await CreateLangKeyAsync(descLangKey, input.LanguageTextDescEn, input.LanguageTextDescTr);
if (!isCustomComponent)
{
await CreateLangKeyAsync(titleLangKey, input.LanguageTextTitleEn, input.LanguageTextTitleTr);
await CreateLangKeyAsync(descLangKey, input.LanguageTextDescEn, input.LanguageTextDescTr);
}
// Permission Group
var groupName = input.PermissionGroupName ?? AppName;
@ -189,35 +201,33 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
permRead = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both), autoSave: true);
var permCreate = existingPerms.FirstOrDefault(a => a.Name == permCreateName);
if (permCreate == null)
permCreate = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: true);
// Custom yolunda kayıt üzerinde CRUD yapılmadığı için yalnızca read izni üretilir.
if (!isCustomComponent)
{
if (!existingPerms.Any(a => a.Name == permCreateName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: true);
var permUpdate = existingPerms.FirstOrDefault(a => a.Name == permUpdateName);
if (permUpdate == null)
permUpdate = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, true, MultiTenancySides.Both), autoSave: true);
if (!existingPerms.Any(a => a.Name == permUpdateName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, true, MultiTenancySides.Both), autoSave: true);
var permDelete = existingPerms.FirstOrDefault(a => a.Name == permDeleteName);
if (permDelete == null)
permDelete = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, true, MultiTenancySides.Both), autoSave: true);
if (!existingPerms.Any(a => a.Name == permDeleteName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, true, MultiTenancySides.Both), autoSave: true);
var permExport = existingPerms.FirstOrDefault(a => a.Name == permExportName);
if (permExport == null)
permExport = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: true);
if (!existingPerms.Any(a => a.Name == permExportName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: true);
var permImport = existingPerms.FirstOrDefault(a => a.Name == permImportName);
if (permImport == null)
permImport = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permImportName, permRead.Name, WizardConsts.LangKeyImport, true, MultiTenancySides.Both), autoSave: true);
if (!existingPerms.Any(a => a.Name == permImportName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permImportName, permRead.Name, WizardConsts.LangKeyImport, true, MultiTenancySides.Both), autoSave: true);
var permNote = existingPerms.FirstOrDefault(a => a.Name == permNoteName);
if (permNote == null)
permNote = await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permNoteName, permRead.Name, WizardConsts.LangKeyNote, true, MultiTenancySides.Both), autoSave: true);
if (!existingPerms.Any(a => a.Name == permNoteName))
await _repoPerm.InsertAsync(new PermissionDefinitionRecord(
Guid.NewGuid(), groupName, permNoteName, permRead.Name, WizardConsts.LangKeyNote, true, MultiTenancySides.Both), autoSave: true);
}
// // Permission Grants - Admin role için, sadece eksik olanları ekle
// var existingGrants = await _permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName);
@ -264,6 +274,14 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
var maxChildOrder = menuQueryable.Where(a => a.ParentCode == menuParent.Code).Select(a => (int?)a.Order).Max() ?? 0;
var menuOrder = input.MenuOrder > 0 ? input.MenuOrder : maxChildOrder + 1;
input.MenuOrder = menuOrder;
// Menünün açacağı adres. Custom yolunda normalize edilmis rota, List yolunda
// MenuUrl alani (tasimayan eski dosyalar icin AppService.Create ile aynı kural).
var menuUrl = isCustomComponent
? customRoutePath
: !string.IsNullOrWhiteSpace(input.MenuUrl)
? input.MenuUrl.Trim()
: WizardConsts.MenuUrl(code);
input.MenuUrl = menuUrl;
var existingMenu = await _repoMenu.FirstOrDefaultAsync(a => a.Code == code);
if (existingMenu == null)
{
@ -277,17 +295,22 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
Target = null,
ElementId = null,
CssClass = null,
Url = WizardConsts.MenuUrl(code),
Url = menuUrl,
RequiredPermissionName = permRead.Name,
Order = menuOrder,
}, autoSave: true);
}
else if (existingMenu.Order != menuOrder)
else if (existingMenu.Order != menuOrder || existingMenu.Url != menuUrl)
{
existingMenu.Order = menuOrder;
existingMenu.Url = menuUrl;
await _repoMenu.UpdateAsync(existingMenu, autoSave: true);
}
// Custom yolunda ListForm ekosistemi üretilmez.
if (isCustomComponent)
return;
// DataSource
var existingDataSource = await _repoDataSource.FirstOrDefaultAsync(a => a.Code == input.DataSourceCode);
if (existingDataSource == null)
@ -527,6 +550,34 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency
}
}
/// <summary>
/// Wizard'in menu kodunu, ApplyWizardSeedAsync ile ayni kurala gore cozer.
/// Custom yolunda idempotency kontrolu ListForm yerine bu kod uzerinden yapilir.
/// </summary>
private static string ResolveMenuCode(ListFormWizardDto input)
{
return string.IsNullOrWhiteSpace(input.MenuCode)
? WizardConsts.WizardKey(input.WizardName.Trim())
: input.MenuCode.Trim();
}
/// <summary>
/// Custom yolunda menunun acacagi rotayi normalize eder. Rota MenuUrl uzerinden
/// tasinir; bos ise menu hicbir yere gitmeyecegi icin seed dosyasi hatali kabul edilir.
/// </summary>
private static string NormalizeSeedCustomRoutePath(ListFormWizardDto input)
{
if (input.ComponentKind != WizardComponentKindEnum.Custom)
return null;
var routePath = input.MenuUrl?.Trim();
if (string.IsNullOrWhiteSpace(routePath))
throw new InvalidOperationException(
$"'{input.WizardName}' custom component wizard'inda Route Path bos olamaz.");
return routePath.StartsWith('/') ? routePath : "/" + routePath;
}
private async Task BackfillMenuParentShortNameAsync(ListFormWizardDto input)
{
if (string.IsNullOrWhiteSpace(input.MenuParentCode) ||

View file

@ -0,0 +1,14 @@
namespace Sozsoft.Platform.Enums;
/// <summary>
/// Wizard'in hangi yolu urettigini belirler. Varsayilan <see cref="List"/> oldugu icin
/// bu alani icermeyen eski wizard seed dosyalari da mevcut davranisla calismaya devam eder.
/// </summary>
public enum WizardComponentKindEnum
{
/// <summary>Platformun dinamik ListForm ekosistemi uretilir (varsayilan).</summary>
List = 0,
/// <summary>DeveloperKit'te uretilmis bir CustomComponent'e menu baglanir; ListForm uretilmez.</summary>
Custom = 1,
}

View file

@ -0,0 +1,36 @@
{
"EntityName": "AbpRoles",
"GeneratedAt": "2026-08-18T07:26:49.511788Z",
"Endpoints": [
{
"Method": "POST",
"Path": "/api/app/crudendpoint/AbpRoles",
"OperationType": "Create",
"IsActive": true
},
{
"Method": "DELETE",
"Path": "/api/app/crudendpoint/AbpRoles/{id}",
"OperationType": "Delete",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpRoles/{id}",
"OperationType": "GetById",
"IsActive": true
},
{
"Method": "GET",
"Path": "/api/app/crudendpoint/AbpRoles",
"OperationType": "GetList",
"IsActive": true
},
{
"Method": "PUT",
"Path": "/api/app/crudendpoint/AbpRoles/{id}",
"OperationType": "Update",
"IsActive": true
}
]
}

View file

@ -27,11 +27,23 @@ export interface ListFormWizardColumnGroupDto {
items: ListFormWizardColumnItemDto[]
}
/** Wizard'ın yol ayrımı; backend `WizardComponentKindEnum` ile birebir eşleşir. */
export enum WizardComponentKindEnum {
List = 0,
Custom = 1,
}
export interface ListFormWizardDto {
componentKind: WizardComponentKindEnum
wizardName: string
listFormCode: string
menuCode: string
menuOrder: number
customComponentName: string
/** Menünün açtığı adres; Custom yolunda seçilen bileşenin rota yolu. */
menuUrl: string
isTenant: boolean
isBranch: boolean
isOrganizationUnit: boolean
@ -90,6 +102,9 @@ export interface WizardFileInfoDto {
menuIcon: string
createdAt: string
hasInsertedRecords: boolean
componentKind: WizardComponentKindEnum
customComponentName: string
menuUrl: string
}
export interface WizardInsertedRecordsDto {

View file

@ -31,6 +31,17 @@ export class MenuService {
{ apiName: this.apiName },
)
/** Menüyü tüm alanlarıyla günceller; görünen adın EN/TR dil metinlerini de yazar. */
updateWithLanguageKeyText = (id: string, input: MenuDto) =>
apiService.fetchData<MenuDto, MenuDto>(
{
method: 'PUT',
url: `/api/app/menu/${id}/with-language-key-text`,
data: input,
},
{ apiName: this.apiName },
)
createWithLanguageKeyText = (input: MenuDto) =>
apiService.fetchData<MenuDto, MenuDto>(
{

View file

@ -35,7 +35,9 @@ import { Container } from '@/components/shared'
import { sqlDataTypeToDbType } from '../edit/options'
import { useStoreActions } from '@/store/store'
import { deleteWizardFile, getWizardFile, postListFormWizard } from '@/services/wizard.service'
import { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import WizardStepComponent from './WizardStepComponent'
import WizardStepCustomComponent from './WizardStepCustomComponent'
import {
ListFormWorkflowCriteriaDto,
SubFormDto,
@ -52,6 +54,9 @@ const initialValues: ListFormWizardDto = {
listFormCode: '',
menuCode: '',
menuOrder: 0,
componentKind: WizardComponentKindEnum.List,
customComponentName: '',
menuUrl: '',
isTenant: false,
isBranch: false,
isOrganizationUnit: false,
@ -174,18 +179,29 @@ const step1ValidationSchema = Yup.object().shape({
languageTextMenuTr: Yup.string().required(),
})
/**
* Custom Component yolunda Settings adımı hiç gösterilmez; bu alanların zorunluluğu
* da kalkmalıdır, aksi halde deploy'a kadar temizlenemeyen hatalar taşınır.
*/
const requiredForListComponent = <T extends Yup.Schema>(schema: T) =>
schema.when('componentKind', {
is: (kind: number) => kind !== WizardComponentKindEnum.Custom,
then: (s: T) => s.required(),
otherwise: (s: T) => s.optional(),
})
const step2ValidationSchema = Yup.object().shape({
listFormCode: Yup.string().required(),
languageTextTitleEn: Yup.string().required(),
languageTextTitleTr: Yup.string().required(),
languageTextDescEn: Yup.string().required(),
languageTextDescTr: Yup.string().required(),
dataSourceCode: Yup.string().required(),
listFormCode: requiredForListComponent(Yup.string()),
languageTextTitleEn: requiredForListComponent(Yup.string()),
languageTextTitleTr: requiredForListComponent(Yup.string()),
languageTextDescEn: requiredForListComponent(Yup.string()),
languageTextDescTr: requiredForListComponent(Yup.string()),
dataSourceCode: requiredForListComponent(Yup.string()),
dataSourceConnectionString: Yup.string(),
selectCommandType: Yup.string().required(),
selectCommand: Yup.string().required(),
keyFieldName: Yup.string().required(),
keyFieldDbSourceType: Yup.number().required(),
selectCommandType: requiredForListComponent(Yup.string()),
selectCommand: requiredForListComponent(Yup.string()),
keyFieldName: requiredForListComponent(Yup.string()),
keyFieldDbSourceType: requiredForListComponent(Yup.number()),
isTenant: Yup.boolean(),
isBranch: Yup.boolean(),
isOrganizationUnit: Yup.boolean(),
@ -225,34 +241,57 @@ const removeTenantGroupItems = (groups: WizardGroup[]) =>
items: group.items.filter((item) => !isTenantColumn(item.fieldName)),
}))
/** Yeni wizard açıldığında ön seçilen veri kaynağı kodu. */
const DEFAULT_DATA_SOURCE_CODE = 'Default'
// Auto-derive listFormCode from wizardName
const deriveListFormCode = (name: string) => {
const sanitized = name.replace(/\s/g, '')
return sanitized ? `App.Wizard.${sanitized}` : ''
}
/**
* 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.
*/
const STEP = {
component: 0,
customComponent: 1,
menu: 2,
settings: 3,
fields: 4,
subForms: 5,
widgets: 6,
workflow: 7,
todo: 8,
tree: 9,
gantt: 10,
scheduler: 11,
deploy: 12,
} as const
/** Yalnızca ilgili layout seçiliyken gösterilen adımlar. */
const OPTIONAL_STEPS = [
{
id: 6,
id: STEP.todo,
flag: 'todo',
translationKey: '::App.Platform.TabTodo',
fallbackTitle: 'Todo / Kanban',
},
{
id: 7,
id: STEP.tree,
flag: 'tree',
translationKey: '::App.Platform.TabTree',
fallbackTitle: 'Tree',
},
{
id: 8,
id: STEP.gantt,
flag: 'gantt',
translationKey: '::App.Platform.TabGantt',
fallbackTitle: 'Gantt',
},
{
id: 9,
id: STEP.scheduler,
flag: 'scheduler',
translationKey: '::App.Platform.TabScheduler',
fallbackTitle: 'Scheduler',
@ -445,9 +484,23 @@ const Wizard = () => {
setIsLoadingDataSource(true)
const response = await getDataSources()
if (response.data?.items) {
setDataSourceList(
response.data.items.map((item: any) => ({ value: item.code, label: item.code })),
)
const options: SelectBoxOption[] = response.data.items.map((item: any) => ({
value: item.code,
label: item.code,
}))
setDataSourceList(options)
// Yeni wizard'da veri kaynağı elle seçilmesin; platform varsayılanı otomatik gelir.
// Edit modunda kayıtlı değer sonradan yükleniyor, burada dokunulmaz.
if (!isEditMode && !formikRef.current?.values.dataSourceCode) {
const defaultOption = options.find(
(o) => String(o.value).toLowerCase() === DEFAULT_DATA_SOURCE_CODE.toLowerCase(),
)
if (defaultOption) {
formikRef.current?.setFieldValue('dataSourceCode', defaultOption.value)
setCurrentDataSource(String(defaultOption.value))
}
}
}
setIsLoadingDataSource(false)
}
@ -512,6 +565,10 @@ const Wizard = () => {
wizardName: w.wizardName ?? '',
listFormCode: w.listFormCode ?? '',
menuCode: w.menuCode ?? '',
// Bu alanları içermeyen eski seed dosyaları List yolunda açılır.
componentKind: w.componentKind ?? WizardComponentKindEnum.List,
customComponentName: w.customComponentName ?? '',
menuUrl: w.menuUrl ?? '',
menuOrder:
w.menuOrder && w.menuOrder > 0
? w.menuOrder
@ -768,10 +825,30 @@ const Wizard = () => {
setCurrentStep(targetStep)
}
const handleNext = () => validateAndGo(step1FieldNames, 1)
const handleNext2 = () => validateAndGo(step2FieldNames, 2)
/** Menu adimindan sonra List yolunda Settings, Custom yolunda dogrudan Deploy gelir. */
const handleMenuNext = (isCustom: boolean) =>
validateAndGo(step1FieldNames, isCustom ? STEP.deploy : STEP.settings)
const handleNext2 = () => validateAndGo(step2FieldNames, STEP.fields)
const handleBack = () => setCurrentStep(0)
const handleBack = () => setCurrentStep(STEP.menu)
/**
* Yol ayrımı değişince karşı yolun taşıdığı veriler temizlenir; aksi halde
* List'ten Custom'a geçip geri dönen kullanıcı, seed dosyasına artık alan yazar.
*/
const handleComponentKindChange = (kind: WizardComponentKindEnum) => {
const formik = formikRef.current
formik?.setFieldValue('componentKind', kind)
if (kind !== WizardComponentKindEnum.Custom) {
formik?.setFieldValue('customComponentName', '')
formik?.setFieldValue('menuUrl', '')
}
}
const handleCustomComponentChange = (name: string, routePath: string) => {
formikRef.current?.setFieldValue('customComponentName', name)
formikRef.current?.setFieldValue('menuUrl', routePath)
}
const { getConfig } = useStoreActions((a) => a.abpConfig)
const preventEnterSubmit = (event: KeyboardEvent<HTMLFormElement>) => {
@ -792,6 +869,7 @@ const Wizard = () => {
if (!formik) throw new Error(translate('::App.Platform.Unknown'))
const values = formik.values
const isCustom = values.componentKind === WizardComponentKindEnum.Custom
try {
// Edit modunda: önce eski dosyayı sil (DB kayıtlarını temizler)
@ -799,6 +877,28 @@ const Wizard = () => {
await deleteWizardFile(editFileName)
}
// Custom yolunda ListForm'a ait hiçbir koleksiyon gönderilmez.
if (isCustom) {
await postListFormWizard({
...values,
groups: [],
subForms: [],
widgets: [],
})
await getConfig(true)
toast.push(
<Notification type="success" duration={2000}>
{translate('::App.Platform.FormBilgileriKaydedildi')}
</Notification>,
{ placement: 'bottom-end' },
)
navigate(values.menuUrl, { replace: true })
return
}
await postListFormWizard({
...values,
groups: editingGroups.map((g) => ({
@ -880,33 +980,53 @@ const Wizard = () => {
}}
>
{({ touched, errors, values }) => {
// Custom Component yolunda ListForm'a özgü adımların (Settings, Fields,
// SubForms, Widgets, Workflow ve layout adımları) hiçbirine ihtiyaç yok.
const isCustomComponent = values.componentKind === WizardComponentKindEnum.Custom
const activeSteps = [
{ id: 0, title: translate('::App.VideoRoom.Menu') },
{
id: 1,
title: translate('::App.Setting'),
id: STEP.component,
title: translate('::App.WizardStepComponent.ComponentType'),
},
{
id: 2,
title: translate('::App.ListFormWizard.ListFormFields'),
},
{
id: 3,
title: translate('::App.ListFormEdit.SubForms'),
},
{
id: 4,
title: translate('::App.ListFormEdit.TabWidgets'),
},
{
id: 5,
title: translate('::App.ListFormEdit.TabWorkflow'),
},
...OPTIONAL_STEPS.filter((step) => values[step.flag]).map((step) => ({
id: step.id,
title: translate(step.translationKey) || step.fallbackTitle,
})),
{ id: 10, title: translate('::App.Platform.Deploy') },
...(isCustomComponent
? [
{
id: STEP.customComponent,
title: translate('::App.WizardStepComponent.CustomComponent'),
},
]
: []),
{ id: STEP.menu, title: translate('::App.VideoRoom.Menu') },
...(isCustomComponent
? []
: [
{
id: STEP.settings,
title: translate('::App.Setting'),
},
{
id: STEP.fields,
title: translate('::App.ListFormWizard.ListFormFields'),
},
{
id: STEP.subForms,
title: translate('::App.ListFormEdit.SubForms'),
},
{
id: STEP.widgets,
title: translate('::App.ListFormEdit.TabWidgets'),
},
{
id: STEP.workflow,
title: translate('::App.ListFormEdit.TabWorkflow'),
},
...OPTIONAL_STEPS.filter((step) => values[step.flag]).map((step) => ({
id: step.id,
title: translate(step.translationKey) || step.fallbackTitle,
})),
]),
{ id: STEP.deploy, title: translate('::App.Platform.Deploy') },
]
// Opsiyonel bir adım (todo/tree/gantt/scheduler) sonradan kapatılırsa
@ -946,8 +1066,35 @@ const Wizard = () => {
<Form onKeyDown={preventEnterSubmit}>
<FormContainer size={resolvedStep >= 2 ? undefined : 'sm'}>
{/* ─── Step 1: Basic Info ─────────────────────────────── */}
{resolvedStep === 0 && (
{/* ─── Step 1: Component Type (yol ayrımı) ─────────────── */}
{resolvedStep === STEP.component && (
<WizardStepComponent
values={values}
onKindChange={handleComponentKindChange}
translate={translate}
onNext={() =>
setCurrentStep(
values.componentKind === WizardComponentKindEnum.Custom
? STEP.customComponent
: STEP.menu,
)
}
/>
)}
{/* ─── Step 2: Custom Component seçimi ─────────────────── */}
{resolvedStep === STEP.customComponent && (
<WizardStepCustomComponent
values={values}
onCustomComponentChange={handleCustomComponentChange}
translate={translate}
onBack={() => setCurrentStep(STEP.component)}
onNext={() => setCurrentStep(STEP.menu)}
/>
)}
{/* ─── Step 3: Menu ───────────────────────────────────── */}
{resolvedStep === STEP.menu && (
<WizardStep1
values={values}
errors={errors}
@ -967,13 +1114,22 @@ const Wizard = () => {
onReloadMenu={getMenuList}
permissionGroupList={permissionGroupList}
isLoadingPermissionGroup={isLoadingPermissionGroup}
onNext={handleNext}
onBack={() =>
setCurrentStep(
values.componentKind === WizardComponentKindEnum.Custom
? STEP.customComponent
: STEP.component,
)
}
onNext={() =>
handleMenuNext(values.componentKind === WizardComponentKindEnum.Custom)
}
translate={translate}
/>
)}
{/* ─── Step 2: Data Settings ───────────────────────────── */}
{resolvedStep === 1 && (
{/* ─── Step 4: Data Settings ───────────────────────────── */}
{resolvedStep === STEP.settings && (
<WizardStep2
values={values}
errors={errors}
@ -1003,8 +1159,8 @@ const Wizard = () => {
/>
)}
{/* ─── Step 3: List Form Fields ───────────────────────────── */}
{resolvedStep === 2 && (
{/* ─── Step 5: List Form Fields ───────────────────────────── */}
{resolvedStep === STEP.fields && (
<WizardStep3
selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns}
@ -1013,36 +1169,36 @@ const Wizard = () => {
dbObjects={dbObjects}
dsCode={currentDataSource}
translate={translate}
onBack={() => setCurrentStep(1)}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(STEP.settings)}
onNext={() => setCurrentStep(STEP.subForms)}
/>
)}
{/* ─── Step 4: Sub Forms ───────────────────────────── */}
{resolvedStep === 3 && (
{/* ─── Step 6: Sub Forms ───────────────────────────── */}
{resolvedStep === STEP.subForms && (
<WizardStep4
subForms={subForms}
selectCommandColumns={selectCommandColumns}
translate={translate}
onChange={setSubForms}
onBack={() => setCurrentStep(2)}
onNext={() => setCurrentStep(4)}
onBack={() => setCurrentStep(STEP.fields)}
onNext={() => setCurrentStep(STEP.widgets)}
/>
)}
{/* ─── Step 5: Widgets ───────────────────────────── */}
{resolvedStep === 4 && (
{/* ─── Step 7: Widgets ───────────────────────────── */}
{resolvedStep === STEP.widgets && (
<WizardStep5
widgets={widgets}
translate={translate}
onChange={setWidgets}
onBack={() => setCurrentStep(3)}
onNext={() => setCurrentStep(5)}
onBack={() => setCurrentStep(STEP.subForms)}
onNext={() => setCurrentStep(STEP.workflow)}
/>
)}
{/* ─── Step 6: Workflow ───────────────────────────── */}
{resolvedStep === 5 && (
{/* ─── Step 8: Workflow ───────────────────────────── */}
{resolvedStep === STEP.workflow && (
<WizardStep6
listFormCode={values.listFormCode}
workflow={workflow}
@ -1051,13 +1207,13 @@ const Wizard = () => {
translate={translate}
onWorkflowChange={setWorkflow}
onCriteriaChange={setCriteria}
onBack={() => setCurrentStep(4)}
onBack={() => setCurrentStep(STEP.widgets)}
onNext={() => goToAdjacentStep(1)}
/>
)}
{/* ─── Step 7: Todo / Kanban ────────────────────── */}
{resolvedStep === 6 && values.todo && (
{/* ─── Step 9: Todo / Kanban ────────────────────── */}
{resolvedStep === STEP.todo && values.todo && (
<WizardStep7
selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns}
@ -1067,8 +1223,8 @@ const Wizard = () => {
/>
)}
{/* ─── Step 8: Tree ─────────────────────────────── */}
{resolvedStep === 7 && values.tree && (
{/* ─── Step 10: Tree ─────────────────────────────── */}
{resolvedStep === STEP.tree && values.tree && (
<WizardStep8
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1077,8 +1233,8 @@ const Wizard = () => {
/>
)}
{/* ─── Step 9: Gantt ────────────────────────────── */}
{resolvedStep === 8 && values.gantt && (
{/* ─── Step 11: Gantt ────────────────────────────── */}
{resolvedStep === STEP.gantt && values.gantt && (
<WizardStep9
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1087,8 +1243,8 @@ const Wizard = () => {
/>
)}
{/* ─── Step 10: Scheduler ───────────────────────── */}
{resolvedStep === 9 && values.scheduler && (
{/* ─── Step 12: Scheduler ───────────────────────── */}
{resolvedStep === STEP.scheduler && values.scheduler && (
<WizardStep10
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1097,8 +1253,8 @@ const Wizard = () => {
/>
)}
{/* ─── Step 11: Deploy ──────────────────────────── */}
{resolvedStep === 10 && (
{/* ─── Step 13: Deploy ──────────────────────────── */}
{resolvedStep === STEP.deploy && (
<WizardStep11
values={values}
wizardName={values.wizardName}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import classNames from 'classnames'
import { Badge, Button, Dialog, Input, Notification, toast } from '@/components/ui'
import { Button, Dialog, Input, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container'
import {
FaTrash,
@ -12,6 +12,10 @@ import {
FaSearch,
FaEdit,
FaCalendarAlt,
FaCube,
FaListUl,
FaLink,
FaExternalLinkAlt,
} from 'react-icons/fa'
import DbMigrateButton from '@/components/shared/DbMigrateButton'
import { deleteWizardFile, getWizardFiles } from '@/services/wizard.service'
@ -19,7 +23,7 @@ import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { WizardFileInfoDto } from '@/proxy/admin/wizard/models'
import { WizardComponentKindEnum, WizardFileInfoDto } from '@/proxy/admin/wizard/models'
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
interface ConfirmState {
@ -141,7 +145,9 @@ const WizardFileManager = () => {
className="flex items-center"
>
<FaPlus className="mr-1" />
<span className="hidden sm:inline">{translate('::App.ListFormWizard.AddNewRecord')}</span>
<span className="hidden sm:inline">
{translate('::App.ListFormWizard.AddNewRecord')}
</span>
</Button>
</div>
</div>
@ -165,6 +171,7 @@ const WizardFileManager = () => {
<div className="space-y-2">
{filteredFiles.map((f) => {
const Icon = navigationIcon[f.menuIcon] ?? FaDatabase
const isCustom = f.componentKind === WizardComponentKindEnum.Custom
return (
<div
key={f.fileName}
@ -172,15 +179,53 @@ const WizardFileManager = () => {
>
<div className="flex items-center gap-3 min-w-0">
<Icon className="text-indigo-400 shrink-0 text-3xl" />
<div className="min-w-0">
<div className="min-w-0 space-y-1">
{/* 1. satır: wizard adı */}
<div className="font-medium text-sm text-gray-800 dark:text-gray-200 truncate">
{f.wizardName || f.fileName} <Badge content={f.defaultLayout} />
{f.wizardName || f.fileName}
</div>
<div className="text-xs text-gray-400 flex gap-3 mt-0.5">
{/* 2. satır: dosya adı ve üretilen kod */}
<div className="flex flex-wrap items-center gap-x-2 text-xs text-gray-400">
<span className="truncate font-mono">{f.fileName}</span>
<span className="text-gray-300 dark:text-gray-600"></span>
<span className="truncate">
{f.fileName} - {f.listFormCode}
{isCustom ? f.customComponentName : f.listFormCode}
</span>
</div>
{/* 3. satır: yol ayrımı (Custom / List), layout ve menü adresi */}
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span
className={classNames(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium',
isCustom
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300',
)}
>
{isCustom ? <FaCube /> : <FaListUl />}
{translate(
isCustom
? '::App.WizardStepComponent.CustomComponent'
: '::App.WizardStepComponent.ListComponent',
)}
</span>
{/* Custom yolunda layout kavramı yoktur. */}
{!isCustom && f.defaultLayout && (
<span className="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{f.defaultLayout}
</span>
)}
{f.menuUrl && (
<span className="inline-flex min-w-0 items-center gap-1 rounded-full bg-gray-200 px-2 py-0.5 font-mono text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<FaLink className="shrink-0" />
<span className="truncate">{f.menuUrl}</span>
</span>
)}
</div>
</div>
</div>
@ -193,6 +238,19 @@ const WizardFileManager = () => {
<FaExclamationTriangle />
</span>
)}
{/* Menünün açtığı adres yeni sekmede açılır; adres yoksa buton anlamsızdır. */}
{f.menuUrl && (
<Button
size="sm"
variant="plain"
className="text-emerald-500 hover:bg-emerald-50 dark:hover:bg-emerald-900/20"
type="button"
title={translate('::App.Platform.OpenUrl')}
onClick={() => window.open(f.menuUrl, '_blank', 'noopener,noreferrer')}
>
<FaExternalLinkAlt />
</Button>
)}
<Button
size="sm"
variant="plain"

View file

@ -1,5 +1,4 @@
import { Button, FormItem, Input, Notification, Select, toast } from '@/components/ui'
import { MenuDto } from '@/proxy/menus/models'
import { SelectBoxOption } from '@/types/shared'
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
import { MenuItem } from '@/proxy/menus/menu'
@ -8,18 +7,18 @@ import { Field, FieldProps, FormikErrors, FormikTouched } from 'formik'
import { useEffect, useMemo, useState } from 'react'
import CreatableSelect from 'react-select/creatable'
import {
FaArrowLeft,
FaArrowRight,
FaChevronDown,
FaChevronRight,
FaEdit,
FaPlus,
FaTimes,
FaCheck,
FaTrash,
} from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { IconPickerField, MenuAddDialog } from '@/views/shared/MenuAddDialog'
import { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
// ─── Types (exported for Wizard.tsx) ─────────────────────────────────────────
@ -102,13 +101,8 @@ interface TreeNodeProps {
onSelect: (code: string) => void
expanded: Set<string>
onToggle: (code: string) => void
editingCode: string | null
editingValue: string
saving: boolean
onStartEdit: (code: string, currentName: string) => void
onEditChange: (v: string) => void
onSaveEdit: (node: MenuTreeNode & { id?: string }) => void
onCancelEdit: () => void
onStartEdit: (node: MenuTreeNode & { id?: string }) => void
onDelete: (node: MenuTreeNode & { id?: string }) => void
}
@ -119,19 +113,13 @@ function TreeNode({
onSelect,
expanded,
onToggle,
editingCode,
editingValue,
saving,
onStartEdit,
onEditChange,
onSaveEdit,
onCancelEdit,
onDelete,
}: TreeNodeProps) {
const hasChildren = node.children.length > 0
const isExpanded = expanded.has(node.code)
const isSelected = node.code === selectedCode
const isEditing = editingCode === node.code
const navigationIcon = useNavigationIcons()
const NodeIcon = node.icon ? navigationIcon[node.icon] : null
const { translate } = useLocalization()
@ -168,92 +156,50 @@ function TreeNode({
/>
)}
{isEditing ? (
<input
autoFocus
value={editingValue}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSaveEdit(node)
if (e.key === 'Escape') onCancelEdit()
}}
onClick={(e) => e.stopPropagation()}
className="flex-1 px-2 py-0.5 text-sm rounded border border-indigo-400 outline-none bg-white text-gray-800 dark:bg-gray-700 dark:text-gray-100"
/>
) : (
<span
className="flex-1 text-sm truncate cursor-pointer"
onClick={() => onSelect(node.code)}
>
{node.displayName}
</span>
)}
<span
className="flex-1 text-sm truncate cursor-pointer"
onClick={() => onSelect(node.code)}
>
{node.displayName}
</span>
<span
className={`text-xs shrink-0 ${isSelected ? 'text-indigo-200' : 'text-gray-400'}`}
onClick={() => !isEditing && onSelect(node.code)}
onClick={() => onSelect(node.code)}
>
{translate('::' + node.displayName)} ({node.order})
</span>
{isEditing ? (
<>
<Button
type="button"
disabled={saving}
onClick={(e) => {
e.stopPropagation()
onSaveEdit(node)
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaCheck className="text-xs" />}
className="!h-6 !w-6 !px-0 text-green-500 hover:!bg-transparent hover:text-green-600 disabled:opacity-40"
/>
<Button
type="button"
onClick={(e) => {
e.stopPropagation()
onCancelEdit()
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaTimes className="text-xs" />}
className="!h-6 !w-6 !px-0 text-gray-400 hover:!bg-transparent hover:text-gray-600"
/>
</>
) : (
<span className="shrink-0 flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
title={translate('::App.Listform.ListformField.Rename')}
onClick={(e) => {
e.stopPropagation()
onStartEdit(node.code, node.displayName)
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaEdit className="text-xs" />}
className={`!h-6 !w-6 !px-0 hover:!bg-transparent ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-indigo-500'}`}
/>
<Button
type="button"
title={translate('::App.Platform.Delete')}
onClick={(e) => {
e.stopPropagation()
onDelete(node)
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaTrash className="text-xs" />}
className={`!h-6 !w-6 !px-0 hover:!bg-transparent ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-red-500'}`}
/>
</span>
)}
<span className="shrink-0 flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="button"
disabled={saving}
title={translate('::App.Platform.EditMenu')}
onClick={(e) => {
e.stopPropagation()
onStartEdit(node)
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaEdit className="text-xs" />}
className={`!h-6 !w-6 !px-0 hover:!bg-transparent ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-indigo-500'}`}
/>
<Button
type="button"
disabled={saving}
title={translate('::App.Platform.Delete')}
onClick={(e) => {
e.stopPropagation()
onDelete(node)
}}
size="xs"
variant="plain"
shape="circle"
icon={<FaTrash className="text-xs" />}
className={`!h-6 !w-6 !px-0 hover:!bg-transparent ${isSelected ? 'text-indigo-200 hover:text-white' : 'text-gray-300 hover:text-red-500'}`}
/>
</span>
</div>
{isExpanded &&
@ -266,13 +212,8 @@ function TreeNode({
onSelect={onSelect}
expanded={expanded}
onToggle={onToggle}
editingCode={editingCode}
editingValue={editingValue}
saving={saving}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onSaveEdit={onSaveEdit}
onCancelEdit={onCancelEdit}
onDelete={onDelete}
/>
))}
@ -304,6 +245,8 @@ interface MenuTreeInlineProps {
isLoading: boolean
invalid?: boolean
onReload: () => void
/** Düzenleme dialogunu ilgili menü ile açar. */
onEditMenu: (node: MenuTreeNode & { id?: string }) => void
initialExpanded?: Set<string>
}
@ -315,6 +258,7 @@ function MenuTreeInline({
isLoading,
invalid,
onReload,
onEditMenu,
initialExpanded,
}: MenuTreeInlineProps) {
const [expanded, setExpanded] = useState<Set<string>>(() => initialExpanded ?? new Set())
@ -330,8 +274,6 @@ function MenuTreeInline({
return next
})
}, [value, rawItems])
const [editingCode, setEditingCode] = useState<string | null>(null)
const [editingValue, setEditingValue] = useState('')
const [saving, setSaving] = useState(false)
const { translate } = useLocalization()
@ -343,38 +285,10 @@ function MenuTreeInline({
return n
})
const handleStartEdit = (code: string, currentName: string) => {
setEditingCode(code)
setEditingValue(currentName)
}
const handleSaveEdit = async (node: MenuTreeNode & { id?: string }) => {
if (!editingValue.trim() || !node.id) return
setSaving(true)
try {
const original = rawItems.find((i) => i.code === node.code)
if (!original) return
await menuService.update(node.id, {
...original,
displayName: editingValue.trim(),
} as MenuDto)
onReload()
setEditingCode(null)
} catch (e: any) {
toast.push(<Notification title={e.message} type="danger" />, {
placement: 'bottom-end',
})
} finally {
setSaving(false)
}
}
const handleDelete = async (node: MenuTreeNode & { id?: string }) => {
if (!node.id) return
if (
!window.confirm(
`"${node.displayName}" ${translate('::App.WizardStep1.DeleteMenuConfirm')}`,
)
!window.confirm(`"${node.displayName}" ${translate('::App.WizardStep1.DeleteMenuConfirm')}`)
)
return
setSaving(true)
@ -404,21 +318,17 @@ function MenuTreeInline({
const sharedNodeProps = {
expanded,
onToggle: toggle,
editingCode,
editingValue,
saving,
onStartEdit: handleStartEdit,
onEditChange: setEditingValue,
onSaveEdit: handleSaveEdit,
onCancelEdit: () => setEditingCode(null),
onStartEdit: onEditMenu,
onDelete: handleDelete,
}
return (
<div
className={`rounded-lg border ${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-800 overflow-hidden`}
className={`flex h-full min-h-[24rem] flex-col rounded-lg border ${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} bg-white dark:bg-gray-800 overflow-hidden`}
>
<div className="h-96 overflow-y-auto py-1">
{/* Yükseklik sağdaki alan kolonuna göre esner; taşma bu alanda kaydırılır. */}
<div className="min-h-0 flex-1 overflow-y-auto py-1">
{isLoading ? (
<div className="px-4 py-3 text-sm text-gray-400">Loading</div>
) : enrichedNodes.length === 0 ? (
@ -466,6 +376,7 @@ export interface WizardStep1Props {
onReloadMenu: () => void
permissionGroupList: SelectBoxOption[]
isLoadingPermissionGroup: boolean
onBack: () => void
onNext: () => void
translate: (key: string) => string
}
@ -485,11 +396,27 @@ const WizardStep1 = ({
onReloadMenu,
permissionGroupList,
isLoadingPermissionGroup,
onBack,
onNext,
translate,
}: WizardStep1Props) => {
const [menuDialogOpen, setMenuDialogOpen] = useState(false)
const [menuDialogParentCode, setMenuDialogParentCode] = useState('')
// Dolu ise dialog düzenleme modunda açılır.
const [menuBeingEdited, setMenuBeingEdited] = useState<(MenuItem & { id?: string }) | null>(null)
/** Ağaç düğümünü ham menü kaydıyla eşleyip düzenleme dialogunu açar. */
const handleEditMenu = (node: MenuTreeNode & { id?: string }) => {
const original = rawMenuItems.find((item) => item.code === node.code)
if (!original) return
setMenuBeingEdited(original)
setMenuDialogOpen(true)
}
const closeMenuDialog = () => {
setMenuDialogOpen(false)
setMenuBeingEdited(null)
}
const menuDialogInitialOrder = useMemo(() => {
const maxOrder = rawMenuItems.reduce((max, item) => {
@ -500,6 +427,17 @@ const WizardStep1 = ({
return maxOrder + 100
}, [rawMenuItems])
/**
* Deploy'da menü kaydına yazılacak adres (backend: WizardConsts.MenuUrl / RoutePath).
* Custom yolunda bileşenin kendi rotası, List yolunda üretilecek ListForm adresi.
*/
const isCustomComponent = values.componentKind === WizardComponentKindEnum.Custom
const menuUrl = isCustomComponent
? values.menuUrl
: values.menuCode
? `/admin/list/${values.menuCode}`
: ''
const step1Missing = [
!wizardName && translate('::App.WizardStep1.WizardName'),
!values.menuCode && translate('::App.WizardStep1.MenuCode'),
@ -538,9 +476,11 @@ const WizardStep1 = ({
{/* Menu Code / Menu Text / Permission Group — 2-column grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
{/* Col 1 */}
<div>
<div className="flex flex-col">
{/* Menu Parent */}
<FormItem
/* FormItem'in içerik kutusu da esnesin ki ağaç sağ kolon yüksekliğini doldursun. */
className="flex flex-1 flex-col [&>div]:flex [&>div]:min-h-0 [&>div]:flex-1 [&>div]:flex-col"
label={translate('::App.Platform.MenuParent')}
invalid={false}
errorMessage={undefined}
@ -549,6 +489,7 @@ const WizardStep1 = ({
<Button
type="button"
onClick={() => {
setMenuBeingEdited(null)
setMenuDialogParentCode(
values.menuParentCode
? findRootCode(rawMenuItems, values.menuParentCode)
@ -591,6 +532,7 @@ const WizardStep1 = ({
isLoading={isLoadingMenu}
invalid={false}
onReload={onReloadMenu}
onEditMenu={handleEditMenu}
initialExpanded={
values.menuParentCode
? getAncestorCodes(rawMenuItems, values.menuParentCode)
@ -603,11 +545,19 @@ const WizardStep1 = ({
<MenuAddDialog
isOpen={menuDialogOpen}
onClose={() => setMenuDialogOpen(false)}
onClose={closeMenuDialog}
initialParentCode={menuDialogParentCode}
initialOrder={menuDialogInitialOrder}
rawItems={rawMenuItems}
onSaved={onMenuCreated}
editMenu={menuBeingEdited}
onSaved={async (menu) => {
// Düzenlemede seçili parent'ı değiştirmemek için yalnızca ağaç tazelenir.
if (menuBeingEdited) {
await onReloadMenu()
return
}
await onMenuCreated(menu)
}}
/>
</div>
@ -739,13 +689,34 @@ const WizardStep1 = ({
)}
</Field>
</FormItem>
{/* Deploy sonrası menü ikonunun açacağı adres; seçilen yola göre değişir. */}
<FormItem
label={translate('::App.WizardStepComponent.MenuUrl')}
extra={
<span className="ml-2 text-xs text-gray-400">
{translate(
isCustomComponent
? '::App.WizardStepComponent.MenuUrlFromComponent'
: '::App.WizardStepComponent.MenuUrlFromList',
)}
</span>
}
>
<div className="w-full rounded-md border border-gray-300 bg-gray-50 px-3 py-2 font-mono text-sm text-indigo-600 dark:border-gray-600 dark:bg-gray-800 dark:text-indigo-300">
{menuUrl || '—'}
</div>
</FormItem>
</div>
</div>
{/* ─── Fixed Footer ─────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-10 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 px-6 py-0 h-16 flex items-center">
<div className="flex items-center gap-3 w-full">
<div className="flex-1 flex items-center gap-2">
<Button variant="default" type="button" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<div className="flex-1 flex items-center justify-end gap-2">
{!step1CanGo && (
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">
{translate('::App.ListFormWizard.Required')}: {step1Missing.join(', ')}

View file

@ -13,7 +13,7 @@ import {
} from 'react-icons/fa'
import { WizardGroup } from './WizardStep3'
import { dbSourceTypeOptions, selectCommandTypeOptions } from '../edit/options'
import { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import {
ListFormWorkflowCriteriaDto,
SubFormDto,
@ -69,6 +69,39 @@ function buildLogSteps(
translate: (key: string, params?: Record<string, string | number>) => string,
): Omit<LogEntry, 'status'>[] {
const totalFields = groups.reduce((acc, g) => acc + g.items.length, 0)
// Custom yolunda veri kaynağı / ListForm / alan üretimi hiç çalışmaz.
if (values.componentKind === WizardComponentKindEnum.Custom) {
return [
{ id: 1, label: translate('::App.Step4Log.ValidatingConfig') },
{
id: 2,
label: translate('::App.Step4Log.CreatingMenu', { 0: values.menuCode }),
detail: `Parent: ${values.menuParentCode}`,
},
{
id: 3,
label: translate('::App.Step4Log.SavingLanguageTexts'),
detail: `EN: ${values.languageTextMenuEn} / TR: ${values.languageTextMenuTr}`,
},
{
id: 4,
label: translate('::App.Step4Log.ConfiguringPermission', {
0: values.permissionGroupName,
}),
},
{
id: 5,
label: translate('::App.Step4Log.LinkingCustomComponent', {
0: values.customComponentName,
}),
detail: values.menuUrl,
},
{ id: 8, label: translate('::App.Step4Log.Deploying') },
{ id: 9, label: translate('::App.Step4Log.Completed') },
]
}
return [
{ id: 1, label: translate('::App.Step4Log.ValidatingConfig') },
{
@ -259,6 +292,13 @@ const WizardStep11 = ({
)
const workflowItems = criteria.length > 0 ? criteria : (workflow.criteria ?? [])
/**
* Custom Component yolunda ListForm üretilmediği için layout, alanlar, subform,
* widget ve workflow özetlerinin hiçbiri anlamlı değildir; yalnızca menü ve
* bağlanan bileşen gösterilir.
*/
const isCustom = values.componentKind === WizardComponentKindEnum.Custom
return (
<div className="grid grid-cols-[3fr_2fr] gap-5 pb-24 items-start">
{/* ── Left: Summary ──────────────────────────────────────────── */}
@ -267,23 +307,14 @@ const WizardStep11 = ({
<Section title={translate('::App.WizardStep4.MenuInfo')}>
<Row label={translate('::App.WizardStep4.WizardName')} value={wizardName} />
<Row label={translate('::App.WizardStep1.MenuCode')} value={values.menuCode} />
<Row
label={translate('::App.Platform.MenuParent')}
value={values.menuParentCode}
/>
<Row label={translate('::App.Platform.MenuParent')} value={values.menuParentCode} />
<Row
label={translate('::App.WizardStep4.PermissionGroup')}
value={values.permissionGroupName}
/>
<Row label={translate('::App.Listform.ListformField.Icon')} value={values.menuIcon} />
<Row
label={translate('::App.WizardStep4.MenuTr')}
value={values.languageTextMenuTr}
/>
<Row
label={translate('::App.WizardStep4.MenuEn')}
value={values.languageTextMenuEn}
/>
<Row label={translate('::App.WizardStep4.MenuTr')} value={values.languageTextMenuTr} />
<Row label={translate('::App.WizardStep4.MenuEn')} value={values.languageTextMenuEn} />
<Row
label={translate('::App.WizardStep4.MenuParentTr')}
value={values.languageTextMenuParentTr}
@ -294,188 +325,202 @@ const WizardStep11 = ({
/>
</Section>
<Section title={translate('::App.WizardStep4.ListFormSettings')}>
<Row
label={translate('::App.WizardStep4.ListFormCode')}
value={values.listFormCode}
/>
<Row
label={translate('::App.WizardStep4.TitleTr')}
value={values.languageTextTitleTr}
/>
<Row
label={translate('::App.WizardStep4.TitleEn')}
value={values.languageTextTitleEn}
/>
<Row
label={translate('::App.WizardStep4.DescTr')}
value={values.languageTextDescTr}
/>
<Row
label={translate('::App.WizardStep4.DescEn')}
value={values.languageTextDescEn}
/>
<Row
label={translate('::App.Platform.DataSource')}
value={values.dataSourceCode}
/>
<Row
label={translate('::App.Listform.ListformField.ConnectionString')}
value={maskConnectionString(values.dataSourceConnectionString)}
/>
<Row
label={translate('::App.WizardStep2.SelectCommand')}
value={`${values.selectCommand} (${
selectCommandTypeOptions.find((o: any) => o.value === values.selectCommandType)
?.label ?? String(values.selectCommandType)
})`}
/>
<Row
label={translate('::App.WizardStep4.KeyField')}
value={`${values.keyFieldName} (${
dbSourceTypeOptions.find((o: any) => o.value === values.keyFieldDbSourceType)
?.label ?? String(values.keyFieldDbSourceType)
})`}
/>
</Section>
{isCustom ? (
<Section title={translate('::App.WizardStepComponent.CustomComponent')}>
<Row
label={translate('::App.WizardStepComponent.ComponentName')}
value={values.customComponentName}
/>
<Row
label={translate('::App.WizardStepComponent.RoutePath')}
value={values.menuUrl}
/>
</Section>
) : (
<Section title={translate('::App.WizardStep4.ListFormSettings')}>
<Row
label={translate('::App.WizardStep4.ListFormCode')}
value={values.listFormCode}
/>
<Row
label={translate('::App.WizardStep4.TitleTr')}
value={values.languageTextTitleTr}
/>
<Row
label={translate('::App.WizardStep4.TitleEn')}
value={values.languageTextTitleEn}
/>
<Row
label={translate('::App.WizardStep4.DescTr')}
value={values.languageTextDescTr}
/>
<Row
label={translate('::App.WizardStep4.DescEn')}
value={values.languageTextDescEn}
/>
<Row label={translate('::App.Platform.DataSource')} value={values.dataSourceCode} />
<Row
label={translate('::App.Listform.ListformField.ConnectionString')}
value={maskConnectionString(values.dataSourceConnectionString)}
/>
<Row
label={translate('::App.WizardStep2.SelectCommand')}
value={`${values.selectCommand} (${
selectCommandTypeOptions.find((o: any) => o.value === values.selectCommandType)
?.label ?? String(values.selectCommandType)
})`}
/>
<Row
label={translate('::App.WizardStep4.KeyField')}
value={`${values.keyFieldName} (${
dbSourceTypeOptions.find((o: any) => o.value === values.keyFieldDbSourceType)
?.label ?? String(values.keyFieldDbSourceType)
})`}
/>
</Section>
)}
</div>
<Section
title={translate('::App.WizardStep4.ColumnsAndFormLayout')}
badge={`${selectedColumns.size} ${translate('::App.Listform.ListformField.Column')} / ${editingFormFields.length} ${translate('::App.WizardStep4.EditingForm')}`}
>
<div className="mb-3 grid grid-cols-3 gap-2">
{[
{
label: translate('::App.WizardStep4.SelectedColumns'),
value: selectedColumns.size,
className: 'text-indigo-600 dark:text-indigo-400',
},
{
label: translate('::App.WizardStep4.EditingForm'),
value: editingFormFields.length,
className: 'text-emerald-600 dark:text-emerald-400',
},
{
label: translate('::App.WizardStep4.FormGroups'),
value: groups.length,
className: 'text-gray-700 dark:text-gray-200',
},
].map((item) => (
<div
key={item.label}
className="rounded-lg border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 px-3 py-2"
>
<div className={`text-lg font-semibold leading-none ${item.className}`}>
{item.value}
{!isCustom && (
<Section
title={translate('::App.WizardStep4.ColumnsAndFormLayout')}
badge={`${selectedColumns.size} ${translate('::App.Listform.ListformField.Column')} / ${editingFormFields.length} ${translate('::App.WizardStep4.EditingForm')}`}
>
<div className="mb-3 grid grid-cols-3 gap-2">
{[
{
label: translate('::App.WizardStep4.SelectedColumns'),
value: selectedColumns.size,
className: 'text-indigo-600 dark:text-indigo-400',
},
{
label: translate('::App.WizardStep4.EditingForm'),
value: editingFormFields.length,
className: 'text-emerald-600 dark:text-emerald-400',
},
{
label: translate('::App.WizardStep4.FormGroups'),
value: groups.length,
className: 'text-gray-700 dark:text-gray-200',
},
].map((item) => (
<div
key={item.label}
className="rounded-lg border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 px-3 py-2"
>
<div className={`text-lg font-semibold leading-none ${item.className}`}>
{item.value}
</div>
<div className="mt-1 text-[11px] text-gray-400 truncate">{item.label}</div>
</div>
<div className="mt-1 text-[11px] text-gray-400 truncate">{item.label}</div>
</div>
))}
</div>
))}
</div>
<div className="flex flex-col gap-2">
{groups.map((g) => (
<div
key={g.id}
className="overflow-hidden rounded-lg border border-gray-100 dark:border-gray-800"
>
<div className="flex items-center justify-between gap-3 bg-gray-50 dark:bg-gray-800 px-3 py-2">
<span className="text-xs font-semibold text-gray-700 dark:text-gray-200 truncate">
{g.caption || `(${translate('::App.Listform.ListformField.Group')})`}
</span>
<div className="flex items-center gap-1.5 text-[10px] text-gray-400 shrink-0">
<span className="rounded bg-white dark:bg-gray-900 px-1.5 py-0.5">
{g.items.length} {translate('::App.Platform.Field')}
</span>
<span className="rounded bg-white dark:bg-gray-900 px-1.5 py-0.5">
{g.colCount} {translate('::App.Listform.ListformField.Column')}
<div className="flex flex-col gap-2">
{groups.map((g) => (
<div
key={g.id}
className="overflow-hidden rounded-lg border border-gray-100 dark:border-gray-800"
>
<div className="flex items-center justify-between gap-3 bg-gray-50 dark:bg-gray-800 px-3 py-2">
<span className="text-xs font-semibold text-gray-700 dark:text-gray-200 truncate">
{g.caption || `(${translate('::App.Listform.ListformField.Group')})`}
</span>
<div className="flex items-center gap-1.5 text-[10px] text-gray-400 shrink-0">
<span className="rounded bg-white dark:bg-gray-900 px-1.5 py-0.5">
{g.items.length} {translate('::App.Platform.Field')}
</span>
<span className="rounded bg-white dark:bg-gray-900 px-1.5 py-0.5">
{g.colCount} {translate('::App.Listform.ListformField.Column')}
</span>
</div>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{g.items.length === 0 ? (
<span className="block px-3 py-2 text-xs text-gray-300 italic">
{translate('::App.WizardStep4.NoFields')}
</span>
) : (
g.items.map((item) => {
const meta = selectCommandColumns.find(
(c) => c.columnName === item.fieldName,
)
const isKeyField = item.fieldName === values.keyFieldName
const isPopupField = item.includeInEditingForm && !isKeyField
return (
<div
key={item.id}
className="grid grid-cols-[minmax(150px,1fr)_auto] items-center gap-3 px-3 py-1.5"
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400 truncate">
{item.fieldName}
</span>
{meta?.dataType && (
<span className="text-[10px] text-gray-400 truncate">
{meta.dataType}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
<span className="rounded bg-indigo-50 dark:bg-indigo-900/30 px-1.5 py-0.5 text-[10px] text-indigo-600 dark:text-indigo-300">
{translate('::App.WizardStep4.SelectedColumns')}
</span>
{isPopupField && (
<span className="rounded bg-emerald-50 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
{translate('::App.WizardStep4.EditingForm')}
</span>
)}
{isKeyField && (
<span className="rounded bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
{translate('::App.WizardStep4.KeyField')}
</span>
)}
<span className="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-400">
{item.editorType}
</span>
<span className="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-400">
span-{item.colSpan}
{item.isRequired && <span className="ml-1 text-red-400">*</span>}
</span>
</div>
</div>
)
})
)}
</div>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-800">
{g.items.length === 0 ? (
<span className="block px-3 py-2 text-xs text-gray-300 italic">
{translate('::App.WizardStep4.NoFields')}
</span>
) : (
g.items.map((item) => {
const meta = selectCommandColumns.find((c) => c.columnName === item.fieldName)
const isKeyField = item.fieldName === values.keyFieldName
const isPopupField = item.includeInEditingForm && !isKeyField
))}
{ungroupedSelectedColumns.length > 0 && (
<div className="rounded-lg border border-amber-100 dark:border-amber-900/40 bg-amber-50/50 dark:bg-amber-900/10 px-3 py-2">
<div className="mb-1.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
{translate('::App.WizardStep4.UngroupedColumns')}
</div>
<div className="flex flex-wrap gap-1.5">
{ungroupedSelectedColumns.map((col) => {
const meta = selectCommandColumns.find((c) => c.columnName === col)
return (
<div
key={item.id}
className="grid grid-cols-[minmax(150px,1fr)_auto] items-center gap-3 px-3 py-1.5"
<span
key={col}
className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800 bg-white dark:bg-gray-900 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-300"
>
<div className="flex min-w-0 items-center gap-2">
<span className="text-xs font-medium text-indigo-600 dark:text-indigo-400 truncate">
{item.fieldName}
{col}
{meta?.dataType && (
<span className="text-[10px] text-amber-500 opacity-70">
{meta.dataType}
</span>
{meta?.dataType && (
<span className="text-[10px] text-gray-400 truncate">
{meta.dataType}
</span>
)}
</div>
<div className="flex flex-wrap justify-end gap-1">
<span className="rounded bg-indigo-50 dark:bg-indigo-900/30 px-1.5 py-0.5 text-[10px] text-indigo-600 dark:text-indigo-300">
{translate('::App.WizardStep4.SelectedColumns')}
</span>
{isPopupField && (
<span className="rounded bg-emerald-50 dark:bg-emerald-900/30 px-1.5 py-0.5 text-[10px] text-emerald-600 dark:text-emerald-300">
{translate('::App.WizardStep4.EditingForm')}
</span>
)}
{isKeyField && (
<span className="rounded bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 text-[10px] text-amber-600 dark:text-amber-300">
{translate('::App.WizardStep4.KeyField')}
</span>
)}
<span className="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-400">
{item.editorType}
</span>
<span className="rounded bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-400">
span-{item.colSpan}
{item.isRequired && <span className="ml-1 text-red-400">*</span>}
</span>
</div>
</div>
)}
</span>
)
})
)}
})}
</div>
</div>
</div>
))}
{ungroupedSelectedColumns.length > 0 && (
<div className="rounded-lg border border-amber-100 dark:border-amber-900/40 bg-amber-50/50 dark:bg-amber-900/10 px-3 py-2">
<div className="mb-1.5 text-xs font-semibold text-amber-700 dark:text-amber-300">
{translate('::App.WizardStep4.UngroupedColumns')}
</div>
<div className="flex flex-wrap gap-1.5">
{ungroupedSelectedColumns.map((col) => {
const meta = selectCommandColumns.find((c) => c.columnName === col)
return (
<span
key={col}
className="inline-flex items-center gap-1 rounded-full border border-amber-200 dark:border-amber-800 bg-white dark:bg-gray-900 px-2 py-0.5 text-xs text-amber-700 dark:text-amber-300"
>
{col}
{meta?.dataType && (
<span className="text-[10px] text-amber-500 opacity-70">
{meta.dataType}
</span>
)}
</span>
)
})}
</div>
</div>
)}
</div>
</Section>
)}
</div>
</Section>
)}
{subForms.length > 0 && (
{!isCustom && subForms.length > 0 && (
<Section title={translate('::App.ListFormEdit.SubForms')} badge={subForms.length}>
<div className="flex flex-col gap-3">
{subForms.map((subForm, index) => (
@ -508,7 +553,7 @@ const WizardStep11 = ({
</Section>
)}
{widgets.length > 0 && (
{!isCustom && widgets.length > 0 && (
<Section title={translate('::App.ListFormEdit.TabWidgets')} badge={widgets.length}>
<div className="flex flex-col gap-3">
{widgets.map((widget, index) => (
@ -532,11 +577,8 @@ const WizardStep11 = ({
</Section>
)}
{(hasWorkflowFields || workflowItems.length > 0) && (
<Section
title={translate('::App.ListFormEdit.TabWorkflow')}
badge={workflowItems.length}
>
{!isCustom && (hasWorkflowFields || workflowItems.length > 0) && (
<Section title={translate('::App.ListFormEdit.TabWorkflow')} badge={workflowItems.length}>
<div className="grid grid-cols-2 gap-3">
<div>
<Row label="Approval User" value={workflow.approvalUserFieldName} />
@ -582,7 +624,7 @@ const WizardStep11 = ({
</Section>
)}
{values.todo && (
{!isCustom && values.todo && (
<Section title={translate('::App.Platform.TabTodo')}>
<div className="grid grid-cols-2 gap-x-4">
<Row
@ -640,47 +682,49 @@ const WizardStep11 = ({
{/* ── Right: Deploy ──────────────────────────────────────────── */}
<div className="sticky top-4 flex flex-col gap-3 max-h-[calc(100vh-200px)]">
{/* Stats */}
<div className="grid grid-cols-8 gap-2">
{[
{ label: translate('::App.Listform.ListformField.Group'), value: groups.length },
{ label: translate('::App.Platform.Field'), value: totalFields },
{
label: translate('::App.WizardStep4.EditingForm'),
value: editingFormFields.length,
},
{
label: translate('::App.Listform.ListformField.Column'),
value: selectedColumns.size,
},
{
label: translate('::App.ListFormEdit.SubForms'),
value: subForms.length,
},
{
label: translate('::App.ListFormEdit.TabWidgets'),
value: widgets.length,
},
{
label: translate('::App.ListFormEdit.TabWorkflow'),
value: workflowItems.length,
},
{
label: translate('::App.Platform.TabTodo'),
value: values.todo ? 1 : 0,
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 text-center shadow-sm"
>
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{s.value}
{/* Stats — Custom yolunda sayilacak ListForm artefakti yok. */}
{!isCustom && (
<div className="grid grid-cols-8 gap-2">
{[
{ label: translate('::App.Listform.ListformField.Group'), value: groups.length },
{ label: translate('::App.Platform.Field'), value: totalFields },
{
label: translate('::App.WizardStep4.EditingForm'),
value: editingFormFields.length,
},
{
label: translate('::App.Listform.ListformField.Column'),
value: selectedColumns.size,
},
{
label: translate('::App.ListFormEdit.SubForms'),
value: subForms.length,
},
{
label: translate('::App.ListFormEdit.TabWidgets'),
value: widgets.length,
},
{
label: translate('::App.ListFormEdit.TabWorkflow'),
value: workflowItems.length,
},
{
label: translate('::App.Platform.TabTodo'),
value: values.todo ? 1 : 0,
},
].map((s) => (
<div
key={s.label}
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-3 text-center shadow-sm"
>
<div className="text-2xl font-bold text-indigo-600 dark:text-indigo-400">
{s.value}
</div>
<div className="text-xs text-gray-400 mt-0.5">{s.label}</div>
</div>
<div className="text-xs text-gray-400 mt-0.5">{s.label}</div>
</div>
))}
</div>
))}
</div>
)}
{/* Log panel — grows to fill remaining height */}
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col flex-1 min-h-0">

View file

@ -501,7 +501,8 @@ const WizardStep2 = ({
</div>
<div className="grid grid-cols-1 gap-x-6">
<div className="flex flex-wrap gap-6">
{/* Menü URL'i Menu adımında gösterilir; burada yalnızca layout seçimi kalır. */}
<div className="w-32">
<FormItem
label={translate('::App.ListFormEditDetailsLayoutDto.DefaultLayout')}
invalid={errors?.defaultLayout && touched?.defaultLayout}

View file

@ -0,0 +1,118 @@
import { Button } from '@/components/ui'
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
import { FaArrowRight, FaCheck, FaCube, FaTable } from 'react-icons/fa'
// ─── Props ────────────────────────────────────────────────────────────────────
export interface WizardStepComponentProps {
values: ListFormWizardDto
onKindChange: (kind: WizardComponentKindEnum) => void
translate: (key: string) => string
onNext: () => void
}
// ─── Kind card ────────────────────────────────────────────────────────────────
interface KindCardProps {
active: boolean
icon: React.ReactNode
title: string
description: string
onSelect: () => void
}
function KindCard({ active, icon, title, description, onSelect }: KindCardProps) {
return (
<button
type="button"
className={`group relative flex flex-col items-center gap-3 rounded-2xl border-2 px-6 py-8 text-center transition-all ${
active
? 'border-indigo-500 bg-indigo-50 shadow-md dark:border-indigo-500 dark:bg-indigo-900/20'
: 'border-gray-200 bg-white hover:-translate-y-0.5 hover:border-indigo-300 hover:shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:hover:border-indigo-600'
}`}
onClick={onSelect}
>
{active && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-indigo-500 text-[10px] text-white">
<FaCheck />
</span>
)}
<span
className={`flex h-14 w-14 items-center justify-center rounded-full text-2xl transition-colors ${
active
? 'bg-indigo-500 text-white'
: 'bg-gray-100 text-gray-400 group-hover:bg-indigo-100 group-hover:text-indigo-500 dark:bg-gray-700 dark:group-hover:bg-indigo-900/40'
}`}
>
{icon}
</span>
<span className="text-base font-semibold text-gray-800 dark:text-gray-100">{title}</span>
<span className="text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{description}
</span>
</button>
)
}
// ─── WizardStepComponent ──────────────────────────────────────────────────────
/**
* Wizard'ın yol ayrımı adımı: yalnızca tip seçimi yapılır.
* List seçilirse mevcut ListForm akışı aynen devam eder; Custom seçilirse bir
* sonraki adımda bileşen seçimi yapılır ve ListForm'a ait tüm adımlar devre dışı kalır.
*/
const WizardStepComponent = ({
values,
onKindChange,
translate,
onNext,
}: WizardStepComponentProps) => {
const isCustom = values.componentKind === WizardComponentKindEnum.Custom
return (
<div className="pb-20">
{/* Bu adımda tek bir karar var; seçim ekranın ortasına yerleşir. */}
<div className="flex min-h-[calc(100vh-320px)] items-center justify-center">
<div className="w-full max-w-3xl">
<div className="mb-8 text-center">
<h4 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
{translate('::App.WizardStepComponent.ComponentType')}
</h4>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{translate('::App.WizardStepComponent.ComponentTypeHint')}
</p>
</div>
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<KindCard
active={!isCustom}
icon={<FaTable />}
title={translate('::App.WizardStepComponent.ListComponent')}
description={translate('::App.WizardStepComponent.ListComponentHint')}
onSelect={() => onKindChange(WizardComponentKindEnum.List)}
/>
<KindCard
active={isCustom}
icon={<FaCube />}
title={translate('::App.WizardStepComponent.CustomComponent')}
description={translate('::App.WizardStepComponent.CustomComponentHint')}
onSelect={() => onKindChange(WizardComponentKindEnum.Custom)}
/>
</div>
</div>
</div>
{/* ─── Fixed Footer ─────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 py-0 dark:border-gray-700 dark:bg-gray-900">
{/* Wizard'in ilk adimi oldugu icin geri butonu yok. */}
<div className="flex w-full items-center justify-end gap-3">
<Button variant="solid" type="button" icon={<FaArrowRight />} onClick={onNext}>
{translate('::App.Platform.Next')}
</Button>
</div>
</div>
</div>
)
}
export default WizardStepComponent

View file

@ -0,0 +1,196 @@
import { Button, FormItem, Select } from '@/components/ui'
import { useComponents } from '@/contexts/ComponentContext'
import { ListFormWizardDto } from '@/proxy/admin/wizard/models'
import { Field, FieldProps } from 'formik'
import { useMemo } from 'react'
import { FaArrowLeft, FaArrowRight, FaCube, FaExternalLinkAlt } from 'react-icons/fa'
// ─── Props ────────────────────────────────────────────────────────────────────
export interface WizardStepCustomComponentProps {
values: ListFormWizardDto
onCustomComponentChange: (name: string, routePath: string) => void
translate: (key: string) => string
onBack: () => void
onNext: () => void
}
// ─── WizardStepCustomComponent ────────────────────────────────────────────────
/**
* Custom yolunda menüye bağlanacak bileşenin seçildiği adım. Seçimle birlikte
* bileşenin rota yolu ve canlı önizlemesi gösterilir; deploy sonrası menü ikonu
* tam olarak bu rotayı açar.
*/
const WizardStepCustomComponent = ({
values,
onCustomComponentChange,
translate,
onBack,
onNext,
}: WizardStepCustomComponentProps) => {
const { components, compiling, componentErrors, renderComponent } = useComponents()
// Yalnızca aktif ve rota tanımlı bileşenler menüye bağlanabilir; pasif bir
// bileşenin rotası router'a hiç kaydedilmediği için menü boşa düşerdi.
const selectableComponents = useMemo(
() => components.filter((c) => c.isActive && c.routePath?.trim()),
[components],
)
const componentOptions = useMemo(
() =>
selectableComponents.map((c) => ({
value: c.name,
label: c.description?.trim() ? `${c.name}${c.description.trim()}` : c.name,
})),
[selectableComponents],
)
const selectedComponent = useMemo(
() => selectableComponents.find((c) => c.name === values.customComponentName),
[selectableComponents, values.customComponentName],
)
const compileError = values.customComponentName
? componentErrors[values.customComponentName]
: undefined
const stepMissing = [
!values.customComponentName && translate('::App.WizardStepComponent.ComponentName'),
!!values.customComponentName &&
!values.menuUrl &&
translate('::App.WizardStepComponent.RoutePath'),
].filter(Boolean) as string[]
const canGo = stepMissing.length === 0
return (
<div className="pb-20">
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
<FormItem label={translate('::App.WizardStepComponent.ComponentName')} asterisk={true}>
<Field type="text" autoComplete="off" name="customComponentName">
{({ field, form }: FieldProps<string>) => (
<Select
field={field}
form={form}
isClearable={true}
options={componentOptions}
placeholder={translate('::App.WizardStepComponent.SelectComponent')}
value={
values.customComponentName
? (componentOptions.find((o) => o.value === values.customComponentName) ?? {
label: values.customComponentName,
value: values.customComponentName,
})
: null
}
onChange={(option) => {
const name = option?.value ?? ''
const routePath =
selectableComponents.find((c) => c.name === name)?.routePath ?? ''
onCustomComponentChange(name, routePath)
}}
/>
)}
</Field>
</FormItem>
{/* Deploy sonrası menüye eklenen ikon bu rotayı açar. */}
<FormItem
label={translate('::App.WizardStepComponent.RoutePath')}
extra={
<span className="ml-2 text-xs text-gray-400">
{translate('::App.WizardStepComponent.RoutePathHint')}
</span>
}
>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1 rounded-md border border-gray-300 bg-gray-50 px-3 py-2 font-mono text-sm text-indigo-600 dark:border-gray-600 dark:bg-gray-800 dark:text-indigo-300">
{values.menuUrl || '—'}
</div>
{values.menuUrl && (
<Button
type="button"
size="sm"
variant="default"
icon={<FaExternalLinkAlt />}
title={translate('::App.Platform.OpenUrl')}
onClick={() => window.open(values.menuUrl, '_blank')}
/>
)}
</div>
</FormItem>
</div>
{selectableComponents.length === 0 && (
<div className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
{translate('::App.WizardStepComponent.NoComponents')}
</div>
)}
{/* ── Önizleme ───────────────────────────────────────────────── */}
{values.customComponentName ? (
<FormItem
label={translate('::App.WizardStepComponent.Preview')}
extra={
selectedComponent?.description?.trim() ? (
<span className="ml-2 text-xs text-gray-400">
{selectedComponent.description.trim()}
</span>
) : null
}
>
<div className="min-h-[320px] overflow-auto rounded-lg border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-900">
{compiling ? (
<div className="py-6 text-center text-sm text-gray-400">
{translate('::App.Platform.LoadingWithThreeDot')}
</div>
) : compileError ? (
<div className="whitespace-pre-wrap rounded border-2 border-red-300 bg-red-50 p-3 text-sm text-red-700 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300">
{compileError}
</div>
) : (
renderComponent(values.customComponentName)
)}
</div>
</FormItem>
) : (
selectableComponents.length > 0 && (
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-gray-200 text-gray-300 dark:border-gray-700 dark:text-gray-600">
<FaCube className="text-3xl" />
<span className="select-none text-sm">
{translate('::App.WizardStepComponent.SelectComponent')}
</span>
</div>
)
)}
{/* ─── Fixed Footer ─────────────────────────────── */}
<div className="fixed bottom-0 left-0 right-0 z-10 flex h-16 items-center border-t border-gray-200 bg-white px-6 py-0 dark:border-gray-700 dark:bg-gray-900">
<div className="flex w-full items-center gap-3">
<Button variant="default" type="button" icon={<FaArrowLeft />} onClick={onBack}>
{translate('::App.Platform.Back')}
</Button>
<div className="flex flex-1 items-center justify-end gap-3">
{!canGo && (
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
{translate('::App.ListFormWizard.Required')}: {stepMissing.join(', ')}
</span>
)}
<Button
variant="solid"
type="button"
icon={<FaArrowRight />}
disabled={!canGo}
onClick={onNext}
>
{translate('::App.Platform.Next')}
</Button>
</div>
</div>
</div>
</div>
)
}
export default WizardStepCustomComponent

View file

@ -4,7 +4,7 @@ import { MenuItem } from '@/proxy/menus/menu'
import { MenuService } from '@/services/menu.service'
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FaChevronDown, FaPlus } from 'react-icons/fa'
import { FaChevronDown, FaEdit, FaPlus } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useConfig } from '@/components/ui/ConfigProvider'
import { useForm } from '@/components/ui/Form/context'
@ -83,9 +83,7 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
<span className="truncate">{value}</span>
</span>
) : (
<span className="flex-1 !text-gray-400">
{translate('::App.WizardStep1.SelectIcon')}
</span>
<span className="flex-1 !text-gray-400">{translate('::App.WizardStep1.SelectIcon')}</span>
)}
<FaChevronDown
className={`shrink-0 text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`}
@ -153,6 +151,11 @@ export interface MenuAddDialogProps {
initialParentCode: string
initialOrder: number
rawItems: (MenuItem & { id?: string })[]
/**
* Dolu ise dialog düzenleme modunda ılır ve menünün tüm alanları değiştirilebilir.
* Boş bırakılırsa yeni menü oluşturma modudur.
*/
editMenu?: (MenuItem & { id?: string }) | null
onSaved: (menu: {
code: string
parentCode?: string
@ -163,42 +166,86 @@ export interface MenuAddDialogProps {
}) => void | Promise<void>
}
/** Düzenleme modunda menü kendi alt ağacına taşınamaz; aksi halde döngü oluşur. */
function collectDescendantCodes(
items: (MenuItem & { id?: string })[],
rootCode: string,
): Set<string> {
const result = new Set<string>([rootCode])
let grew = true
while (grew) {
grew = false
for (const item of items) {
if (!item.code || result.has(item.code)) continue
if (item.parentCode && result.has(item.parentCode)) {
result.add(item.code)
grew = true
}
}
}
return result
}
const emptyForm = (parentCode: string, order: number) => ({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode,
icon: '',
shortName: '',
order,
url: '',
isDisabled: false,
})
export function MenuAddDialog({
isOpen,
onClose,
initialParentCode,
initialOrder,
rawItems,
editMenu,
onSaved,
}: MenuAddDialogProps) {
const [form, setForm] = useState({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
})
const isEditMode = !!editMenu?.id
const [form, setForm] = useState(() => emptyForm(initialParentCode, initialOrder))
const [saving, setSaving] = useState(false)
const { translate } = useLocalization()
useEffect(() => {
if (isOpen)
if (!isOpen) return
if (editMenu?.id) {
setForm({
name: '',
code: '',
menuTextEn: '',
menuTextTr: '',
parentCode: initialParentCode,
icon: '',
shortName: '',
order: initialOrder,
name: editMenu.code ?? '',
code: editMenu.code ?? '',
menuTextEn: editMenu.menuTextEn ?? '',
menuTextTr: editMenu.menuTextTr ?? '',
parentCode: editMenu.parentCode ?? '',
icon: editMenu.icon ?? '',
shortName: editMenu.shortName ?? '',
order: editMenu.order ?? 0,
url: editMenu.url ?? '',
isDisabled: editMenu.isDisabled ?? false,
})
}, [isOpen, initialParentCode, initialOrder])
return
}
setForm(emptyForm(initialParentCode, initialOrder))
}, [isOpen, initialParentCode, initialOrder, editMenu])
// Kök menülerde kısa ad zorunlu; alt menülerde isteğe bağlıdır.
const shortNameRequired = !form.parentCode.trim()
// Düzenlenen menü, kendisi veya alt ağacındaki bir menünün altına taşınamaz.
const parentOptions = useMemo(() => {
if (!isEditMode) return []
const blocked = collectDescendantCodes(rawItems, editMenu?.code ?? '')
return rawItems
.filter((item) => item.code && !blocked.has(item.code))
.map((item) => ({ code: item.code!, label: item.code! }))
.sort((a, b) => a.label.localeCompare(b.label))
}, [isEditMode, rawItems, editMenu])
const handleSave = async () => {
if (!form.code.trim() || !form.menuTextEn.trim()) return
if (shortNameRequired && !form.shortName.trim()) return
@ -206,17 +253,27 @@ export function MenuAddDialog({
try {
const savedMenu = {
code: form.code.trim(),
// Görünen ad dil anahtarıdır; menü kodu ile aynı kalır.
displayName: form.code.trim(),
parentCode: form.parentCode.trim() || undefined,
icon: form.icon || undefined,
shortName: form.shortName.trim() || undefined,
order: form.order,
isDisabled: false,
isDisabled: form.isDisabled,
url: form.url.trim() || undefined,
menuTextTr: form.menuTextTr.trim(),
menuTextEn: form.menuTextEn.trim(),
} as MenuDto
await menuService.createWithLanguageKeyText(savedMenu)
if (isEditMode) {
// Kod ve görünen ad dışındaki tüm alanlar güncellenir; sunucu kodu korur.
await menuService.updateWithLanguageKeyText(editMenu!.id!, {
...(editMenu as MenuDto),
...savedMenu,
})
} else {
await menuService.createWithLanguageKeyText(savedMenu)
}
await onSaved({
code: savedMenu.code!,
@ -236,9 +293,6 @@ export function MenuAddDialog({
}
}
// suppress unused warning — rawItems kept for future use
void rawItems
const fieldCls =
'h-9 px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-200 outline-none focus:border-indigo-400 w-full'
const disabledCls =
@ -257,9 +311,15 @@ export function MenuAddDialog({
<div className="flex flex-col gap-5 p-1">
{/* Header */}
<div className="flex items-center gap-2 pb-1 border-b border-gray-100 dark:border-gray-700">
<FaPlus className="text-green-500 text-sm" />
{isEditMode ? (
<FaEdit className="text-indigo-500 text-sm" />
) : (
<FaPlus className="text-green-500 text-sm" />
)}
<h5 className="text-base font-semibold text-gray-800 dark:text-gray-100">
{translate('::App.Platform.AddNewMenu')}
{isEditMode
? translate('::App.Platform.EditMenu')
: translate('::App.Platform.AddNewMenu')}
</h5>
</div>
@ -270,8 +330,10 @@ export function MenuAddDialog({
{translate('::App.Listform.ListformField.Name')}{' '}
<span className="text-red-500">*</span>
</label>
{/* Ad, kodu ve dil anahtarını türetir; düzenlemede kod sabit olduğu için kilitlidir. */}
<input
autoFocus
autoFocus={!isEditMode}
disabled={isEditMode}
value={form.name}
onChange={(e) => {
const codeToken = toCodeToken(e.target.value)
@ -287,7 +349,7 @@ export function MenuAddDialog({
}))
}}
placeholder="MyMenu"
className={fieldCls}
className={isEditMode ? disabledCls : fieldCls}
/>
</div>
<div className="flex flex-col">
@ -346,7 +408,22 @@ export function MenuAddDialog({
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Platform.MenuParent')}</label>
<input disabled value={form.parentCode} className={disabledCls} />
{isEditMode ? (
<select
value={form.parentCode}
onChange={(e) => setForm((p) => ({ ...p, parentCode: e.target.value }))}
className={fieldCls}
>
<option value="">{translate('::App.WizardStep1.RootMenu')}</option>
{parentOptions.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
) : (
<input disabled value={form.parentCode} className={disabledCls} />
)}
</div>
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Listform.ListformField.Order')}</label>
@ -378,12 +455,43 @@ export function MenuAddDialog({
className={`${fieldCls}`}
/>
{shortNameRequired && (
<p className="text-xs text-gray-400 mt-1">
{translate('::App.Platform.MenuCodeHint')}
</p>
<p className="text-xs text-gray-400 mt-1">{translate('::App.Platform.MenuCodeHint')}</p>
)}
</div>
{/* Row 6 — Url | Durum (yalnızca düzenlemede) */}
{isEditMode && (
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Platform.OpenUrl')}</label>
<input
value={form.url}
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
placeholder="/admin/list/App.Wizard.MyMenu"
className={`${fieldCls} font-mono`}
/>
</div>
<div className="flex flex-col">
<label className={labelCls}>{translate('::App.Platform.Status')}</label>
<label className="flex h-9 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={!form.isDisabled}
onChange={(e) => setForm((p) => ({ ...p, isDisabled: !e.target.checked }))}
className="h-4 w-4 accent-indigo-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-200">
{translate(
form.isDisabled
? '::App.Platform.Passive'
: '::App.Listform.ListformField.IsActive',
)}
</span>
</label>
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
<Button type="button" size="sm" variant="plain" onClick={onClose}>