Claude güncellemesi WizardManager güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 14:09:58 +03:00
parent 5626c27a90
commit db24baed91
15 changed files with 497 additions and 397 deletions

View file

@ -1,38 +1,18 @@
using Sozsoft.Platform.Localization;
using Volo.Abp.Authorization.Permissions; using Volo.Abp.Authorization.Permissions;
using Volo.Abp.Localization;
using Volo.Abp.MultiTenancy;
namespace Sozsoft.Platform.Permissions; namespace Sozsoft.Platform.Permissions;
/// <summary>
/// Uygulama yetkileri kod içinde tanımlanmaz; <c>Seeds/PermissionsData.json</c>
/// dosyasından <c>PermissionDataSeeder</c> ile veritabanına seed edilir ve
/// oradan dinamik olarak yönetilir. Bu sağlayıcı yalnızca kullanılmayan
/// ABP modül gruplarını kaldırmak için vardır.
/// </summary>
public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider
{ {
public override void Define(IPermissionDefinitionContext context) public override void Define(IPermissionDefinitionContext context)
{ {
PermissionRemoveGroup(context); PermissionRemoveGroup(context);
DefineSetupPermissions(context);
}
/// <summary>
/// Kurulum / veritabanı bakım yetkileri. <see cref="MultiTenancySides.Host"/> ile
/// işaretlendiği için tenant kullanıcılarına hiç görünmez ve verilemez.
/// </summary>
private void DefineSetupPermissions(IPermissionDefinitionContext context)
{
var setupGroup = context.AddGroup(
PlatformPermissions.GroupName,
L("Permission:Setup"));
setupGroup.AddPermission(
PlatformPermissions.Setup.Migrate,
L("Permission:Setup.Migrate"),
MultiTenancySides.Host);
}
private static LocalizableString L(string name)
{
return LocalizableString.Create<PlatformResource>(name);
} }
private void PermissionRemoveGroup(IPermissionDefinitionContext context) private void PermissionRemoveGroup(IPermissionDefinitionContext context)
@ -42,6 +22,4 @@ public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider
context.RemoveGroup("FeatureManagement"); context.RemoveGroup("FeatureManagement");
context.RemoveGroup("AbpIdentity"); context.RemoveGroup("AbpIdentity");
} }
} }

View file

