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.Localization;
using Volo.Abp.MultiTenancy;
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 override void Define(IPermissionDefinitionContext 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)
@ -42,6 +22,4 @@ public class PlatformPermissionDefinitionProvider : PermissionDefinitionProvider
context.RemoveGroup("FeatureManagement");
context.RemoveGroup("AbpIdentity");
}
}

View file

@ -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]
/// <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(
IRepository<ListForm, Guid> repoListForm,
IRepository<ListFormField, Guid> repoListFormField,
@ -54,25 +60,31 @@ public class ListFormWizardAppService(
private readonly ILogger<ListFormWizardAppService> 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<string> { 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<List<WizardFileInfoDto>> GetFiles()
public async Task<List<WizardFileInfoDto>> GetFiles()
{
var outputPath = ResolveWizardSeedOutputPath();
var result = new List<WizardFileInfoDto>();
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<WizardSeedFileDto>(json, options);
var fileName = Path.GetFileName(file);
var json = await File.ReadAllTextAsync(file);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(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<WizardSeedFileDto> 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<WizardSeedFileDto>(json, options);
return seed ?? throw new Volo.Abp.AbpException("Dosya okunamadı.");
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(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<WizardSeedFileDto>(json, options);
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions);
if (seed != null)
await DeleteWizardDataAsync(seed);
@ -646,6 +635,37 @@ public class ListFormWizardAppService(
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)
{
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<string, object>
{
["schemaName"] = schemaName,
["tableName"] = tableName,
};
var rows = await repo.QueryAsync(query, connectionString, parameters);
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in rows)
{
var dict = row as IDictionary<string, object>;
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<string, object> 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 [];
}
}

View file

@ -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"
},

View file

@ -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",

View file

@ -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<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 ───────────────────────────────────────────────────────────────────
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<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) => {
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<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 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<string, boolean>,
)
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<string, boolean>,
)
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(
<Notification type="success" duration={2000}>
{translate('::ListForms.FormBilgileriKaydedildi')}
</Notification>,
{ 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,
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 = () => {
<>
<div className="mb-6 mt-2">
<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"
>
<Steps.Item
@ -935,9 +946,9 @@ const Wizard = () => {
</div>
<Form onKeyDown={preventEnterSubmit}>
<FormContainer size={currentStep >= 2 ? undefined : 'sm'}>
<FormContainer size={resolvedStep >= 2 ? undefined : 'sm'}>
{/* ─── Step 1: Basic Info ─────────────────────────────── */}
{currentStep === 0 && (
{resolvedStep === 0 && (
<WizardStep1
values={values}
errors={errors}
@ -963,7 +974,7 @@ const Wizard = () => {
)}
{/* ─── Step 2: Data Settings ───────────────────────────── */}
{currentStep === 1 && (
{resolvedStep === 1 && (
<WizardStep2
values={values}
errors={errors}
@ -994,7 +1005,7 @@ const Wizard = () => {
)}
{/* ─── Step 3: List Form Fields ───────────────────────────── */}
{currentStep === 2 && (
{resolvedStep === 2 && (
<WizardStep3
selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns}
@ -1009,7 +1020,7 @@ const Wizard = () => {
)}
{/* ─── Step 4: Sub Forms ───────────────────────────── */}
{currentStep === 3 && (
{resolvedStep === 3 && (
<WizardStep4
subForms={subForms}
selectCommandColumns={selectCommandColumns}
@ -1021,7 +1032,7 @@ const Wizard = () => {
)}
{/* ─── Step 5: Widgets ───────────────────────────── */}
{currentStep === 4 && (
{resolvedStep === 4 && (
<WizardStep5
widgets={widgets}
translate={translate}
@ -1032,7 +1043,7 @@ const Wizard = () => {
)}
{/* ─── Step 6: Workflow ───────────────────────────── */}
{currentStep === 5 && (
{resolvedStep === 5 && (
<WizardStep6
listFormCode={values.listFormCode}
workflow={workflow}
@ -1047,7 +1058,7 @@ const Wizard = () => {
)}
{/* ─── Step 7: Todo / Kanban ────────────────────── */}
{currentStep === 6 && values.todo && (
{resolvedStep === 6 && values.todo && (
<WizardStep7
selectedColumns={selectedColumns}
selectCommandColumns={selectCommandColumns}
@ -1058,7 +1069,7 @@ const Wizard = () => {
)}
{/* ─── Step 8: Tree ─────────────────────────────── */}
{currentStep === 7 && values.tree && (
{resolvedStep === 7 && values.tree && (
<WizardStep8
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1068,7 +1079,7 @@ const Wizard = () => {
)}
{/* ─── Step 9: Gantt ────────────────────────────── */}
{currentStep === 8 && values.gantt && (
{resolvedStep === 8 && values.gantt && (
<WizardStep9
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1078,7 +1089,7 @@ const Wizard = () => {
)}
{/* ─── Step 10: Scheduler ───────────────────────── */}
{currentStep === 9 && values.scheduler && (
{resolvedStep === 9 && values.scheduler && (
<WizardStep10
selectCommandColumns={selectCommandColumns}
translate={translate}
@ -1088,7 +1099,7 @@ const Wizard = () => {
)}
{/* ─── Step 11: Deploy ──────────────────────────── */}
{currentStep === 10 && (
{resolvedStep === 10 && (
<WizardStep11
values={values}
wizardName={values.wizardName}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import classNames from 'classnames'
import { Badge, Button, Input, Notification, toast } from '@/components/ui'
import { Badge, Button, Dialog, Input, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container'
import {
FaTrash,
@ -38,9 +38,6 @@ const WizardFileManager = () => {
const [deletingFile, setDeletingFile] = useState<string | null>(null)
const [confirm, setConfirm] = useState<ConfirmState | null>(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 = () => {
<div className="space-y-2">
{filteredFiles.map((f) => {
const Icon = IconComponent(f.menuIcon)
const Icon = navigationIcon[f.menuIcon] ?? FaDatabase
return (
<div
key={f.fileName}
@ -191,7 +188,10 @@ const WizardFileManager = () => {
<div className="flex items-center gap-2 shrink-0 sm:ml-3">
{!f.hasInsertedRecords && (
<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"
>
<FaExclamationTriangle />
@ -232,36 +232,34 @@ const WizardFileManager = () => {
</div>
{/* Delete Confirm Dialog */}
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-xl p-6 max-w-sm w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<FaExclamationTriangle className="text-red-500 text-xl shrink-0" />
<div>
<p className="font-semibold text-gray-800 dark:text-gray-200">
{translate('::App.Platform.DeleteAction')}
</p>
<p className="text-sm text-gray-500 mt-1">
{translate('::App.Listforms.WizardFileDeleteConfirm')}
</p>
</div>
</div>
<div className="flex justify-end gap-2 mt-4">
<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
isOpen={confirm !== null}
onClose={() => setConfirm(null)}
onRequestClose={() => setConfirm(null)}
>
<Dialog.Body>
<div className="flex items-start gap-3">
<FaExclamationTriangle className="text-red-500 text-xl shrink-0 mt-1" />
<div>
<p className="font-semibold text-gray-800 dark:text-gray-200">
{translate('::App.Platform.DeleteAction')}
</p>
<p className="text-sm text-gray-500 mt-1">
<strong>{confirm?.wizardName}</strong>{' '}
{translate('::App.Listforms.WizardFileDeleteConfirm')}
</p>
</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>
)
}

View file

@ -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<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 {
const parents = buildParentMap(rawItems)
const visited = new Set<string>()
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<string> {
const parents = buildParentMap(rawItems)
const result = new Set<string>()
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'),

View file

@ -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, string | number>) => string
onBack: () => void
onSubmit: () => Promise<void>
}
@ -48,6 +49,18 @@ interface LogEntry {
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 ─────────────────────────────────────────────────────────
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 = ({
/>
<Row
label={translate('::App.Listform.ListformField.ConnectionString')}
value={values.dataSourceConnectionString}
value={maskConnectionString(values.dataSourceConnectionString)}
/>
<Row
label={translate('::ListForms.Wizard.Step4.SelectCommand')}

View file

@ -114,6 +114,10 @@ const WizardStep2 = ({
const step2Missing = [
!values.listFormCode && translate('::App.Listform.ListformField.ListFormCode'),
!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.keyFieldName && translate('::ListForms.Wizard.Step4.KeyField'),
!values.languageTextDescEn && translate('::ListForms.Wizard.Step2.DescriptionTextEnglish'),
@ -188,12 +192,15 @@ const WizardStep2 = ({
{isDataSourceNew && (
<FormItem
label={translate('::App.Listform.ListformField.ConnectionString')}
asterisk={true}
invalid={!!(errors.dataSourceConnectionString && touched.dataSourceConnectionString)}
errorMessage={errors.dataSourceConnectionString}
>
{/* Bağlantı cümlesi kimlik bilgisi içerebilir; ekranda ve tarayıcı
otomatik doldurmasında ıkta bırakılmaz. */}
<Field
type="text"
autoComplete="off"
type="password"
autoComplete="new-password"
name="dataSourceConnectionString"
placeholder={translate('::App.Listform.ListformField.ConnectionString')}
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 {
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])

View file

@ -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 && (
<Formik
initialValues={dialogIndex === -1 ? emptySubForm : subForms[dialogIndex]}
initialValues={dialogIndex === -1 ? createEmptySubForm() : subForms[dialogIndex]}
validationSchema={schema}
onSubmit={upsertSubForm}
>

View file

@ -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(() => {

View file

@ -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':

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'
/**
* 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')}
>

View file

@ -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<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 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 (
<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
type="button"
onClick={() => setOpen((v) => !v)}
variant="plain"
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
${invalid ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:border-indigo-400`}
size={controlSize}
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
${invalid ? 'input-invalid' : ''}`}
>
{SelectedIcon ? (
<span className="flex items-center gap-2 flex-1 truncate">
<SelectedIcon className="text-xl shrink-0" />
<span className="text-xs text-gray-500 truncate">{value}</span>
<span className="truncate">{value}</span>
</span>
) : (
<span className="text-gray-400 flex-1">
<span className="flex-1 !text-gray-400">
{translate('::ListForms.Wizard.Step1.SelectIcon') || 'Select icon...'}
</span>
)}
<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>