diff --git a/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs index fc64f0c3..2b827c25 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/Permissions/PlatformPermissionDefinitionProvider.cs @@ -1,38 +1,18 @@ -using Sozsoft.Platform.Localization; using Volo.Abp.Authorization.Permissions; -using Volo.Abp.Localization; -using Volo.Abp.MultiTenancy; namespace Sozsoft.Platform.Permissions; +/// +/// Uygulama yetkileri kod içinde tanımlanmaz; Seeds/PermissionsData.json +/// dosyasından PermissionDataSeeder 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. +/// public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider { public override void Define(IPermissionDefinitionContext context) { PermissionRemoveGroup(context); - - DefineSetupPermissions(context); - } - - /// - /// Kurulum / veritabanı bakım yetkileri. ile - /// işaretlendiği için tenant kullanıcılarına hiç görünmez ve verilemez. - /// - 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(name); } private void PermissionRemoveGroup(IPermissionDefinitionContext context) @@ -42,6 +22,4 @@ public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider context.RemoveGroup("FeatureManagement"); context.RemoveGroup("AbpIdentity"); } - } - diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index fb850882..4c68c3b4 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -8,6 +8,7 @@ using Sozsoft.Languages.Entities; using Sozsoft.Languages.Languages; using Sozsoft.Platform.Entities; using Sozsoft.Platform.Enums; +using Volo.Abp; using Volo.Abp.Domain.Repositories; using Volo.Abp.MultiTenancy; using Volo.Abp.PermissionManagement; @@ -17,10 +18,15 @@ using Sozsoft.Languages; using Sozsoft.Platform.DynamicData; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.Logging; +using static Sozsoft.Platform.PlatformConsts; namespace Sozsoft.Platform.ListForms; -[Authorize] +/// +/// Wizard, global menu/permission/language kayitlari ve seed dosyalari uretir. +/// Bu nedenle sadece host tarafinda tanimli olan App.Listforms.Wizard yetkisi ile calisir. +/// +[Authorize(AppCodes.Listforms.Wizard)] public class ListFormWizardAppService( IRepository repoListForm, IRepository repoListFormField, @@ -54,25 +60,31 @@ public class ListFormWizardAppService( private readonly ILogger logger = logger; private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage; + private static readonly JsonSerializerOptions SeedJsonReadOptions = new() { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions SeedJsonWriteOptions = new() { WriteIndented = true }; + [UnitOfWork] 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) ? WizardConsts.WizardKey(wizardName) : input.MenuCode.Trim(); var listFormCode = string.IsNullOrWhiteSpace(input.ListFormCode) ? code : 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 nameLangKey = code; 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) var inserted = new WizardInsertedRecordsDto(); @@ -96,67 +108,50 @@ public class ListFormWizardAppService( // Permission'ları tek seferde kontrol et ve oluştur var existingPerms = await repoPerm.GetListAsync(a => a.GroupName == groupName); + var permRead = existingPerms.FirstOrDefault(a => a.Name == code); 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); } - var permCreate = existingPerms.FirstOrDefault(a => a.Name == permCreateName); - if (permCreate == null) + // Read altındaki alt yetkiler: (suffix, dil anahtarı) + var childPermissions = new[] { - permCreate = await repoPerm.InsertAsync(new PermissionDefinitionRecord(Guid.NewGuid(), groupName, permCreateName, permRead.Name, WizardConsts.LangKeyCreate, true, MultiTenancySides.Both), autoSave: false); - inserted.PermissionNames.Add(permCreate.Name); - } + ("Create", WizardConsts.LangKeyCreate), + ("Update", WizardConsts.LangKeyUpdate), + ("Delete", WizardConsts.LangKeyDelete), + ("Export", WizardConsts.LangKeyExport), + ("Import", WizardConsts.LangKeyImport), + ("Note", WizardConsts.LangKeyNote), + }; - var permUpdate = existingPerms.FirstOrDefault(a => a.Name == permUpdateName); - if (permUpdate == null) + var permissionNames = new List { permRead.Name }; + 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); - inserted.PermissionNames.Add(permUpdate.Name); - } + var permissionName = $"{code}.{suffix}"; + permissionNames.Add(permissionName); - 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: false); - inserted.PermissionNames.Add(permDelete.Name); - } + if (existingPerms.Any(a => a.Name == permissionName)) + continue; - 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: false); - 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); + 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) var existingGrants = await permissionGrantRepository.GetListAsync("R", PlatformConsts.AbpIdentity.User.AdminRoleName); var existingGrantNames = existingGrants.Select(g => g.Name).ToHashSet(); - var grantsToInsert = new[] - { - permRead.Name, permCreate.Name, permUpdate.Name, - permDelete.Name, permExport.Name, permImport.Name, permNote.Name - } - .Where(name => !existingGrantNames.Contains(name)) - .Select(name => new PermissionGrant(Guid.NewGuid(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName)) - .ToList(); + var grantsToInsert = permissionNames + .Where(name => !existingGrantNames.Contains(name)) + .Select(name => new PermissionGrant(GuidGenerator.Create(), name, "R", PlatformConsts.AbpIdentity.User.AdminRoleName)) + .ToList(); if (grantsToInsert.Count > 0) { @@ -227,26 +222,32 @@ public class ListFormWizardAppService( var dataSource = await AsyncExecuter.FirstOrDefaultAsync(dataSourceQueryable.Where(a => a.Code == input.DataSourceCode)); 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 { 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 }, autoSave: false); 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 - .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, - Caption = g.Caption, - ColCount = g.ColCount, - ColSpan = g.ColCount, + Order = x.Order, + Caption = x.Group.Caption, + ColCount = x.Group.ColCount, + ColSpan = x.Group.ColCount, ItemType = "group" }) - .Where((_, index) => input.Groups[index].Items.Any(i => i.IncludeInEditingForm && i.FieldName != input.KeyFieldName)) .ToList(); //ListForm - varsa sil, yeniden ekle @@ -373,7 +374,9 @@ public class ListFormWizardAppService( ColumnCustomizationJson = WizardConsts.DefaultColumnCustomizationJson, ColumnFilterJson = WizardConsts.DefaultColumnFilteringJson, 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); await CreateLangKey(item.CaptionName, item.EnglishCaption, item.TurkishCaption, inserted); @@ -547,38 +550,40 @@ public class ListFormWizardAppService( 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 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"); await File.WriteAllTextAsync(filePath, json); - logger.LogInformation($"Seed file saved: {filePath}"); + logger.LogInformation("Seed file saved: {FilePath}", filePath); } catch (Exception ex) { // 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> GetFiles() + public async Task> GetFiles() { var outputPath = ResolveWizardSeedOutputPath(); var result = new List(); 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(f => Path.GetFileName(f))) + foreach (var file in Directory.GetFiles(outputPath, "*.json").OrderBy(Path.GetFileName)) { + var fileName = Path.GetFileName(file); try { - var json = File.ReadAllText(file); - var seed = JsonSerializer.Deserialize(json, options); - var fileName = Path.GetFileName(file); + var json = await File.ReadAllTextAsync(file); + var seed = JsonSerializer.Deserialize(json, SeedJsonReadOptions); result.Add(new WizardFileInfoDto { FileName = fileName, @@ -595,48 +600,32 @@ public class ListFormWizardAppService( 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 GetFile(string fileName) { - // Güvenlik: sadece dosya adı, path traversal yasak - 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 filePath = ResolveWizardSeedFilePath(fileName); var json = await File.ReadAllTextAsync(filePath); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var seed = JsonSerializer.Deserialize(json, options); - return seed ?? throw new Volo.Abp.AbpException("Dosya okunamadı."); + var seed = JsonSerializer.Deserialize(json, SeedJsonReadOptions); + return seed ?? throw new UserFriendlyException("Dosya okunamadı."); } + [UnitOfWork] public async Task DeleteFile(string fileName) { - // Güvenlik: sadece dosya adı, path traversal yasak - 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 filePath = ResolveWizardSeedFilePath(fileName); var json = await File.ReadAllTextAsync(filePath); - var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; - var seed = JsonSerializer.Deserialize(json, options); + var seed = JsonSerializer.Deserialize(json, SeedJsonReadOptions); if (seed != null) await DeleteWizardDataAsync(seed); @@ -646,6 +635,37 @@ public class ListFormWizardAppService( await _languageTextAppService.ClearRedisCacheAsync(); } + /// + /// 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. + /// + 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) { var ins = seed.InsertedRecords; @@ -767,35 +787,45 @@ public class ListFormWizardAppService( if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure) return []; + if (string.IsNullOrWhiteSpace(selectCommand)) + return []; + var parts = selectCommand.Split('.'); var schemaName = parts.Length > 1 ? parts[0].Trim('"', '[', ']') : "dbo"; - var tableName = parts[parts.Length - 1].Trim('"', '[', ']'); + var tableName = parts[^1].Trim('"', '[', ']'); try { var (repo, connectionString, dbType) = await _dynamicDataManager.GetAsync(false, dataSourceCode); - var query = dbType == Sozsoft.Platform.Enums.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}'"; + // Şema/tablo adları kullanıcıdan geldiği için sorguya parametre olarak bağlanır. + 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"; - var rows = await repo.QueryAsync(query, connectionString); + var parameters = new Dictionary + { + ["schemaName"] = schemaName, + ["tableName"] = tableName, + }; + + var rows = await repo.QueryAsync(query, connectionString, parameters); var columns = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) { - var dict = row as IDictionary; - if (dict != null) - { - var key = dict.ContainsKey("COLUMN_NAME") ? "COLUMN_NAME" : "column_name"; - if (dict.TryGetValue(key, out var col) && col != null) - columns.Add(col.ToString()!); - } + if (row is not IDictionary dict) + continue; + + var key = dict.ContainsKey("COLUMN_NAME") ? "COLUMN_NAME" : "column_name"; + if (dict.TryGetValue(key, out var col) && col != null) + columns.Add(col.ToString()!); } return columns; } - catch + catch (Exception ex) { + logger.LogWarning(ex, "Table columns could not be read for {DataSourceCode}/{SelectCommand}", dataSourceCode, selectCommand); return []; } } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 8c51964e..7342f3b0 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -3620,13 +3620,13 @@ }, { "resourceName": "Platform", - "key": "Permission:Setup", + "key": "App.Setup", "en": "Setup & Database Maintenance", "tr": "Kurulum ve Veritabanı Bakımı" }, { "resourceName": "Platform", - "key": "Permission:Setup.Migrate", + "key": "App.Setup.Migrate", "en": "Run database migration", "tr": "Veritabanı geçişi çalıştırma" }, diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json index 5459dd7e..ab8274d4 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json @@ -7,9 +7,22 @@ { "Name": "App.Administration", "DisplayName": "App.Administration" + }, + { + "Name": "App.Setup", + "DisplayName": "App.Setup" } ], "PermissionDefinitionRecords": [ + { + "GroupName": "App.Setup", + "Name": "App.Setup.Migrate", + "ParentName": null, + "DisplayName": "App.Setup.Migrate", + "IsEnabled": true, + "MultiTenancySide": 2, + "MenuGroup": "Erp|Kurs" + }, { "GroupName": "App.Saas", "Name": "AbpTenantManagement.Tenants", diff --git a/ui/src/views/admin/listForm/wizard/Wizard.tsx b/ui/src/views/admin/listForm/wizard/Wizard.tsx index 4371a286..8f6e8652 100644 --- a/ui/src/views/admin/listForm/wizard/Wizard.tsx +++ b/ui/src/views/admin/listForm/wizard/Wizard.tsx @@ -4,7 +4,7 @@ import { SelectBoxOption } from '@/types/shared' import { useLocalization } from '@/utils/hooks/useLocalization' import { Form, Formik, FormikProps } from 'formik' import type { KeyboardEvent } from 'react' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Helmet } from 'react-helmet' import { useLocation, useNavigate } from 'react-router-dom' import * as Yup from 'yup' @@ -197,6 +197,79 @@ const step2ValidationSchema = Yup.object().shape({ 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) => + 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 ─────────────────────────────────────────────────────────────────── const Wizard = () => { @@ -225,25 +298,32 @@ const Wizard = () => { selectCommandType: SelectCommandTypeEnum } | 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) { setDbObjects(null) + setIsLoadingDbObjects(false) return } setIsLoadingDbObjects(true) try { const res = await sqlObjectManagerService.getAllObjects(dsCode) + if (dbObjectsRequestRef.current !== requestId) return setDbObjects(res.data) } catch { + if (dbObjectsRequestRef.current !== requestId) return setDbObjects(null) } finally { - setIsLoadingDbObjects(false) + if (dbObjectsRequestRef.current === requestId) setIsLoadingDbObjects(false) } - } + }, []) useEffect(() => { loadDbObjects(currentDataSource) - }, [currentDataSource]) + }, [currentDataSource, loadDbObjects]) // When dbObjects become available in edit mode, load columns for the saved selectCommand useEffect(() => { @@ -293,31 +373,6 @@ const Wizard = () => { }) const [criteria, setCriteria] = useState([]) - // 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) => - 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) => { if (!dsCode || !name) { setSelectCommandColumns([]) @@ -368,7 +423,7 @@ const Wizard = () => { return next }) - const toggleAllColumns = (all: boolean, isTenant = formikRef.current?.values.isTenant ?? false) => + const toggleAllColumns = (all: boolean, isTenant: boolean) => setSelectedColumns( all ? new Set( @@ -469,14 +524,14 @@ const Wizard = () => { confirmDelete: w.confirmDelete ?? true, allowDetail: w.allowDetail ?? false, defaultLayout: w.defaultLayout ?? 'grid', - grid: w.grid ?? true, - card: w.card ?? true, - pivot: w.pivot ?? true, - tree: w.tree ?? true, - chart: w.chart ?? true, - gantt: w.gantt ?? true, - scheduler: w.scheduler ?? true, - todo: w.todo ?? true, + grid: w.grid ?? initialValues.grid, + card: w.card ?? initialValues.card, + pivot: w.pivot ?? initialValues.pivot, + tree: w.tree ?? initialValues.tree, + chart: w.chart ?? initialValues.chart, + gantt: w.gantt ?? initialValues.gantt, + scheduler: w.scheduler ?? initialValues.scheduler, + todo: w.todo ?? initialValues.todo, treeOptionDto: w.treeOptionDto ?? initialValues.treeOptionDto, ganttOptionDto: w.ganttOptionDto ?? initialValues.ganttOptionDto, schedulerOptionDto: w.schedulerOptionDto ?? initialValues.schedulerOptionDto, @@ -578,18 +633,6 @@ const Wizard = () => { const navigate = useNavigate() const formikRef = useRef>(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 formik = formikRef.current const spacedLabel = toSpacedLabel(name) @@ -710,24 +753,23 @@ const Wizard = () => { await getMenuList() } - const handleNext = async () => { - if (!formikRef.current) return - const errors = await formikRef.current.validateForm() - const step1Fields = Object.keys(step1ValidationSchema.fields) - const hasStep1Errors = step1Fields.some((f) => errors[f as keyof ListFormWizardDto]) - - // Always mark step1 fields touched so validation errors become visible - const touchedStep1 = step1Fields.reduce( + /** Alanları touched yapar, hata yoksa hedef adıma geçer. */ + const validateAndGo = async (fields: (keyof ListFormWizardDto)[], targetStep: number) => { + const formik = formikRef.current + if (!formik) return + const errors = await formik.validateForm() + const touched = fields.reduce( (acc, key) => ({ ...acc, [key]: true }), {} as Record, ) - await formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep1 }) - - // Also require wizardName - if (hasStep1Errors) return - setCurrentStep(1) + await formik.setTouched({ ...formik.touched, ...touched }) + if (fields.some((field) => errors[field])) return + setCurrentStep(targetStep) } + const handleNext = () => validateAndGo(step1FieldNames, 1) + const handleNext2 = () => validateAndGo(step2FieldNames, 2) + const handleBack = () => setCurrentStep(0) 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, - ) - await formikRef.current.setTouched({ ...formikRef.current.touched, ...touchedStep2 }) - if (hasStep2Errors) return - setCurrentStep(2) - } - const handleDeploy = async () => { + const formik = formikRef.current + if (!formik) throw new Error(translate('::App.Platform.Unknown') || 'Form bulunamadı') + + const values = formik.values + 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) if (isEditMode && editFileName) { await deleteWizardFile(editFileName) } - // 🔴 Önce kayıt işlemi TAMAMLANSIN await postListFormWizard({ ...values, groups: editingGroups.map((g) => ({ @@ -786,7 +814,7 @@ const Wizard = () => { colSpan: item.colSpan, isRequired: item.isRequired, includeInEditingForm: item.includeInEditingForm, - dbSourceType: col ? sqlDataTypeToDbType(col.dataType) : 12, + dbSourceType: col ? sqlDataTypeToDbType(col.dataType) : DbTypeEnum.String, turkishCaption: item.turkishCaption, englishCaption: item.englishCaption, 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) - // ✅ sonra navigate - navigate(ROUTES_ENUM.protected.admin.list.replace(':listFormCode', values.listFormCode), { - replace: true, - }) - - // ✅ en son kullanıcıya mesaj toast.push( {translate('::ListForms.FormBilgileriKaydedildi')} , { 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( + + {err?.message ?? translate('::App.Platform.Unknown') ?? 'Unknown error'} + , + { 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, title: translate('::ListForms.ListFormEdit.TabWorkflow') || 'Workflow', }, - ...(values.todo - ? [ - { - 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', - }, - ] - : []), + ...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') || '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 currentIndex = activeSteps.findIndex((step) => step.id === currentStep) + const currentIndex = activeSteps.findIndex((step) => step.id === resolvedStep) const targetStep = activeSteps[currentIndex + direction] if (targetStep) { setCurrentStep(targetStep.id) @@ -917,7 +928,7 @@ const Wizard = () => { <>
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" > {
- = 2 ? undefined : 'sm'}> + = 2 ? undefined : 'sm'}> {/* ─── Step 1: Basic Info ─────────────────────────────── */} - {currentStep === 0 && ( + {resolvedStep === 0 && ( { )} {/* ─── Step 2: Data Settings ───────────────────────────── */} - {currentStep === 1 && ( + {resolvedStep === 1 && ( { )} {/* ─── Step 3: List Form Fields ───────────────────────────── */} - {currentStep === 2 && ( + {resolvedStep === 2 && ( { )} {/* ─── Step 4: Sub Forms ───────────────────────────── */} - {currentStep === 3 && ( + {resolvedStep === 3 && ( { )} {/* ─── Step 5: Widgets ───────────────────────────── */} - {currentStep === 4 && ( + {resolvedStep === 4 && ( { )} {/* ─── Step 6: Workflow ───────────────────────────── */} - {currentStep === 5 && ( + {resolvedStep === 5 && ( { )} {/* ─── Step 7: Todo / Kanban ────────────────────── */} - {currentStep === 6 && values.todo && ( + {resolvedStep === 6 && values.todo && ( { )} {/* ─── Step 8: Tree ─────────────────────────────── */} - {currentStep === 7 && values.tree && ( + {resolvedStep === 7 && values.tree && ( { )} {/* ─── Step 9: Gantt ────────────────────────────── */} - {currentStep === 8 && values.gantt && ( + {resolvedStep === 8 && values.gantt && ( { )} {/* ─── Step 10: Scheduler ───────────────────────── */} - {currentStep === 9 && values.scheduler && ( + {resolvedStep === 9 && values.scheduler && ( { )} {/* ─── Step 11: Deploy ──────────────────────────── */} - {currentStep === 10 && ( + {resolvedStep === 10 && ( { const [deletingFile, setDeletingFile] = useState(null) const [confirm, setConfirm] = useState(null) const [search, setSearch] = useState('') - const IconComponent = (icon: string) => { - return navigationIcon[icon] || FaDatabase // default icon - } const filteredFiles = useMemo(() => { const q = search.trim().toLowerCase() @@ -67,7 +64,7 @@ const WizardFileManager = () => { } finally { setLoading(false) } - }, []) + }, [translate]) useEffect(() => { loadFiles() @@ -168,7 +165,7 @@ const WizardFileManager = () => {
{filteredFiles.map((f) => { - const Icon = IconComponent(f.menuIcon) + const Icon = navigationIcon[f.menuIcon] ?? FaDatabase return (
{
{!f.hasInsertedRecords && ( @@ -232,36 +232,34 @@ const WizardFileManager = () => {
{/* Delete Confirm Dialog */} - {confirm && ( -
-
-
- -
-

- {translate('::App.Platform.DeleteAction')} -

-

- {translate('::App.Listforms.WizardFileDeleteConfirm')} -

-
-
-
- - + setConfirm(null)} + onRequestClose={() => setConfirm(null)} + > + +
+ +
+

+ {translate('::App.Platform.DeleteAction')} +

+

+ {confirm?.wizardName}{' '} + {translate('::App.Listforms.WizardFileDeleteConfirm')} +

-
- )} + + + + + + ) } diff --git a/ui/src/views/admin/listForm/wizard/WizardStep1.tsx b/ui/src/views/admin/listForm/wizard/WizardStep1.tsx index 0d8ea56b..ce31de7a 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStep1.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStep1.tsx @@ -66,12 +66,25 @@ export function filterNonLinkNodes(nodes: MenuTreeNode[]): MenuTreeNode[] { .map((n) => ({ ...n, children: filterNonLinkNodes(n.children) })) } +/** code → parentCode haritası; her aramada listeyi taramamak için. */ +function buildParentMap(rawItems: MenuItem[]): Map { + const map = new Map() + rawItems.forEach((item) => { + if (item.code) map.set(item.code, item.parentCode ?? undefined) + }) + return map +} + export function findRootCode(rawItems: MenuItem[], code: string): string { + const parents = buildParentMap(rawItems) + const visited = new Set() let current = code - for (let i = 0; i < 50; i++) { - const item = rawItems.find((r) => r.code === current) - if (!item?.parentCode) return current - current = item.parentCode + // visited: veri bozulsa bile (döngüsel parent) sonsuz döngüye girilmez + while (!visited.has(current)) { + visited.add(current) + const parentCode = parents.get(current) + if (!parentCode) return current + current = parentCode } return current } @@ -270,13 +283,14 @@ function TreeNode({ /** Returns all ancestor codes of `code` in the flat rawItems list */ function getAncestorCodes(rawItems: MenuItem[], code: string): Set { + const parents = buildParentMap(rawItems) const result = new Set() let current = code - for (let i = 0; i < 50; i++) { - const item = rawItems.find((r) => r.code === current) - if (!item?.parentCode) break - result.add(item.parentCode) - current = item.parentCode + while (true) { + const parentCode = parents.get(current) + if (!parentCode || result.has(parentCode)) break + result.add(parentCode) + current = parentCode } return result } @@ -375,12 +389,16 @@ function MenuTreeInline({ } } - function enrichNode(node: MenuTreeNode): MenuTreeNode & { id?: string } { - const raw = rawItems.find((i) => i.code === node.code) - return { ...node, id: raw?.id, children: node.children.map(enrichNode) } - } - - const enrichedNodes = nodes.map(enrichNode) + // Menü ağacı her render'da yeniden dolaşılmasın; id eşlemesi tek haritadan yapılır. + const enrichedNodes = useMemo(() => { + const idByCode = new Map(rawItems.map((item) => [item.code, item.id])) + const enrichNode = (node: MenuTreeNode): MenuTreeNode & { id?: string } => ({ + ...node, + id: idByCode.get(node.code), + children: node.children.map(enrichNode), + }) + return nodes.map(enrichNode) + }, [nodes, rawItems]) const sharedNodeProps = { expanded, @@ -483,6 +501,11 @@ const WizardStep1 = ({ const step1Missing = [ !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.languageTextMenuEn && translate('::ListForms.Wizard.Step4.MenuEn'), !values.languageTextMenuTr && translate('::ListForms.Wizard.Step4.MenuTr'), diff --git a/ui/src/views/admin/listForm/wizard/WizardStep11.tsx b/ui/src/views/admin/listForm/wizard/WizardStep11.tsx index bae813d2..07bc27b2 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStep11.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStep11.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui' import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' -import React, { useState } from 'react' +import React, { useMemo, useState } from 'react' import { FaArrowLeft, FaCheckCircle, @@ -34,7 +34,8 @@ export interface WizardStep11Props { widgets: WidgetEditDto[] workflow: WorkflowDto 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 onBack: () => void onSubmit: () => Promise } @@ -48,6 +49,18 @@ interface LogEntry { detail?: string } +/** + * Bağlantı cümlesi özet ekranında açı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 ───────────────────────────────────────────────────────── function buildLogSteps( @@ -175,7 +188,10 @@ const WizardStep11 = ({ const [isDone, setIsDone] = 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)) @@ -308,7 +324,7 @@ const WizardStep11 = ({ /> + {/* Bağlantı cümlesi kimlik bilgisi içerebilir; ekranda ve tarayıcı + otomatik doldurmasında açıkta bırakılmaz. */} { ) } +/** + * 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 açıyordu. + */ +let idCounter = 0 +const nextId = (prefix: string) => `${prefix}_${Date.now()}_${++idCounter}` + function newGroupItem(colName: string, meta?: DatabaseColumnDto): WizardGroupItem { const sqlType = meta?.dataType ?? '' return { - id: `${colName}_${Date.now()}`, + id: nextId(colName), fieldName: colName, editorType: inferEditorType(sqlType), editorOptions: '', @@ -132,7 +140,7 @@ function newGroupItem(colName: string, meta?: DatabaseColumnDto): WizardGroupIte function newGroup(order: number): WizardGroup { return { - id: `grp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + id: nextId('grp'), caption: `Group ${order}`, colCount: 2, items: [], @@ -864,22 +872,13 @@ const WizardStep3 = ({ // 2. Ensure at least one empty group exists for the user to drag into const finalGroups = cleaned.length === 0 - ? [ - { - id: `grp_default_${Date.now()}`, - caption: '', - colCount: 2, - items: [] as WizardGroupItem[], - }, - ] + ? [{ id: nextId('grp_default'), caption: '', colCount: 2, items: [] as WizardGroupItem[] }] : cleaned - // 3. Only update if something actually changed - const itemsChanged = finalGroups.some( - (g, i) => g.items.length !== (groups[i]?.items.length ?? -1), - ) - const countChanged = finalGroups.length !== groups.length - if (itemsChanged || countChanged) onGroupsChange(finalGroups) + // 3. Yalnızca gerçekten bir item düştüyse (veya varsayılan grup eklendiyse) güncelle. + // Uzunluk karşılaştırması aynı sayıda ekleme/çıkarma durumunu kaçırıyordu. + const removedItem = cleaned.some((g, i) => g.items.length !== groups[i].items.length) + if (removedItem || finalGroups.length !== groups.length) onGroupsChange(finalGroups) // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedColumns]) diff --git a/ui/src/views/admin/listForm/wizard/WizardStep4.tsx b/ui/src/views/admin/listForm/wizard/WizardStep4.tsx index 7d3a1b68..19c7fe29 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStep4.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStep4.tsx @@ -39,7 +39,9 @@ const schema = object().shape({ 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: '', tabType: SubFormTabTypeEnum.List, code: '', @@ -47,7 +49,7 @@ const emptySubForm: SubFormDto = { relation: [], tabMode: 'edit', searchParams: new URLSearchParams(), -} +}) function WizardStep4({ subForms, @@ -243,7 +245,7 @@ function WizardStep4({ > {dialogIndex !== null && ( diff --git a/ui/src/views/admin/listForm/wizard/WizardStep6.tsx b/ui/src/views/admin/listForm/wizard/WizardStep6.tsx index a101e030..22aca92e 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStep6.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStep6.tsx @@ -81,14 +81,23 @@ function WizardStep6({ })) useEffect(() => { - getUsers(0, 1000).then((response) => { - setUserList( - (response.data?.items ?? []).map((user: IdentityUserDto) => ({ - value: user.userName, - label: `${user.userName} (${user.name} ${user.surname})`, - })), - ) - }) + let cancelled = false + getUsers(0, 1000) + .then((response) => { + if (cancelled) return + 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(() => { diff --git a/ui/src/views/admin/listForm/workflow/WorkflowCanvas.tsx b/ui/src/views/admin/listForm/workflow/WorkflowCanvas.tsx index 573dd1f6..35523254 100644 --- a/ui/src/views/admin/listForm/workflow/WorkflowCanvas.tsx +++ b/ui/src/views/admin/listForm/workflow/WorkflowCanvas.tsx @@ -298,7 +298,8 @@ function FlowNode({ variant="plain" shape="none" 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-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': diff --git a/ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx b/ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx index 59503876..958330ba 100644 --- a/ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx +++ b/ui/src/views/admin/listForm/workflow/WorkflowDesigner.tsx @@ -51,6 +51,24 @@ const designerIconButtonClass = 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({ busy, canvasRef, @@ -177,10 +195,7 @@ function DesignerToolbar({ type="button" variant="solid" shape="round" - className={classNames( - 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', - )} + className={classNames(designerButtonClass, designerNeutralColorClass)} disabled={busy} title="Demo akışı yükle" onClick={onResetDemo} @@ -192,10 +207,7 @@ function DesignerToolbar({ type="button" variant="solid" shape="round" - className={classNames( - 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', - )} + className={classNames(designerButtonClass, designerAccentColorClass)} disabled={busy || currentCriteria.length === 0} title="Düğümleri okunabilir şekilde yerleştir" onClick={onFitLayout} @@ -207,10 +219,7 @@ function DesignerToolbar({ type="button" variant="solid" shape="round" - className={classNames( - 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', - )} + className={classNames(designerIconButtonClass, designerAccentColorClass)} title="Yakınlaştır" onClick={onZoomIn} > @@ -220,10 +229,7 @@ function DesignerToolbar({ type="button" variant="solid" shape="round" - className={classNames( - 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', - )} + className={classNames(designerIconButtonClass, designerAccentColorClass)} title="Uzaklaştır" onClick={onZoomOut} > @@ -240,10 +246,7 @@ function DesignerToolbar({ type="button" variant="solid" shape="round" - className={classNames( - 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', - )} + className={classNames(designerButtonClass, designerAccentColorClass)} disabled={busy} onClick={() => onAddCriteria(option.value)} > @@ -274,9 +277,7 @@ function DesignerTabs({ role="tab" className={classNames( designerTabClass, - activeTab === 'flow' - ? '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', + activeTab === 'flow' ? designerTabActiveClass : designerTabInactiveClass, )} onClick={() => onChange('flow')} > @@ -289,9 +290,7 @@ function DesignerTabs({ role="tab" className={classNames( designerTabClass, - activeTab === 'criteria' - ? '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', + activeTab === 'criteria' ? designerTabActiveClass : designerTabInactiveClass, )} onClick={() => onChange('criteria')} > diff --git a/ui/src/views/shared/MenuAddDialog.tsx b/ui/src/views/shared/MenuAddDialog.tsx index 23b9db50..97eebf88 100644 --- a/ui/src/views/shared/MenuAddDialog.tsx +++ b/ui/src/views/shared/MenuAddDialog.tsx @@ -6,6 +6,8 @@ import navigationIcon from '@/proxy/menus/navigation-icon.config' import { useEffect, useRef, useState } from 'react' import { FaChevronDown, FaPlus } from 'react-icons/fa' import { useLocalization } from '@/utils/hooks/useLocalization' +import { useConfig } from '@/components/ui/ConfigProvider' +import { useForm } from '@/components/ui/Form/context' const menuService = new MenuService() @@ -26,6 +28,12 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro const [limit, setLimit] = useState(ICON_PAGE_SIZE) const wrapperRef = useRef(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 filtered = search.trim() ? ALL_ICON_ENTRIES.filter(([key]) => key.toLowerCase().includes(search.toLowerCase())) @@ -48,27 +56,33 @@ export function IconPickerField({ value, onChange, invalid }: IconPickerFieldPro return (
+ {/* + Tetikleyici, platformun `.input` sınıfını kullanır; böylece kenarlık, köşe + yarıçapı, dolgu ve açı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. + */}