@ -8,6 +8,7 @@ using Sozsoft.Languages.Entities;
using Sozsoft.Languages.Languages; using Sozsoft.Languages.Languages;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Enums; using Sozsoft.Platform.Enums;
using Volo.Abp;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.MultiTenancy; using Volo.Abp.MultiTenancy;
using Volo.Abp.PermissionManagement; using Volo.Abp.PermissionManagement;
@ -17,10 +18,15 @@ using Sozsoft.Languages;
using Sozsoft.Platform.DynamicData; using Sozsoft.Platform.DynamicData;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static Sozsoft.Platform.PlatformConsts;
namespace Sozsoft.Platform.ListForms; namespace Sozsoft.Platform.ListForms;
[Authorize] /// <summary>
/// Wizard, global menu/permission/language kayitlari ve seed dosyalari uretir.
/// Bu nedenle sadece host tarafinda tanimli olan <c>App.Listforms.Wizard</c> yetkisi ile calisir.
/// </summary>
[Authorize(AppCodes.Listforms.Wizard)]
public class ListFormWizardAppService( public class ListFormWizardAppService(
IRepository<ListForm, Guid> repoListForm, IRepository<ListForm, Guid> repoListForm,
IRepository<ListFormField, Guid> repoListFormField, IRepository<ListFormField, Guid> repoListFormField,
@ -54,25 +60,31 @@ public class ListFormWizardAppService(
private readonly ILogger<ListFormWizardAppService> logger = logger; private readonly ILogger<ListFormWizardAppService> logger = logger;
private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage; private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage;
private static readonly JsonSerializerOptions SeedJsonReadOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions SeedJsonWriteOptions = new() { WriteIndented = true };
[UnitOfWork] [UnitOfWork]
public async Task Create(ListFormWizardDto input) public async Task Create(ListFormWizardDto input)
{ {
var wizardName = input.WizardName.Trim(); Check.NotNull(input, nameof(input));
var wizardName = input.WizardName?.Trim();
if (string.IsNullOrWhiteSpace(wizardName))
throw new UserFriendlyException("Wizard adı zorunludur.");
var code = string.IsNullOrWhiteSpace(input.MenuCode) var code = string.IsNullOrWhiteSpace(input.MenuCode)
? WizardConsts.WizardKey(wizardName) ? WizardConsts.WizardKey(wizardName)
: input.MenuCode.Trim(); : input.MenuCode.Trim();
var listFormCode = string.IsNullOrWhiteSpace(input.ListFormCode) var listFormCode = string.IsNullOrWhiteSpace(input.ListFormCode)
? code ? code
: input.ListFormCode.Trim(); : input.ListFormCode.Trim();
// Aşağıdaki tüm kayıtlar normalize edilmiş kodlara göre üretilir.
input.ListFormCode = listFormCode;
input.MenuCode = code;
var titleLangKey = $"{listFormCode}.Title"; var titleLangKey = $"{listFormCode}.Title";
var nameLangKey = code; var nameLangKey = code;
var descLangKey = $"{listFormCode}.Desc"; var descLangKey = $"{listFormCode}.Desc";
var permCreateName = $"{code}.Create";
var permUpdateName = $"{code}.Update";
var permDeleteName = $"{code}.Delete";
var permExportName = $"{code}.Export";
var permImportName = $"{code}.Import";
var permNoteName = $"{code}.Note";
// Eklenen kayıtları takip et (silme işleminde kullanılır) // Eklenen kayıtları takip et (silme işleminde kullanılır)
var inserted = new WizardInsertedRecordsDto(); var inserted = new WizardInsertedRecordsDto();
@ -96,67 +108,50 @@ public class ListFormWizardAppService(
// Permission'ları tek seferde kontrol et ve oluştur // Permission'ları tek seferde kontrol et ve oluştur
var existingPerms = await repoPerm.GetListAsync(a => a.GroupName == groupName); var existingPerms = await repoPerm.GetListAsync(a => a.GroupName == groupName);
var permRead = existingPerms.FirstOrDefault(a => a.Name == code); var permRead = existingPerms.FirstOrDefault(a => a.Name == code);
if (permRead == null) if (permRead == null)
{ {
permRead = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both), autoSave: false); permRead = await repoPerm.InsertAsync(
new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, code, null, nameLangKey, true, MultiTenancySides.Both),
autoSave: false);
inserted.PermissionNames.Add(permRead.Name); inserted.PermissionNames.Add(permRead.Name);
} }
var permCreate = existingPerms.FirstOrDefault(a => a.Name == permCreateName); // Read altındaki alt yetkiler: (suffix, dil anahtarı)
if (permCreate == null) var childPermissions = new[]
{ {
permCreate = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: false); ("Create", WizardConsts.LangKeyCreate),
inserted.PermissionNames.Add(permCreate.Name); ("Update", WizardConsts.LangKeyUpdate),
} ("Delete", WizardConsts.LangKeyDelete),
("Export", WizardConsts.LangKeyExport),
("Import", WizardConsts.LangKeyImport),
("Note", WizardConsts.LangKeyNote),
};
var permUpdate = existingPerms.FirstOrDefault(a => a.Name == permUpdateName); var permissionNames = new List<string> { permRead.Name };
if (permUpdate == null) foreach (var (suffix, langKey) in childPermissions)
{ {
permUpdate = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, permUpdateName, permRead.Name, WizardConsts.LangKeyUpdate, true, MultiTenancySides.Both), autoSave: false); var permissionName = $"{code}.{suffix}";
inserted.PermissionNames.Add(permUpdate.Name); permissionNames.Add(permissionName);
}
var permDelete = existingPerms.FirstOrDefault(a => a.Name == permDeleteName); if (existingPerms.Any(a => a.Name == permissionName))
if (permDelete == null) continue;
{
permDelete = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, permDeleteName, permRead.Name, WizardConsts.LangKeyDelete, true, MultiTenancySides.Both), autoSave: false);
inserted.PermissionNames.Add(permDelete.Name);
}
var permExport = existingPerms.FirstOrDefault(a => a.Name == permExportName); await repoPerm.InsertAsync(
if (permExport == null) new PermissionDefinitionRecord(GuidGenerator.Create(), groupName, permissionName, permRead.Name, langKey, true, MultiTenancySides.Both),
{ autoSave: false);
permExport = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, permExportName, permRead.Name, WizardConsts.LangKeyExport, true, MultiTenancySides.Both), autoSave: false); inserted.PermissionNames.Add(permissionName);
inserted.PermissionNames.Add(permExport.Name);
}
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: false);
inserted.PermissionNames.Add(permImport.Name);
}
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: false);
inserted.PermissionNames.Add(permNote.Name);
} }
// Permission Grants - Bulk Insert (only missing ones) // Permission Grants - Bulk Insert (only missing ones)
var existingGrants = await permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName); var existingGrants = await permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName);
var existingGrantNames = existingGrants.Select(g => g.Name).ToHashSet(); var existingGrantNames = existingGrants.Select(g => g.Name).ToHashSet();
var grantsToInsert = new[] var grantsToInsert = permissionNames
{ .Where(name => !existingGrantNames.Contains(name))
permRead.Name, permCreate.Name, permUpdate.Name, .Select(name => new PermissionGrant(GuidGenerator.Create(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName))
permDelete.Name, permExport.Name, permImport.Name, permNote.Name .ToList();
}
.Where(name => !existingGrantNames.Contains(name))
.Select(name => new PermissionGrant(Guid.NewGuid(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName))
.ToList();
if (grantsToInsert.Count > 0) if (grantsToInsert.Count > 0)
{ {
@ -227,26 +222,32 @@ public class ListFormWizardAppService(
var dataSource = await AsyncExecuter.FirstOrDefaultAsync(dataSourceQueryable.Where(a => a.Code == input.DataSourceCode)); var dataSource = await AsyncExecuter.FirstOrDefaultAsync(dataSourceQueryable.Where(a => a.Code == input.DataSourceCode));
if (dataSource is null) if (dataSource is null)
{ {
if (string.IsNullOrWhiteSpace(input.DataSourceConnectionString))
throw new UserFriendlyException($"'{input.DataSourceCode}' veri kaynağı bulunamadı. Yeni veri kaynağı için bağlantı cümlesi zorunludur.");
await repoDataSource.InsertAsync(new DataSource await repoDataSource.InsertAsync(new DataSource
{ {
Code = input.DataSourceCode, Code = input.DataSourceCode,
DataSourceType = input.DataSourceConnectionString.IndexOf("Server") >= 0 ? DataSourceTypeEnum.Mssql : DataSourceTypeEnum.Postgresql, DataSourceType = input.DataSourceConnectionString.Contains("Server", StringComparison.OrdinalIgnoreCase)
? DataSourceTypeEnum.Mssql
: DataSourceTypeEnum.Postgresql,
ConnectionString = input.DataSourceConnectionString ConnectionString = input.DataSourceConnectionString
}, autoSave: false); }, autoSave: false);
inserted.DataSourceCodes.Add(input.DataSourceCode); inserted.DataSourceCodes.Add(input.DataSourceCode);
} }
// Build EditingFormJson from wizard groups // Build EditingFormJson from wizard groups (sadece editing form alanı olan gruplar)
var editingFormDtos = input.Groups var editingFormDtos = input.Groups
.Select((g, gi) => new EditingFormDto .Select((g, gi) => new { Group = g, Order = gi + 1 })
.Where(x => x.Group.Items.Any(i => i.IncludeInEditingForm && i.FieldName != input.KeyFieldName))
.Select(x => new EditingFormDto
{ {
Order = gi + 1, Order = x.Order,
Caption = g.Caption, Caption = x.Group.Caption,
ColCount = g.ColCount, ColCount = x.Group.ColCount,
ColSpan = g.ColCount, ColSpan = x.Group.ColCount,
ItemType = "group" ItemType = "group"
}) })
.Where((_, index) => input.Groups[index].Items.Any(i => i.IncludeInEditingForm && i.FieldName != input.KeyFieldName))
.ToList(); .ToList();
//ListForm - varsa sil, yeniden ekle //ListForm - varsa sil, yeniden ekle
@ -373,7 +374,9 @@ public class ListFormWizardAppService(
ColumnCustomizationJson = WizardConsts.DefaultColumnCustomizationJson, ColumnCustomizationJson = WizardConsts.DefaultColumnCustomizationJson,
ColumnFilterJson = WizardConsts.DefaultColumnFilteringJson, ColumnFilterJson = WizardConsts.DefaultColumnFilteringJson,
PivotSettingsJson = WizardConsts.DefaultPivotSettingsJson, PivotSettingsJson = WizardConsts.DefaultPivotSettingsJson,
LookupJson = item.LookupQuery.Length > 0 ? WizardConsts.DefaultLookupJson(item.LookupDataSourceType, item.DisplayExpr, item.ValueExpr, item.LookupQuery) : null, LookupJson = !string.IsNullOrWhiteSpace(item.LookupQuery)
? WizardConsts.DefaultLookupJson(item.LookupDataSourceType, item.DisplayExpr, item.ValueExpr, item.LookupQuery)
: null,
}, autoSave: true); }, autoSave: true);
await CreateLangKey(item.CaptionName, item.EnglishCaption, item.TurkishCaption, inserted); await CreateLangKey(item.CaptionName, item.EnglishCaption, item.TurkishCaption, inserted);
@ -547,38 +550,40 @@ public class ListFormWizardAppService(
InsertedRecords = inserted InsertedRecords = inserted
}; };
var json = JsonSerializer.Serialize(seedData, new JsonSerializerOptions { WriteIndented = true }); var json = JsonSerializer.Serialize(seedData, SeedJsonWriteOptions);
// Dosya adı kullanıcı girdisinden türetildiği için geçersiz karakterler ayıklanır.
var safeWizardName = string.Concat(input.WizardName.Trim().Split(Path.GetInvalidFileNameChars())); var safeWizardName = string.Concat(input.WizardName.Trim().Split(Path.GetInvalidFileNameChars()));
var timestamp = DateTime.Now.ToString("yyyyMMddHHmm"); if (string.IsNullOrWhiteSpace(safeWizardName))
safeWizardName = "Wizard";
var timestamp = Clock.Now.ToString("yyyyMMddHHmmss");
var filePath = Path.Combine(outputPath, $"{timestamp}_{safeWizardName}.json"); var filePath = Path.Combine(outputPath, $"{timestamp}_{safeWizardName}.json");
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
logger.LogInformation($"Seed file saved: {filePath}"); logger.LogInformation("Seed file saved: {FilePath}", filePath);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Dosya kaydetme hatası wizard işlemini engellemez // Dosya kaydetme hatası wizard işlemini engellemez
logger.LogError(ex, $"Seed file could not be saved: {ex.Message}"); logger.LogError(ex, "Seed file could not be saved: {Message}", ex.Message);
} }
} }
public Task<List<WizardFileInfoDto>> GetFiles() public async Task<List<WizardFileInfoDto>> GetFiles()
{ {
var outputPath = ResolveWizardSeedOutputPath(); var outputPath = ResolveWizardSeedOutputPath();
var result = new List<WizardFileInfoDto>(); var result = new List<WizardFileInfoDto>();
if (!Directory.Exists(outputPath)) if (!Directory.Exists(outputPath))
return Task.FromResult(result); return result;
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName))
foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(f => Path.GetFileName(f)))
{ {
var fileName = Path.GetFileName(file);
try try
{ {
var json = File.ReadAllText(file); var json = await File.ReadAllTextAsync(file);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, options); var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions);
var fileName = Path.GetFileName(file);
result.Add(new WizardFileInfoDto result.Add(new WizardFileInfoDto
{ {
FileName = fileName, FileName = fileName,
@ -595,48 +600,32 @@ public class ListFormWizardAppService(
seed.InsertedRecords.DataSourceCodes.Count > 0) seed.InsertedRecords.DataSourceCodes.Count > 0)
}); });
} }
catch catch (Exception ex)
{ {
result.Add(new WizardFileInfoDto { FileName = Path.GetFileName(file) }); logger.LogWarning(ex, "Wizard seed file could not be parsed: {FileName}", fileName);
result.Add(new WizardFileInfoDto { FileName = fileName });
} }
} }
return Task.FromResult(result); return result;
} }
public async Task<WizardSeedFileDto> GetFile(string fileName) public async Task<WizardSeedFileDto> GetFile(string fileName)
{ {
// Güvenlik: sadece dosya adı, path traversal yasak var filePath = ResolveWizardSeedFilePath(fileName);
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
throw new Volo.Abp.AbpException("Geçersiz dosya adı.");
var outputPath = ResolveWizardSeedOutputPath();
var filePath = Path.Combine(outputPath, fileName);
if (!File.Exists(filePath))
throw new Volo.Abp.AbpException($"Dosya bulunamadı: {fileName}");
var json = await File.ReadAllTextAsync(filePath); var json = await File.ReadAllTextAsync(filePath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, options); return seed ?? throw new UserFriendlyException("Dosya okunamadı.");
return seed ?? throw new Volo.Abp.AbpException("Dosya okunamadı.");
} }
[UnitOfWork]
public async Task DeleteFile(string fileName) public async Task DeleteFile(string fileName)
{ {
// Güvenlik: sadece dosya adı, path traversal yasak var filePath = ResolveWizardSeedFilePath(fileName);
if (fileName.Contains('/') || fileName.Contains('\\') || fileName.Contains(".."))
throw new Volo.Abp.AbpException("Geçersiz dosya adı.");
var outputPath = ResolveWizardSeedOutputPath();
var filePath = Path.Combine(outputPath, fileName);
if (!File.Exists(filePath))
throw new Volo.Abp.AbpException($"Dosya bulunamadı: {fileName}");
var json = await File.ReadAllTextAsync(filePath); var json = await File.ReadAllTextAsync(filePath);
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, options);
if (seed != null) if (seed != null)
await DeleteWizardDataAsync(seed); await DeleteWizardDataAsync(seed);
@ -646,6 +635,37 @@ public class ListFormWizardAppService(
await _languageTextAppService.ClearRedisCacheAsync(); await _languageTextAppService.ClearRedisCacheAsync();
} }
/// <summary>
/// Kullanıcıdan gelen dosya adını doğrular ve seed dizini içinde kaldığını garanti eder.
/// Sadece düz dosya adı kabul edilir; dizin ayracı, üst dizin ifadesi ve `.json` dışındaki
/// uzantılar reddedilir. Ek olarak birleştirilen tam yol seed dizini ile karşılaştırılır.
/// </summary>
private string ResolveWizardSeedFilePath(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
throw new UserFriendlyException("Geçersiz dosya adı.");
// Path.GetFileName sadece son segmenti döndürür; farklıysa yol bileşeni var demektir.
if (!string.Equals(Path.GetFileName(fileName), fileName, StringComparison.Ordinal) ||
fileName.Contains("..", StringComparison.Ordinal) ||
fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
!fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
throw new UserFriendlyException("Geçersiz dosya adı.");
}
var outputPath = Path.GetFullPath(ResolveWizardSeedOutputPath());
var filePath = Path.GetFullPath(Path.Combine(outputPath, fileName));
if (!filePath.StartsWith(outputPath + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new UserFriendlyException("Geçersiz dosya adı.");
if (!File.Exists(filePath))
throw new UserFriendlyException($"Dosya bulunamadı: {fileName}");
return filePath;
}
private async Task DeleteWizardDataAsync(WizardSeedFileDto seed) private async Task DeleteWizardDataAsync(WizardSeedFileDto seed)
{ {
var ins = seed.InsertedRecords; var ins = seed.InsertedRecords;
@ -767,35 +787,45 @@ public class ListFormWizardAppService(
if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure) if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure)
return []; return [];
if (string.IsNullOrWhiteSpace(selectCommand))
return [];
var parts = selectCommand.Split('.'); var parts = selectCommand.Split('.');
var schemaName = parts.Length > 1 ? parts[0].Trim('"', '[', ']') : "dbo"; var schemaName = parts.Length > 1 ? parts[0].Trim('"', '[', ']') : "dbo";
var tableName = parts[parts.Length - 1].Trim('"', '[', ']'); var tableName = parts[^1].Trim('"', '[', ']');
try try
{ {
var (repo, connectionString, dbType) = await _dynamicDataManager.GetAsync(false, dataSourceCode); var (repo, connectionString, dbType) = await _dynamicDataManager.GetAsync(false, dataSourceCode);
var query = dbType == Sozsoft.Platform.Enums.DataSourceTypeEnum.Postgresql // Şema/tablo adları kullanıcıdan geldiği için sorguya parametre olarak bağlanır.
? $"SELECT column_name FROM information_schema.columns WHERE table_schema = '{schemaName}' AND table_name = '{tableName}'" var query = dbType == DataSourceTypeEnum.Postgresql
: $"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = '{schemaName}' AND TABLE_NAME = '{tableName}'"; ? "SELECT column_name FROM information_schema.columns WHERE table_schema = @schemaName AND table_name = @tableName"
: "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schemaName AND TABLE_NAME = @tableName";
var rows = await repo.QueryAsync(query, connectionString); var parameters = new Dictionary<string, object>
{
["schemaName"] = schemaName,
["tableName"] = tableName,
};
var rows = await repo.QueryAsync(query, connectionString, parameters);
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows) foreach (var row in rows)
{ {
var dict = row as IDictionary<string, object>; if (row is not IDictionary<string, object> dict)
if (dict != null) continue;
{
var key = dict.ContainsKey("COLUMN_NAME") ? "COLUMN_NAME" : "column_name"; var key = dict.ContainsKey("COLUMN_NAME") ? "COLUMN_NAME" : "column_name";
if (dict.TryGetValue(key, out var col) && col != null) if (dict.TryGetValue(key, out var col) && col != null)
columns.Add(col.ToString()!); columns.Add(col.ToString()!);
}
} }
return columns; return columns;
} }
catch catch (Exception ex)
{ {
logger.LogWarning(ex, "Table columns could not be read for {DataSourceCode}/{SelectCommand}", dataSourceCode, selectCommand);
return []; return [];
} }
} }

View file

@ -3620,13 +3620,13 @@
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Permission:Setup", "key": "App.Setup",
"en": "Setup & Database Maintenance", "en": "Setup & Database Maintenance",
"tr": "Kurulum ve Veritabanı Bakımı" "tr": "Kurulum ve Veritabanı Bakımı"
}, },
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "Permission:Setup.Migrate", "key": "App.Setup.Migrate",
"en": "Run database migration", "en": "Run database migration",
"tr": "Veritabanı geçişi çalıştırma" "tr": "Veritabanı geçişi çalıştırma"
}, },

View file

@ -7,9 +7,22 @@
{ {
"Name": "App.Administration", "Name": "App.Administration",
"DisplayName": "App.Administration" "DisplayName": "App.Administration"
},
{
"Name": "App.Setup",
"DisplayName": "App.Setup"
} }
], ],
"PermissionDefinitionRecords": [ "PermissionDefinitionRecords": [
{
"GroupName": "App.Setup",
"Name": "App.Setup.Migrate",
"ParentName": null,
"DisplayName": "App.Setup.Migrate",
"IsEnabled": true,
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{ {
"GroupName": "App.Saas", "GroupName": "App.Saas",
"Name": "AbpTenantManagement.Tenants", "Name": "AbpTenantManagement.Tenants",

View file

@ -4,7 +4,7 @@ import { SelectBoxOption } from '@/types/shared'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { Form, Formik, FormikProps } from 'formik' import { Form, Formik, FormikProps } from 'formik'
import type { KeyboardEvent } from 'react' import type { KeyboardEvent } from 'react'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import * as Yup from 'yup' import * as Yup from 'yup'
@ -197,6 +197,79 @@ const step2ValidationSchema = Yup.object().shape({
const listFormValidationSchema = step1ValidationSchema.concat(step2ValidationSchema) const listFormValidationSchema = step1ValidationSchema.concat(step2ValidationSchema)
const step1FieldNames = Object.keys(step1ValidationSchema.fields) as (keyof ListFormWizardDto)[]
const step2FieldNames = Object.keys(step2ValidationSchema.fields) as (keyof ListFormWizardDto)[]
// Audit columns that should not be selected by default
const AUDIT_COLUMNS = new Set([
'creationtime',
'creatorid',
'lastmodificationtime',
'lastmodifierid',
'isdeleted',
'deletiontime',
'deleterid',
])
const isAuditColumn = (columnName: string) => AUDIT_COLUMNS.has(columnName.toLowerCase())
const isTenantColumn = (columnName: string) => columnName.toLowerCase() === 'tenantid'
const isAutoSelectedColumn = (columnName: string, isTenant = false) =>
!isAuditColumn(columnName) && !(isTenant && isTenantColumn(columnName))
const removeTenantColumn = (columns: Set<string>) =>
new Set([...columns].filter((columnName) => !isTenantColumn(columnName)))
const removeTenantGroupItems = (groups: WizardGroup[]) =>
groups.map((group) => ({
...group,
items: group.items.filter((item) => !isTenantColumn(item.fieldName)),
}))
// Auto-derive listFormCode from wizardName
const deriveListFormCode = (name: string) => {
const sanitized = name.replace(/\s/g, '')
return sanitized ? `App.Wizard.${sanitized}` : ''
}
/** Yalnızca ilgili layout seçiliyken gösterilen adımlar. */
const OPTIONAL_STEPS = [
{
id: 6,
flag: 'todo',
translationKey: '::ListForms.ListFormEdit.TabTodo',
fallbackTitle: 'Todo / Kanban',
},
{
id: 7,
flag: 'tree',
translationKey: '::ListForms.ListFormEdit.TabTree',
fallbackTitle: 'Tree',
},
{
id: 8,
flag: 'gantt',
translationKey: '::ListForms.ListFormEdit.TabGantt',
fallbackTitle: 'Gantt',
},
{
id: 9,
flag: 'scheduler',
translationKey: '::ListForms.ListFormEdit.TabScheduler',
fallbackTitle: 'Scheduler',
},
] as const satisfies ReadonlyArray<{
id: number
flag: keyof ListFormWizardDto
translationKey: string
fallbackTitle: string
}>
const toSpacedLabel = (value: string) =>
value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.trim()
// ─── Wizard ─────────────────────────────────────────────────────────────────── // ─── Wizard ───────────────────────────────────────────────────────────────────
const Wizard = () => { const Wizard = () => {
@ -225,25 +298,32 @@ const Wizard = () => {
selectCommandType: SelectCommandTypeEnum selectCommandType: SelectCommandTypeEnum
} | null>(null) } | null>(null)
const loadDbObjects = async (dsCode: string) => { // Yalnızca son isteğin sonucu state'e yazılır (hızlı datasource değişiminde yarış koşulu).
const dbObjectsRequestRef = useRef(0)
const loadDbObjects = useCallback(async (dsCode: string) => {
const requestId = ++dbObjectsRequestRef.current
if (!dsCode) { if (!dsCode) {
setDbObjects(null) setDbObjects(null)
setIsLoadingDbObjects(false)
return return
} }
setIsLoadingDbObjects(true) setIsLoadingDbObjects(true)
try { try {
const res = await sqlObjectManagerService.getAllObjects(dsCode) const res = await sqlObjectManagerService.getAllObjects(dsCode)
if (dbObjectsRequestRef.current !== requestId) return
setDbObjects(res.data) setDbObjects(res.data)
} catch { } catch {
if (dbObjectsRequestRef.current !== requestId) return
setDbObjects(null) setDbObjects(null)
} finally { } finally {
setIsLoadingDbObjects(false) if (dbObjectsRequestRef.current === requestId) setIsLoadingDbObjects(false)
} }
} }, [])
useEffect(() => { useEffect(() => {
loadDbObjects(currentDataSource) loadDbObjects(currentDataSource)
}, [currentDataSource]) }, [currentDataSource, loadDbObjects])
// When dbObjects become available in edit mode, load columns for the saved selectCommand // When dbObjects become available in edit mode, load columns for the saved selectCommand
useEffect(() => { useEffect(() => {
@ -293,31 +373,6 @@ const Wizard = () => {
}) })
const [criteria, setCriteria] = useState<ListFormWorkflowCriteriaDto[]>([]) const [criteria, setCriteria] = useState<ListFormWorkflowCriteriaDto[]>([])
// Audit columns that should not be selected by default
const AUDIT_COLUMNS = new Set([
'creationtime',
'creatorid',
'lastmodificationtime',
'lastmodifierid',
'isdeleted',
'deletiontime',
'deleterid',
])
const isAuditColumn = (columnName: string) => AUDIT_COLUMNS.has(columnName.toLowerCase())
const isTenantColumn = (columnName: string) => columnName.toLowerCase() === 'tenantid'
const isAutoSelectedColumn = (columnName: string, isTenant = false) =>
!isAuditColumn(columnName) && !(isTenant && isTenantColumn(columnName))
const removeTenantColumn = (columns: Set<string>) =>
new Set([...columns].filter((columnName) => !isTenantColumn(columnName)))
const removeTenantGroupItems = (groups: WizardGroup[]) =>
groups.map((group) => ({
...group,
items: group.items.filter((item) => !isTenantColumn(item.fieldName)),
}))
const loadColumns = async (dsCode: string, schema: string, name: string) => { const loadColumns = async (dsCode: string, schema: string, name: string) => {
if (!dsCode || !name) { if (!dsCode || !name) {
setSelectCommandColumns([]) setSelectCommandColumns([])
@ -368,7 +423,7 @@ const Wizard = () => {
return next return next
}) })
const toggleAllColumns = (all: boolean, isTenant = formikRef.current?.values.isTenant ?? false) => const toggleAllColumns = (all: boolean, isTenant: boolean) =>
setSelectedColumns( setSelectedColumns(
all all
? new Set( ? new Set(
@ -469,14 +524,14 @@ const Wizard = () => {
confirmDelete: w.confirmDelete ?? true, confirmDelete: w.confirmDelete ?? true,
allowDetail: w.allowDetail ?? false, allowDetail: w.allowDetail ?? false,
defaultLayout: w.defaultLayout ?? 'grid', defaultLayout: w.defaultLayout ?? 'grid',
grid: w.grid ?? true, grid: w.grid ?? initialValues.grid,
card: w.card ?? true, card: w.card ?? initialValues.card,
pivot: w.pivot ?? true, pivot: w.pivot ?? initialValues.pivot,
tree: w.tree ?? true, tree: w.tree ?? initialValues.tree,
chart: w.chart ?? true, chart: w.chart ?? initialValues.chart,
gantt: w.gantt ?? true, gantt: w.gantt ?? initialValues.gantt,
scheduler: w.scheduler ?? true, scheduler: w.scheduler ?? initialValues.scheduler,
todo: w.todo ?? true, todo: w.todo ?? initialValues.todo,
treeOptionDto: w.treeOptionDto ?? initialValues.treeOptionDto, treeOptionDto: w.treeOptionDto ?? initialValues.treeOptionDto,
ganttOptionDto: w.ganttOptionDto ?? initialValues.ganttOptionDto, ganttOptionDto: w.ganttOptionDto ?? initialValues.ganttOptionDto,
schedulerOptionDto: w.schedulerOptionDto ?? initialValues.schedulerOptionDto, schedulerOptionDto: w.schedulerOptionDto ?? initialValues.schedulerOptionDto,
@ -578,18 +633,6 @@ const Wizard = () => {
const navigate = useNavigate() const navigate = useNavigate()
const formikRef = useRef<FormikProps<ListFormWizardDto>>(null) const formikRef = useRef<FormikProps<ListFormWizardDto>>(null)
// Auto-derive listFormCode from wizardName
const deriveListFormCode = (name: string) => {
const sanitized = name.replace(/\s/g, '')
return sanitized ? `App.Wizard.${sanitized}` : ''
}
const toSpacedLabel = (value: string) =>
value
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
.trim()
const handleWizardNameChange = (name: string) => { const handleWizardNameChange = (name: string) => {
const formik = formikRef.current const formik = formikRef.current
const spacedLabel = toSpacedLabel(name) const spacedLabel = toSpacedLabel(name)
@ -710,24 +753,23 @@ const Wizard = () => {
await getMenuList() await getMenuList()
} }
const handleNext = async () => { /** Alanları touched yapar, hata yoksa hedef adıma geçer. */
if (!formikRef.current) return const validateAndGo = async (fields: (keyof ListFormWizardDto)[], targetStep: number) => {
const errors = await formikRef.current.validateForm() const formik = formikRef.current
const step1Fields = Object.keys(step1ValidationSchema.fields) if (!formik) return
const hasStep1Errors = step1Fields.some((f) => errors[f as keyof ListFormWizardDto]) const errors = await formik.validateForm()
const touched = fields.reduce(
// Always mark step1 fields touched so validation errors become visible
const touchedStep1 = step1Fields.reduce(
(acc, key) => ({ ...acc, [key]: true }), (acc, key) => ({ ...acc, [key]: true }),
{} as Record<string, boolean>, {} as Record<string, boolean>,
) )
await formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep1 }) await formik.setTouched({ ...formik.touched, ...touched })
if (fields.some((field) => errors[field])) return
// Also require wizardName setCurrentStep(targetStep)
if (hasStep1Errors) return
setCurrentStep(1)
} }
const handleNext = () => validateAndGo(step1FieldNames, 1)
const handleNext2 = () => validateAndGo(step2FieldNames, 2)
const handleBack = () => setCurrentStep(0) const handleBack = () => setCurrentStep(0)
const { getConfig } = useStoreActions((a) => a.abpConfig) const { getConfig } = useStoreActions((a) => a.abpConfig)
@ -744,32 +786,18 @@ const Wizard = () => {
} }
} }
const handleNext2 = async () => {
if (!formikRef.current) return
const errors = await formikRef.current.validateForm()
const step2Fields = Object.keys(step2ValidationSchema.fields)
const hasStep2Errors = step2Fields.some((f) => errors[f as keyof ListFormWizardDto])
const touchedStep2 = step2Fields.reduce(
(acc, key) => ({ ...acc, [key]: true }),
{} as Record<string, boolean>,
)
await formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep2 })
if (hasStep2Errors) return
setCurrentStep(2)
}
const handleDeploy = async () => { const handleDeploy = async () => {
const formik = formikRef.current
if (!formik) throw new Error(translate('::App.Platform.Unknown') || 'Form bulunamadı')
const values = formik.values
try { try {
if (!formikRef.current) throw new Error('Form bulunamadı')
const values = formikRef.current.values
// Edit modunda: önce eski dosyayı sil (DB kayıtlarını temizler) // Edit modunda: önce eski dosyayı sil (DB kayıtlarını temizler)
if (isEditMode && editFileName) { if (isEditMode && editFileName) {
await deleteWizardFile(editFileName) await deleteWizardFile(editFileName)
} }
// 🔴 Önce kayıt işlemi TAMAMLANSIN
await postListFormWizard({ await postListFormWizard({
...values, ...values,
groups: editingGroups.map((g) => ({ groups: editingGroups.map((g) => ({
@ -786,7 +814,7 @@ const Wizard = () => {
colSpan: item.colSpan, colSpan: item.colSpan,
isRequired: item.isRequired, isRequired: item.isRequired,
includeInEditingForm: item.includeInEditingForm, includeInEditingForm: item.includeInEditingForm,
dbSourceType: col ? sqlDataTypeToDbType(col.dataType) : 12, dbSourceType: col ? sqlDataTypeToDbType(col.dataType) : DbTypeEnum.String,
turkishCaption: item.turkishCaption, turkishCaption: item.turkishCaption,
englishCaption: item.englishCaption, englishCaption: item.englishCaption,
captionName: item.captionName, captionName: item.captionName,
@ -805,23 +833,28 @@ const Wizard = () => {
}, },
}) })
// ✅ sonra config çek // Menü/permission değişikliklerinin görünmesi için ABP config'i tazele
await getConfig(true) await getConfig(true)
// ✅ sonra navigate
navigate(ROUTES_ENUM.protected.admin.list.replace(':listFormCode', values.listFormCode), {
replace: true,
})
// ✅ en son kullanıcıya mesaj
toast.push( toast.push(
<Notification type="success" duration={2000}> <Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')} {translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>, </Notification>,
{ placement: 'bottom-end' }, { placement: 'bottom-end' },
) )
} catch (err) {
console.error(err) navigate(ROUTES_ENUM.protected.admin.list.replace(':listFormCode', values.listFormCode), {
replace: true,
})
} catch (err: any) {
toast.push(
<Notification type="danger">
{err?.message ?? translate('::App.Platform.Unknown') ?? 'Unknown error'}
</Notification>,
{ placement: 'bottom-end' },
)
// Deploy adımının hatayı gösterebilmesi için hatayı yeniden fırlat
throw err
} }
} }
@ -870,43 +903,21 @@ const Wizard = () => {
id: 5, id: 5,
title: translate('::ListForms.ListFormEdit.TabWorkflow') || 'Workflow', title: translate('::ListForms.ListFormEdit.TabWorkflow') || 'Workflow',
}, },
...(values.todo ...OPTIONAL_STEPS.filter((step) => values[step.flag]).map((step) => ({
? [ id: step.id,
{ title: translate(step.translationKey) || step.fallbackTitle,
id: 6, })),
title: translate('::ListForms.ListFormEdit.TabTodo') || 'Todo / Kanban',
},
]
: []),
...(values.tree
? [
{
id: 7,
title: translate('::ListForms.ListFormEdit.TabTree') || 'Tree',
},
]
: []),
...(values.gantt
? [
{
id: 8,
title: translate('::ListForms.ListFormEdit.TabGantt') || 'Gantt',
},
]
: []),
...(values.scheduler
? [
{
id: 9,
title: translate('::ListForms.ListFormEdit.TabScheduler') || 'Scheduler',
},
]
: []),
{ id: 10, title: translate('::App.Platform.Deploy') || 'Deploy' }, { id: 10, title: translate('::App.Platform.Deploy') || 'Deploy' },
] ]
// Opsiyonel bir adım (todo/tree/gantt/scheduler) sonradan kapatılırsa
// currentStep listede kalmayabilir; boş ekran yerine en yakın geçerli adıma düş.
const resolvedStep = activeSteps.some((step) => step.id === currentStep)
? currentStep
: (activeSteps.filter((step) => step.id < currentStep).pop()?.id ?? 0)
const goToAdjacentStep = (direction: -1 | 1) => { const goToAdjacentStep = (direction: -1 | 1) => {
const currentIndex = activeSteps.findIndex((step) => step.id === currentStep) const currentIndex = activeSteps.findIndex((step) => step.id === resolvedStep)
const targetStep = activeSteps[currentIndex + direction] const targetStep = activeSteps[currentIndex + direction]
if (targetStep) { if (targetStep) {
setCurrentStep(targetStep.id) setCurrentStep(targetStep.id)
@ -917,7 +928,7 @@ const Wizard = () => {
<> <>
<div className="mb-6 mt-2"> <div className="mb-6 mt-2">
<Steps <Steps
current={activeSteps.findIndex((step) => step.id === currentStep) + 1} current={activeSteps.findIndex((step) => step.id === resolvedStep) + 1}
className="flex flex-row flex-wrap !justify-start gap-y-2 lg:flex-nowrap lg:!justify-between" className="flex flex-row flex-wrap !justify-start gap-y-2 lg:flex-nowrap lg:!justify-between"
> >
<Steps.Item <Steps.Item
@ -935,9 +946,9 @@ const Wizard = () => {
</div> </div>
<Form onKeyDown={preventEnterSubmit}> <Form onKeyDown={preventEnterSubmit}>
<FormContainer size={currentStep >= 2 ? undefined : 'sm'}> <FormContainer size={resolvedStep >= 2 ? undefined : 'sm'}>
{/* ─── Step 1: Basic Info ─────────────────────────────── */} {/* ─── Step 1: Basic Info ─────────────────────────────── */}
{currentStep === 0 && ( {resolvedStep === 0 && (
<WizardStep1 <WizardStep1
values={values} values={values}
errors={errors} errors={errors}
@ -963,7 +974,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 2: Data Settings ───────────────────────────── */} {/* ─── Step 2: Data Settings ───────────────────────────── */}
{currentStep === 1 && ( {resolvedStep === 1 && (
<WizardStep2 <WizardStep2
values={values} values={values}
errors={errors} errors={errors}
@ -994,7 +1005,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 3: List Form Fields ───────────────────────────── */} {/* ─── Step 3: List Form Fields ───────────────────────────── */}
{currentStep === 2 && ( {resolvedStep === 2 && (
<WizardStep3 <WizardStep3
selectedColumns={selectedColumns} selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
@ -1009,7 +1020,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 4: Sub Forms ───────────────────────────── */} {/* ─── Step 4: Sub Forms ───────────────────────────── */}
{currentStep === 3 && ( {resolvedStep === 3 && (
<WizardStep4 <WizardStep4
subForms={subForms} subForms={subForms}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
@ -1021,7 +1032,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 5: Widgets ───────────────────────────── */} {/* ─── Step 5: Widgets ───────────────────────────── */}
{currentStep === 4 && ( {resolvedStep === 4 && (
<WizardStep5 <WizardStep5
widgets={widgets} widgets={widgets}
translate={translate} translate={translate}
@ -1032,7 +1043,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 6: Workflow ───────────────────────────── */} {/* ─── Step 6: Workflow ───────────────────────────── */}
{currentStep === 5 && ( {resolvedStep === 5 && (
<WizardStep6 <WizardStep6
listFormCode={values.listFormCode} listFormCode={values.listFormCode}
workflow={workflow} workflow={workflow}
@ -1047,7 +1058,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 7: Todo / Kanban ────────────────────── */} {/* ─── Step 7: Todo / Kanban ────────────────────── */}
{currentStep === 6 && values.todo && ( {resolvedStep === 6 && values.todo && (
<WizardStep7 <WizardStep7
selectedColumns={selectedColumns} selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
@ -1058,7 +1069,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 8: Tree ─────────────────────────────── */} {/* ─── Step 8: Tree ─────────────────────────────── */}
{currentStep === 7 && values.tree && ( {resolvedStep === 7 && values.tree && (
<WizardStep8 <WizardStep8
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
@ -1068,7 +1079,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 9: Gantt ────────────────────────────── */} {/* ─── Step 9: Gantt ────────────────────────────── */}
{currentStep === 8 && values.gantt && ( {resolvedStep === 8 && values.gantt && (
<WizardStep9 <WizardStep9
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
@ -1078,7 +1089,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 10: Scheduler ───────────────────────── */} {/* ─── Step 10: Scheduler ───────────────────────── */}
{currentStep === 9 && values.scheduler && ( {resolvedStep === 9 && values.scheduler && (
<WizardStep10 <WizardStep10
selectCommandColumns={selectCommandColumns} selectCommandColumns={selectCommandColumns}
translate={translate} translate={translate}
@ -1088,7 +1099,7 @@ const Wizard = () => {
)} )}
{/* ─── Step 11: Deploy ──────────────────────────── */} {/* ─── Step 11: Deploy ──────────────────────────── */}
{currentStep === 10 && ( {resolvedStep === 10 && (
<WizardStep11 <WizardStep11
values={values} values={values}
wizardName={values.wizardName} wizardName={values.wizardName}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import classNames from 'classnames' import classNames from 'classnames'
import { Badge, Button, Input, Notification, toast } from '@/components/ui' import { Badge, Button, Dialog, Input, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container' import Container from '@/components/shared/Container'
import { import {
FaTrash, FaTrash,
@ -38,9 +38,6 @@ const WizardFileManager = () => {
const [deletingFile, setDeletingFile] = useState<string | null>(null) const [deletingFile, setDeletingFile] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(null) const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const IconComponent = (icon: string) => {
return navigationIcon[icon] || FaDatabase // default icon
}
const filteredFiles = useMemo(() => { const filteredFiles = useMemo(() => {
const q = search.trim().toLowerCase() const q = search.trim().toLowerCase()
@ -67,7 +64,7 @@ const WizardFileManager = () => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [translate])
useEffect(() => { useEffect(() => {
loadFiles() loadFiles()
@ -168,7 +165,7 @@ const WizardFileManager = () => {
<div className="space-y-2"> <div className="space-y-2">
{filteredFiles.map((f) => { {filteredFiles.map((f) => {
const Icon = IconComponent(f.menuIcon) const Icon = navigationIcon[f.menuIcon] ?? FaDatabase
return ( return (
<div <div
key={f.fileName} key={f.fileName}
@ -191,7 +188,10 @@ const WizardFileManager = () => {
<div className="flex items-center gap-2 shrink-0 sm:ml-3"> <div className="flex items-center gap-2 shrink-0 sm:ml-3">
{!f.hasInsertedRecords && ( {!f.hasInsertedRecords && (
<span <span
title="Bu dosyada izlenen kayıt bilgisi yok. Eski format olabilir." title={
translate('::App.Listforms.WizardFileNoTrackedRecords') ||
'Bu dosyada izlenen kayıt bilgisi yok. Eski format olabilir.'
}
className="text-yellow-500 text-xs flex items-center gap-1" className="text-yellow-500 text-xs flex items-center gap-1"
> >
<FaExclamationTriangle /> <FaExclamationTriangle />
@ -232,36 +232,34 @@ const WizardFileManager = () => {
</div> </div>
{/* Delete Confirm Dialog */} {/* Delete Confirm Dialog */}
{confirm && ( <Dialog
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> isOpen={confirm !== null}
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl p-6 max-w-sm w-full mx-4"> onClose={() => setConfirm(null)}
<div className="flex items-center gap-3 mb-4"> onRequestClose={() => setConfirm(null)}
<FaExclamationTriangle className="text-red-500 text-xl shrink-0" /> >
<div> <Dialog.Body>
<p className="font-semibold text-gray-800 dark:text-gray-200"> <div className="flex items-start gap-3">
{translate('::App.Platform.DeleteAction')} <FaExclamationTriangle className="text-red-500 text-xl shrink-0 mt-1" />
</p> <div>
<p className="text-sm text-gray-500 mt-1"> <p className="font-semibold text-gray-800 dark:text-gray-200">
{translate('::App.Listforms.WizardFileDeleteConfirm')} {translate('::App.Platform.DeleteAction')}
</p> </p>
</div> <p className="text-sm text-gray-500 mt-1">
</div> <strong>{confirm?.wizardName}</strong>{' '}
<div className="flex justify-end gap-2 mt-4"> {translate('::App.Listforms.WizardFileDeleteConfirm')}
<Button size="sm" variant="plain" type="button" onClick={() => setConfirm(null)}> </p>
{translate('::Cancel')}
</Button>
<Button
size="sm"
variant="solid"
type="button"
onClick={handleDeleteConfirm}
>
{translate('::Delete') || 'Yes, Delete'}
</Button>
</div> </div>
</div> </div>
</div> </Dialog.Body>
)} <Dialog.Footer className="flex justify-end gap-2">
<Button size="sm" variant="plain" type="button" onClick={() => setConfirm(null)}>
{translate('::Cancel')}
</Button>
<Button size="sm" variant="solid" type="button" onClick={handleDeleteConfirm}>
{translate('::Delete') || 'Yes, Delete'}
</Button>
</Dialog.Footer>
</Dialog>
</Container> </Container>
) )
} }

View file

@ -66,12 +66,25 @@ export function filterNonLinkNodes(nodes: MenuTreeNode[]): MenuTreeNode[] {
.map((n) => ({ ...n, children: filterNonLinkNodes(n.children) })) .map((n) => ({ ...n, children: filterNonLinkNodes(n.children) }))
} }
/** code → parentCode haritası; her aramada listeyi taramamak için. */
function buildParentMap(rawItems: MenuItem[]): Map<string, string | undefined> {
const map = new Map<string, string | undefined>()
rawItems.forEach((item) => {
if (item.code) map.set(item.code, item.parentCode ?? undefined)
})
return map
}
export function findRootCode(rawItems: MenuItem[], code: string): string { export function findRootCode(rawItems: MenuItem[], code: string): string {
const parents = buildParentMap(rawItems)
const visited = new Set<string>()
let current = code let current = code
for (let i = 0; i < 50; i++) { // visited: veri bozulsa bile (döngüsel parent) sonsuz döngüye girilmez
const item = rawItems.find((r) => r.code === current) while (!visited.has(current)) {
if (!item?.parentCode) return current visited.add(current)
current = item.parentCode const parentCode = parents.get(current)
if (!parentCode) return current
current = parentCode
} }
return current return current
} }
@ -270,13 +283,14 @@ function TreeNode({
/** Returns all ancestor codes of `code` in the flat rawItems list */ /** Returns all ancestor codes of `code` in the flat rawItems list */
function getAncestorCodes(rawItems: MenuItem[], code: string): Set<string> { function getAncestorCodes(rawItems: MenuItem[], code: string): Set<string> {
const parents = buildParentMap(rawItems)
const result = new Set<string>() const result = new Set<string>()
let current = code let current = code
for (let i = 0; i < 50; i++) { while (true) {
const item = rawItems.find((r) => r.code === current) const parentCode = parents.get(current)
if (!item?.parentCode) break if (!parentCode || result.has(parentCode)) break
result.add(item.parentCode) result.add(parentCode)
current = item.parentCode current = parentCode
} }
return result return result
} }
@ -375,12 +389,16 @@ function MenuTreeInline({
} }
} }
function enrichNode(node: MenuTreeNode): MenuTreeNode & { id?: string } { // Menü ağacı her render'da yeniden dolaşılmasın; id eşlemesi tek haritadan yapılır.
const raw = rawItems.find((i) => i.code === node.code) const enrichedNodes = useMemo(() => {
return { ...node, id: raw?.id, children: node.children.map(enrichNode) } const idByCode = new Map(rawItems.map((item) => [item.code, item.id]))
} const enrichNode = (node: MenuTreeNode): MenuTreeNode & { id?: string } => ({
...node,
const enrichedNodes = nodes.map(enrichNode) id: idByCode.get(node.code),
children: node.children.map(enrichNode),
})
return nodes.map(enrichNode)
}, [nodes, rawItems])
const sharedNodeProps = { const sharedNodeProps = {
expanded, expanded,
@ -483,6 +501,11 @@ const WizardStep1 = ({
const step1Missing = [ const step1Missing = [
!wizardName && translate('::ListForms.Wizard.Step1.WizardName'), !wizardName && translate('::ListForms.Wizard.Step1.WizardName'),
!values.menuCode && translate('::ListForms.Wizard.Step1.MenuCode'),
// Yup şeması menuOrder >= 1 istiyor; başlangıç değeri 0 olduğu için
// burada kontrol edilmezse "Next" aktif görünüp sessizce çalışmıyordu.
!(Number(values.menuOrder) >= 1) &&
(translate('::ListForms.Wizard.Step1.MenuOrder') || 'Menu Order'),
!values.permissionGroupName && translate('::ListForms.Wizard.Step1.PermissionGroupName'), !values.permissionGroupName && translate('::ListForms.Wizard.Step1.PermissionGroupName'),
!values.languageTextMenuEn && translate('::ListForms.Wizard.Step4.MenuEn'), !values.languageTextMenuEn && translate('::ListForms.Wizard.Step4.MenuEn'),
!values.languageTextMenuTr && translate('::ListForms.Wizard.Step4.MenuTr'), !values.languageTextMenuTr && translate('::ListForms.Wizard.Step4.MenuTr'),

View file

@ -1,6 +1,6 @@
import { Button } from '@/components/ui' import { Button } from '@/components/ui'
import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
import React, { useState } from 'react' import React, { useMemo, useState } from 'react'
import { import {
FaArrowLeft, FaArrowLeft,
FaCheckCircle, FaCheckCircle,
@ -34,7 +34,8 @@ export interface WizardStep11Props {
widgets: WidgetEditDto[] widgets: WidgetEditDto[]
workflow: WorkflowDto workflow: WorkflowDto
criteria: ListFormWorkflowCriteriaDto[] criteria: ListFormWorkflowCriteriaDto[]
translate: (key: string) => string // Log satırları {0} gibi parametreler içerdiği için params destekli imza gerekli
translate: (key: string, params?: Record<string, string | number>) => string
onBack: () => void onBack: () => void
onSubmit: () => Promise<void> onSubmit: () => Promise<void>
} }
@ -48,6 +49,18 @@ interface LogEntry {
detail?: string detail?: string
} }
/**
* Bağlantı cümlesi özet ekranında ıkça gösterilmez; yalnızca kullanıcının
* doğru kaynağı seçtiğini anlamasına yetecek kadarı bırakılıp gerisi maskelenir.
*/
function maskConnectionString(value?: string) {
if (!value) return value
return value.replace(
/(password|pwd|user id|uid|username|account key|accountkey)\s*=\s*[^;]*/gi,
(_match, key: string) => `${key}=••••••`,
)
}
// ─── Deploy log steps ───────────────────────────────────────────────────────── // ─── Deploy log steps ─────────────────────────────────────────────────────────
function buildLogSteps( function buildLogSteps(
@ -175,7 +188,10 @@ const WizardStep11 = ({
const [isDone, setIsDone] = useState(false) const [isDone, setIsDone] = useState(false)
const [hasError, setHasError] = useState(false) const [hasError, setHasError] = useState(false)
const steps = buildLogSteps(values, groups, translate) const steps = useMemo(
() => buildLogSteps(values, groups, translate),
[values, groups, translate],
)
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
@ -308,7 +324,7 @@ const WizardStep11 = ({
/> />
<Row <Row
label={translate('::App.Listform.ListformField.ConnectionString')} label={translate('::App.Listform.ListformField.ConnectionString')}
value={values.dataSourceConnectionString} value={maskConnectionString(values.dataSourceConnectionString)}
/> />
<Row <Row
label={translate('::ListForms.Wizard.Step4.SelectCommand')} label={translate('::ListForms.Wizard.Step4.SelectCommand')}

View file

@ -114,6 +114,10 @@ const WizardStep2 = ({
const step2Missing = [ const step2Missing = [
!values.listFormCode && translate('::App.Listform.ListformField.ListFormCode'), !values.listFormCode && translate('::App.Listform.ListformField.ListFormCode'),
!values.dataSourceCode && translate('::ListForms.Wizard.Step4.DataSource'), !values.dataSourceCode && translate('::ListForms.Wizard.Step4.DataSource'),
// Yeni veri kaynağı seçildiyse bağlantı cümlesi zorunlu (backend de doğruluyor)
isDataSourceNew &&
!values.dataSourceConnectionString?.trim() &&
translate('::App.Listform.ListformField.ConnectionString'),
!values.selectCommand && translate('::ListForms.Wizard.Step2.SelectCommand'), !values.selectCommand && translate('::ListForms.Wizard.Step2.SelectCommand'),
!values.keyFieldName && translate('::ListForms.Wizard.Step4.KeyField'), !values.keyFieldName && translate('::ListForms.Wizard.Step4.KeyField'),
!values.languageTextDescEn && translate('::ListForms.Wizard.Step2.DescriptionTextEnglish'), !values.languageTextDescEn && translate('::ListForms.Wizard.Step2.DescriptionTextEnglish'),
@ -188,12 +192,15 @@ const WizardStep2 = ({
{isDataSourceNew && ( {isDataSourceNew && (
<FormItem <FormItem
label={translate('::App.Listform.ListformField.ConnectionString')} label={translate('::App.Listform.ListformField.ConnectionString')}
asterisk={true}
invalid={!!(errors.dataSourceConnectionString && touched.dataSourceConnectionString)} invalid={!!(errors.dataSourceConnectionString && touched.dataSourceConnectionString)}
errorMessage={errors.dataSourceConnectionString} errorMessage={errors.dataSourceConnectionString}
> >
{/* Bağlantı cümlesi kimlik bilgisi içerebilir; ekranda ve tarayıcı
otomatik doldurmasında ıkta bırakılmaz. */}
<Field <Field
type="text" type="password"
autoComplete="off" autoComplete="new-password"
name="dataSourceConnectionString" name="dataSourceConnectionString"
placeholder={translate('::App.Listform.ListformField.ConnectionString')} placeholder={translate('::App.Listform.ListformField.ConnectionString')}
component={Input} component={Input}

View file

@ -109,10 +109,18 @@ const formatLabel = (text: string) => {
) )
} }
/**
* DnD ve React key'leri için benzersiz kimlik.
* `Date.now()` tek başına yeterli değil: "Tümünü Ekle" aynı milisaniyede
* birden fazla item üretip çakışan id'lere yol ıyordu.
*/
let idCounter = 0
const nextId = (prefix: string) => `${prefix}_${Date.now()}_${++idCounter}`
function newGroupItem(colName: string, meta?: DatabaseColumnDto): WizardGroupItem { function newGroupItem(colName: string, meta?: DatabaseColumnDto): WizardGroupItem {
const sqlType = meta?.dataType ?? '' const sqlType = meta?.dataType ?? ''
return { return {
id: `${colName}_${Date.now()}`, id: nextId(colName),
fieldName: colName, fieldName: colName,
editorType: inferEditorType(sqlType), editorType: inferEditorType(sqlType),
editorOptions: '', editorOptions: '',
@ -132,7 +140,7 @@ function newGroupItem(colName: string, meta?: DatabaseColumnDto): WizardGroupIte
function newGroup(order: number): WizardGroup { function newGroup(order: number): WizardGroup {
return { return {
id: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, id: nextId('grp'),
caption: `Group ${order}`, caption: `Group ${order}`,
colCount: 2, colCount: 2,
items: [], items: [],
@ -864,22 +872,13 @@ const WizardStep3 = ({
// 2. Ensure at least one empty group exists for the user to drag into // 2. Ensure at least one empty group exists for the user to drag into
const finalGroups = const finalGroups =
cleaned.length === 0 cleaned.length === 0
? [ ? [{ id: nextId('grp_default'), caption: '', colCount: 2, items: [] as WizardGroupItem[] }]
{
id: `grp_default_${Date.now()}`,
caption: '',
colCount: 2,
items: [] as WizardGroupItem[],
},
]
: cleaned : cleaned
// 3. Only update if something actually changed // 3. Yalnızca gerçekten bir item düştüyse (veya varsayılan grup eklendiyse) güncelle.
const itemsChanged = finalGroups.some( // Uzunluk karşılaştırması aynı sayıda ekleme/çıkarma durumunu kaçırıyordu.
(g, i) => g.items.length !== (groups[i]?.items.length ?? -1), const removedItem = cleaned.some((g, i) => g.items.length !== groups[i].items.length)
) if (removedItem || finalGroups.length !== groups.length) onGroupsChange(finalGroups)
const countChanged = finalGroups.length !== groups.length
if (itemsChanged || countChanged) onGroupsChange(finalGroups)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedColumns]) }, [selectedColumns])

View file

@ -39,7 +39,9 @@ const schema = object().shape({
code: string().required(), code: string().required(),
}) })
const emptySubForm: SubFormDto = { // Her yeni kayıt için ayrı nesne üretilir; aksi halde eklenen tüm sub-form'lar
// aynı relation dizisini / URLSearchParams örneğini paylaşırdı.
const createEmptySubForm = (): SubFormDto => ({
tabTitle: '', tabTitle: '',
tabType: SubFormTabTypeEnum.List, tabType: SubFormTabTypeEnum.List,
code: '', code: '',
@ -47,7 +49,7 @@ const emptySubForm: SubFormDto = {
relation: [], relation: [],
tabMode: 'edit', tabMode: 'edit',
searchParams: new URLSearchParams(), searchParams: new URLSearchParams(),
} })
function WizardStep4({ function WizardStep4({
subForms, subForms,
@ -243,7 +245,7 @@ function WizardStep4({
> >
{dialogIndex !== null && ( {dialogIndex !== null && (
<Formik <Formik
initialValues={dialogIndex === -1 ? emptySubForm : subForms[dialogIndex]} initialValues={dialogIndex === -1 ? createEmptySubForm() : subForms[dialogIndex]}
validationSchema={schema} validationSchema={schema}
onSubmit={upsertSubForm} onSubmit={upsertSubForm}
> >

View file

@ -81,14 +81,23 @@ function WizardStep6({
})) }))
useEffect(() => { useEffect(() => {
getUsers(0, 1000).then((response) => { let cancelled = false
setUserList( getUsers(0, 1000)
(response.data?.items ?? []).map((user: IdentityUserDto) => ({ .then((response) => {
value: user.userName, if (cancelled) return
label: `${user.userName} (${user.name} ${user.surname})`, setUserList(
})), (response.data?.items ?? []).map((user: IdentityUserDto) => ({
) value: user.userName,
}) label: `${user.userName} (${user.name} ${user.surname})`,
})),
)
})
.catch(() => {
if (!cancelled) setUserList([])
})
return () => {
cancelled = true
}
}, []) }, [])
useEffect(() => { useEffect(() => {

View file

@ -298,7 +298,8 @@ function FlowNode({
variant="plain" variant="plain"
shape="none" shape="none"
className={classNames( className={classNames(
'absolute z-40 grid !h-32 !w-44 touch-none content-start justify-items-start gap-1 rounded-lg border-2 border-[#667085] !bg-white !p-2.5 text-left text-slate-700 shadow-lg hover:!bg-white active:!bg-white dark:border-gray-600 dark:!bg-gray-900 dark:text-gray-200 dark:hover:!bg-gray-900 dark:active:!bg-gray-900', // Metin renkleri `!` ile: Button variant'ının kendi text rengini ezmesi gerekiyor.
'absolute z-40 grid !h-32 !w-44 touch-none content-start justify-items-start gap-1 rounded-lg border-2 border-[#667085] !bg-white !p-2.5 text-left !text-slate-700 shadow-lg hover:!bg-white active:!bg-white dark:border-gray-600 dark:!bg-gray-900 dark:!text-gray-200 dark:hover:!bg-gray-900 dark:active:!bg-gray-900',
{ {
'border-blue-600 outline outline-[3px] outline-blue-600/20': selected, 'border-blue-600 outline outline-[3px] outline-blue-600/20': selected,
'border-green-600 !bg-green-50 shadow-[0_0_0_4px_rgba(22,163,74,0.18),0_10px_24px_rgba(22,101,52,0.14)] hover:!bg-green-50 active:!bg-green-50 dark:!bg-green-900/20 dark:hover:!bg-green-900/20 dark:active:!bg-green-900/20': 'border-green-600 !bg-green-50 shadow-[0_0_0_4px_rgba(22,163,74,0.18),0_10px_24px_rgba(22,101,52,0.14)] hover:!bg-green-50 active:!bg-green-50 dark:!bg-green-900/20 dark:hover:!bg-green-900/20 dark:active:!bg-green-900/20':

View file

@ -51,6 +51,24 @@ const designerIconButtonClass =
const designerTabClass = '!h-auto min-h-8 rounded-md border !px-3 py-1.5 transition-colors' const designerTabClass = '!h-auto min-h-8 rounded-md border !px-3 py-1.5 transition-colors'
/**
* Button bileşeni variant'a göre kendi metin rengini basıyor
* (`solid` `text-white`, `plain` `text-gray-600`). Aynı özgüllükteki
* yardımcı sınıflar CSS sırasına göre eziliyor ve beyaz zeminde beyaz yazı
* oluşuyordu. Bu yüzden metin renkleri `!` ile önceliklendirilir.
*/
const designerNeutralColorClass =
'border-gray-300 !bg-white !text-slate-700 hover:!bg-gray-50 dark:border-gray-700 dark:!bg-gray-800 dark:!text-gray-200 dark:hover:!bg-gray-700'
const designerAccentColorClass =
'border-blue-600 !bg-white !text-blue-600 hover:!bg-blue-50 dark:border-blue-500 dark:!bg-gray-800 dark:!text-blue-300 dark:hover:!bg-blue-900/30'
const designerTabActiveClass =
'border-blue-700 !bg-blue-700 !text-white shadow-sm dark:border-blue-500 dark:!bg-blue-600 dark:!text-white'
const designerTabInactiveClass =
'border-gray-200 !bg-white !text-slate-600 hover:!bg-gray-50 dark:border-gray-700 dark:!bg-gray-800 dark:!text-gray-300 dark:hover:!bg-gray-700'
export function WorkflowDesigner({ export function WorkflowDesigner({
busy, busy,
canvasRef, canvasRef,
@ -177,10 +195,7 @@ function DesignerToolbar({
type="button" type="button"
variant="solid" variant="solid"
shape="round" shape="round"
className={classNames( className={classNames(designerButtonClass, designerNeutralColorClass)}
designerButtonClass,
'border-gray-300 !bg-white text-slate-700 hover:!bg-gray-50 dark:border-gray-700 dark:!bg-gray-800 dark:text-gray-200 dark:hover:!bg-gray-700',
)}
disabled={busy} disabled={busy}
title="Demo akışı yükle" title="Demo akışı yükle"
onClick={onResetDemo} onClick={onResetDemo}
@ -192,10 +207,7 @@ function DesignerToolbar({
type="button" type="button"
variant="solid" variant="solid"
shape="round" shape="round"
className={classNames( className={classNames(designerButtonClass, designerAccentColorClass)}
designerButtonClass,
'border-blue-600 !bg-white text-blue-600 hover:!bg-blue-50 dark:border-blue-500 dark:!bg-gray-800 dark:text-blue-300 dark:hover:!bg-blue-900/30',
)}
disabled={busy || currentCriteria.length === 0} disabled={busy || currentCriteria.length === 0}
title="Düğümleri okunabilir şekilde yerleştir" title="Düğümleri okunabilir şekilde yerleştir"
onClick={onFitLayout} onClick={onFitLayout}
@ -207,10 +219,7 @@ function DesignerToolbar({
type="button" type="button"
variant="solid" variant="solid"
shape="round" shape="round"
className={classNames( className={classNames(designerIconButtonClass, designerAccentColorClass)}
designerIconButtonClass,
'border-blue-600 !bg-white text-blue-600 hover:!bg-blue-50 dark:border-blue-500 dark:!bg-gray-800 dark:text-blue-300 dark:hover:!bg-blue-900/30',
)}
title="Yakınlaştır" title="Yakınlaştır"
onClick={onZoomIn} onClick={onZoomIn}
> >
@ -220,10 +229,7 @@ function DesignerToolbar({
type="button" type="button"
variant="solid" variant="solid"
shape="round" shape="round"
className={classNames( className={classNames(designerIconButtonClass, designerAccentColorClass)}
designerIconButtonClass,
'border-blue-600 !bg-white text-blue-600 hover:!bg-blue-50 dark:border-blue-500 dark:!bg-gray-800 dark:text-blue-300 dark:hover:!bg-blue-900/30',
)}
title="Uzaklaştır" title="Uzaklaştır"
onClick={onZoomOut} onClick={onZoomOut}
> >
@ -240,10 +246,7 @@ function DesignerToolbar({
type="button" type="button"
variant="solid" variant="solid"
shape="round" shape="round"
className={classNames( className={classNames(designerButtonClass, designerAccentColorClass)}
designerButtonClass,
'border-blue-600 !bg-white text-blue-600 hover:!bg-blue-50 dark:border-blue-500 dark:!bg-gray-800 dark:text-blue-300 dark:hover:!bg-blue-900/30',
)}
disabled={busy} disabled={busy}
onClick={() => onAddCriteria(option.value)} onClick={() => onAddCriteria(option.value)}
> >
@ -274,9 +277,7 @@ function DesignerTabs({
role="tab" role="tab"
className={classNames( className={classNames(
designerTabClass, designerTabClass,
activeTab === 'flow' activeTab === 'flow' ? designerTabActiveClass : designerTabInactiveClass,
? 'border-blue-700 !bg-blue-700 text-white shadow-sm dark:border-blue-500 dark:!bg-blue-600'
: 'border-gray-200 !bg-white text-slate-600 hover:!bg-gray-50 dark:border-gray-700 dark:!bg-gray-800 dark:text-gray-300 dark:hover:!bg-gray-700',
)} )}
onClick={() => onChange('flow')} onClick={() => onChange('flow')}
> >
@ -289,9 +290,7 @@ function DesignerTabs({
role="tab" role="tab"
className={classNames( className={classNames(
designerTabClass, designerTabClass,
activeTab === 'criteria' activeTab === 'criteria' ? designerTabActiveClass : designerTabInactiveClass,
? 'border-blue-700 !bg-blue-700 text-white shadow-sm dark:border-blue-500 dark:!bg-blue-600'
: 'border-gray-200 !bg-white text-slate-600 hover:!bg-gray-50 dark:border-gray-700 dark:!bg-gray-800 dark:text-gray-300 dark:hover:!bg-gray-700',
)} )}
onClick={() => onChange('criteria')} onClick={() => onChange('criteria')}
> >

View file

@ -6,6 +6,8 @@ import navigationIcon from '@/proxy/menus/navigation-icon.config'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { FaChevronDown, FaPlus } from 'react-icons/fa' import { FaChevronDown, FaPlus } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { useConfig } from '@/components/ui/ConfigProvider'
import { useForm } from '@/components/ui/Form/context'
const menuService = new MenuService() const menuService = new MenuService()
@ -26,6 +28,12 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
const [limit, setLimit] = useState(ICON_PAGE_SIZE) const [limit, setLimit] = useState(ICON_PAGE_SIZE)
const wrapperRef = useRef<HTMLDivElement>(null) const wrapperRef = useRef<HTMLDivElement>(null)
// Tetikleyici yüksekliği, komşu Input/Select alanlarıyla aynı olsun diye
// Input bileşeniyle birebir aynı kaynaktan (form context → global config) okunur.
const formSize = useForm()?.size
const { controlSize: defaultControlSize } = useConfig()
const controlSize = formSize ?? defaultControlSize
const SelectedIcon = value ? navigationIcon[value] : null const SelectedIcon = value ? navigationIcon[value] : null
const filtered = search.trim() const filtered = search.trim()
? ALL_ICON_ENTRIES.filter(([key]) => key.toLowerCase().includes(search.toLowerCase())) ? ALL_ICON_ENTRIES.filter(([key]) => key.toLowerCase().includes(search.toLowerCase()))
@ -48,27 +56,33 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro
return ( return (
<div ref={wrapperRef} className="relative"> <div ref={wrapperRef} className="relative">
{/*
Tetikleyici, platformun `.input` sınıfını kullanır; böylece kenarlık, köşe
yarıçapı, dolgu ve ık/koyu tema arka planı diğer form alanlarıyla birebir
aynı olur. Metin renkleri `!` ile verilir: Button bileşeni variant'ına göre
kendi `text-*` sınıfını basıyor ve aynı özgüllükte olduğu için eziyordu.
*/}
<Button <Button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
variant="plain" variant="plain"
shape="none" shape="none"
className={`flex items-center gap-2 w-full !h-auto !justify-start !rounded-lg border !px-3 !py-2 text-sm text-left transition-colors size={controlSize}
${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'} className={`input flex items-center gap-2 !justify-start !px-3 text-left text-sm !text-gray-700 dark:!text-gray-100 hover:border-indigo-400
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:border-indigo-400`} ${invalid ? 'input-invalid' : ''}`}
> >
{SelectedIcon ? ( {SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate"> <span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" /> <SelectedIcon className="text-xl shrink-0" />
<span className="text-xs text-gray-500 truncate">{value}</span> <span className="truncate">{value}</span>
</span> </span>
) : ( ) : (
<span className="text-gray-400 flex-1"> <span className="flex-1 !text-gray-400">
{translate('::ListForms.Wizard.Step1.SelectIcon') || 'Select icon...'} {translate('::ListForms.Wizard.Step1.SelectIcon') || 'Select icon...'}
</span> </span>
)} )}
<FaChevronDown <FaChevronDown
className={`text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`} className={`shrink-0 text-gray-400 text-xs transition-transform ${open ? 'rotate-180' : ''}`}
/> />
</Button> </Button>