Wizard Export Özelliği
This commit is contained in:
parent
3152cd681b
commit
3aa74318c2
7 changed files with 451 additions and 9 deletions
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Content;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms;
|
||||
|
||||
|
|
@ -9,5 +10,8 @@ public interface IListFormWizardAppService
|
|||
Task<List<WizardFileInfoDto>> GetFiles();
|
||||
Task<WizardSeedFileDto> GetFile(string fileName);
|
||||
Task DeleteFile(string fileName);
|
||||
|
||||
/// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary>
|
||||
Task<IRemoteStreamContent> ExportFile(string fileName);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Sozsoft.Languages.Entities;
|
||||
using Sozsoft.Languages.Languages;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using Sozsoft.Platform.Enums;
|
||||
using Volo.Abp;
|
||||
using Volo.Abp.Content;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
using Volo.Abp.MultiTenancy;
|
||||
using Volo.Abp.PermissionManagement;
|
||||
|
|
@ -19,6 +22,7 @@ using Sozsoft.Languages;
|
|||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.DynamicData;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Sozsoft.Platform.PlatformConsts;
|
||||
|
||||
|
|
@ -86,6 +90,11 @@ public class ListFormWizardAppService(
|
|||
input.ListFormCode = listFormCode;
|
||||
input.MenuCode = code;
|
||||
|
||||
// Ad ve kod catismalari, kayitlar uretilmeye baslamadan once reddedilir; aksi halde
|
||||
// yarim kalan bir deploy mevcut menu/permission kayitlarini bozabilir.
|
||||
ValidateWizardName(wizardName);
|
||||
await ValidateWizardUniquenessAsync(wizardName, code, input.MenuParentCode);
|
||||
|
||||
// Yol ayrımı: Custom seçildiğinde ListForm ekosistemi (veri kaynağı, ListForm,
|
||||
// alanlar, workflow) hiç üretilmez; menü doğrudan CustomComponent rotasına bağlanır.
|
||||
var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom;
|
||||
|
|
@ -821,6 +830,61 @@ public class ListFormWizardAppService(
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Wizard adi hem menu/permission/dil anahtarlarinin hem de seed dosyasi adinin kaynagidir;
|
||||
/// bu yuzden yalnizca ASCII harf/rakam/alt cizgi kabul edilir. Bosluk ve Turkce karakter
|
||||
/// iceren bir ad, uretilen anahtarlari ve dosya adini bozar.
|
||||
/// </summary>
|
||||
private static readonly Regex WizardNamePattern = new(@"^[A-Za-z][A-Za-z0-9_]*$", RegexOptions.Compiled);
|
||||
|
||||
private static void ValidateWizardName(string wizardName)
|
||||
{
|
||||
if (!WizardNamePattern.IsMatch(wizardName))
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"'{wizardName}' geçerli bir Wizard adı değil. Ad bir harf ile başlamalı; yalnızca İngiliz alfabesindeki harfler, rakamlar ve alt çizgi (_) içerebilir. Boşluk ve Türkçe karakter (ş, ç, ğ, ü, ö, ı) kullanılamaz.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard'in uretecegi menu kodunun bir baskasiyla catismadigini dogrular.
|
||||
/// <para>
|
||||
/// En kritik durum menu kodunun secilen ust menunun koduyla ayni olmasidir: bu durumda wizard
|
||||
/// ust menuyu kendi kaydiyla ezer ve menu agacinda ust menu kaybolur. Ayni sekilde zaten var
|
||||
/// olan bir menu koduna ya da daha once kaydedilmis bir Wizard adina izin verilmez; ikisi de
|
||||
/// mevcut kayitlarin uzerine yazilmasi anlamina gelir.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duzenleme akisi once <see cref="DeleteFile"/> cagirdigi icin (seed dosyasi ve wizard'in
|
||||
/// olusturdugu menu kaydi silinir) ayni ad ile yeniden deploy edilebilir.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task ValidateWizardUniquenessAsync(string wizardName, string code, string menuParentCode)
|
||||
{
|
||||
if (string.Equals(code, menuParentCode?.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"Menü kodu ('{code}') üst menü kodu ile aynı olamaz. Farklı bir Wizard adı ya da menü kodu seçin; aksi halde üst menü bu kaydın üzerine yazılır.");
|
||||
}
|
||||
|
||||
var existingFiles = await GetFiles();
|
||||
var duplicateFile = existingFiles.FirstOrDefault(f =>
|
||||
string.Equals(f.WizardName?.Trim(), wizardName, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicateFile != null)
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"'{wizardName}' adında bir Wizard zaten var ({duplicateFile.FileName}). Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin.");
|
||||
}
|
||||
|
||||
var menuQueryable = await repoMenu.GetQueryableAsync();
|
||||
if (await AsyncExecuter.AnyAsync(menuQueryable.Where(a => a.Code == code)))
|
||||
{
|
||||
throw new UserFriendlyException(
|
||||
$"'{code}' kodlu bir menü zaten var. Farklı bir Wizard adı ya da menü kodu seçin.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard seed dosyalarinin yazilacagi wizard dizinini dondurur; kapsam aktif tenant'i izler
|
||||
/// (host baglaminda <c>host/wizard</c>, tenant baglaminda <c>tenants/{tenantId}/wizard</c>).
|
||||
|
|
@ -831,6 +895,193 @@ public class ListFormWizardAppService(
|
|||
return SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id, SeedPathResolver.WizardFolder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wizard seed dosyasini ve bagli oldugu tum seed dosyalarini tek bir zip icinde dondurur.
|
||||
/// Zip icindeki yollar seed kapsam klasoru (host / tenants/{tenantId}) koku baz alinarak yazilir;
|
||||
/// dolayisiyla arsiv baska bir ortamda ayni klasor duzenine dogrudan acilabilir.
|
||||
/// <para>
|
||||
/// Custom yolunda: <c>wizard/{dosya}.json</c>, <c>custom/{component}.json</c> (bagimliliklari ile birlikte)
|
||||
/// ve bu component'lerin data source'larinin isaret ettigi <c>crud/{entity}.json</c> dosyalari.
|
||||
/// List yolunda: <c>wizard/{dosya}.json</c>, SelectCommand'a karsilik gelen
|
||||
/// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HttpGet("api/app/list-form-wizard/export")]
|
||||
public async Task<IRemoteStreamContent> ExportFile(string fileName)
|
||||
{
|
||||
var filePath = ResolveWizardSeedFilePath(fileName);
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var seed = JsonSerializer.Deserialize<WizardSeedFileDto>(json, SeedJsonReadOptions)
|
||||
?? throw new UserFriendlyException("Dosya okunamadı.");
|
||||
|
||||
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
|
||||
|
||||
// Ayni dosyanin iki farkli yoldan (ornegin iki component ayni crud dosyasini kullanir)
|
||||
// eklenmesini engellemek icin zip girdileri yola gore tekillestirilir.
|
||||
var entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[$"{SeedPathResolver.WizardFolder}/{Path.GetFileName(filePath)}"] = filePath
|
||||
};
|
||||
|
||||
if (seed.Wizard?.ComponentKind == WizardComponentKindEnum.Custom)
|
||||
await CollectCustomComponentEntriesAsync(scopeRoot, seed.Wizard.CustomComponentName, entries);
|
||||
else
|
||||
CollectListComponentEntries(scopeRoot, seed.Wizard, entries);
|
||||
|
||||
var memory = new MemoryStream();
|
||||
using (var archive = new ZipArchive(memory, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var entry in entries.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var zipEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal);
|
||||
await using var source = File.OpenRead(entry.Value);
|
||||
await using var target = zipEntry.Open();
|
||||
await source.CopyToAsync(target);
|
||||
}
|
||||
}
|
||||
|
||||
memory.Position = 0;
|
||||
|
||||
var safeName = string.Concat((seed.Wizard?.WizardName ?? Path.GetFileNameWithoutExtension(fileName))
|
||||
.Trim()
|
||||
.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(safeName))
|
||||
safeName = "Wizard";
|
||||
|
||||
return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin
|
||||
/// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile
|
||||
/// ziyaret edilen adlar takip edildigi icin sonsuz dongu olusmaz.
|
||||
/// </summary>
|
||||
private async Task CollectCustomComponentEntriesAsync(string scopeRoot, string? componentName, Dictionary<string, string> entries)
|
||||
{
|
||||
var pending = new Queue<string>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(componentName))
|
||||
pending.Enqueue(componentName.Trim());
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var name = pending.Dequeue();
|
||||
if (!visited.Add(name))
|
||||
continue;
|
||||
|
||||
var relativePath = $"{SeedPathResolver.CustomFolder}/{name}.json";
|
||||
if (!TryAddSeedEntry(scopeRoot, relativePath, entries))
|
||||
{
|
||||
logger.LogWarning("Custom component seed file not found for export: {RelativePath}", relativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
CustomComponentSeedFileDto? customSeed;
|
||||
try
|
||||
{
|
||||
var customJson = await File.ReadAllTextAsync(entries[relativePath]);
|
||||
customSeed = JsonSerializer.Deserialize<CustomComponentSeedFileDto>(customJson, SeedJsonReadOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Custom component seed file could not be parsed for export: {RelativePath}", relativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var component in customSeed?.CustomComponents ?? [])
|
||||
{
|
||||
foreach (var dependency in component.Dependencies)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(dependency))
|
||||
pending.Enqueue(dependency.Trim());
|
||||
}
|
||||
|
||||
foreach (var dataSource in component.DataSources)
|
||||
{
|
||||
// SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur;
|
||||
// elle yazilmis bir URL'in seed karsiligi yoktur.
|
||||
if (string.IsNullOrWhiteSpace(dataSource.SeedFile))
|
||||
continue;
|
||||
|
||||
if (!TryAddSeedEntry(scopeRoot, dataSource.SeedFile, entries))
|
||||
logger.LogWarning("CRUD seed file not found for export: {RelativePath}", dataSource.SeedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// List yolunda wizard'in SelectCommand'ina karsilik gelen .sql dosyasini ve ayni nesne adiyla
|
||||
/// uretilmis crud seed dosyasini toplar. Query tipinde SelectCommand bir nesne adi degil ham SQL
|
||||
/// oldugu icin dosya aramasi yapilmaz.
|
||||
/// </summary>
|
||||
private void CollectListComponentEntries(string scopeRoot, ListFormWizardDto? wizard, Dictionary<string, string> entries)
|
||||
{
|
||||
if (wizard == null || wizard.SelectCommandType == SelectCommandTypeEnum.Query)
|
||||
return;
|
||||
|
||||
var objectName = NormalizeDatabaseObjectName(wizard.SelectCommand);
|
||||
if (objectName == null)
|
||||
return;
|
||||
|
||||
// Saglayici (sql / postgres) ve klasor turu (object / execute) wizard dosyasinda tutulmadigi
|
||||
// icin bilinen dort kombinasyon sirayla denenir; hangisi varsa arsive eklenir.
|
||||
string[] providerFolders = [SeedPathResolver.SqlFolder, SeedPathResolver.PostgresFolder];
|
||||
string[] scriptFolders = [SeedPathResolver.ObjectFolder, SeedPathResolver.ExecuteFolder];
|
||||
|
||||
var sqlFound = false;
|
||||
foreach (var provider in providerFolders)
|
||||
{
|
||||
foreach (var scriptFolder in scriptFolders)
|
||||
{
|
||||
if (TryAddSeedEntry(scopeRoot, $"{provider}/{scriptFolder}/{objectName}.sql", entries))
|
||||
sqlFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sqlFound)
|
||||
logger.LogWarning("SQL seed file not found for export: {ObjectName}", objectName);
|
||||
|
||||
TryAddSeedEntry(scopeRoot, $"{SeedPathResolver.CrudFolder}/{objectName}.json", entries);
|
||||
}
|
||||
|
||||
/// <summary>Sema onekini ve tirnak/koseli parantez suslemelerini ayiklayarak nesne adini dondurur.</summary>
|
||||
private static string? NormalizeDatabaseObjectName(string? selectCommand)
|
||||
{
|
||||
var trimmed = selectCommand?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed))
|
||||
return null;
|
||||
|
||||
var name = trimmed.Split('.')[^1].Trim('"', '[', ']', ' ');
|
||||
if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
return null;
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kapsam klasorune goreli bir seed dosyasini arsiv listesine ekler. Yol her zaman kapsam
|
||||
/// klasoru icinde kalmali; disari cikan bir yol (seed dosyasindaki bozuk bir kayittan gelebilir)
|
||||
/// sessizce reddedilir. Dosya yoksa false doner.
|
||||
/// </summary>
|
||||
private static bool TryAddSeedEntry(string scopeRoot, string relativePath, Dictionary<string, string> entries)
|
||||
{
|
||||
var normalized = relativePath.Replace('\\', '/').Trim('/');
|
||||
if (normalized.Length == 0 || normalized.Contains("..", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Combine(scopeRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
|
||||
if (!fullPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
return false;
|
||||
|
||||
entries[normalized] = fullPath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetTableColumnNamesAsync(string dataSourceCode, SelectCommandTypeEnum commandType, string selectCommand)
|
||||
{
|
||||
if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure)
|
||||
|
|
|
|||
|
|
@ -16116,6 +16116,18 @@
|
|||
"en": "Wizard file deleted successfully.",
|
||||
"tr": "Wizard dosyası başarıyla silindi."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardFileExport",
|
||||
"en": "Export as zip (wizard, custom/crud and sql seed files)",
|
||||
"tr": "Zip olarak dışa aktar (wizard, custom/crud ve sql seed dosyaları)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardFileExportError",
|
||||
"en": "Failed to export wizard file.",
|
||||
"tr": "Wizard dosyası dışa aktarılamadı."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Listforms.WizardManager",
|
||||
|
|
@ -16164,6 +16176,30 @@
|
|||
"en": "Used to generate ListForm Code and Menu Code",
|
||||
"tr": "ListForm Kodu ve Menü Kodu oluşturmak için kullanılır"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.WizardStep1.WizardNameInvalid",
|
||||
"en": "The name must start with a letter and may contain only English letters, digits and underscore (_). Spaces and Turkish characters are not allowed.",
|
||||
"tr": "Ad bir harf ile başlamalı; yalnızca İngiliz alfabesindeki harfler, rakamlar ve alt çizgi (_) içerebilir. Boşluk ve Türkçe karakter kullanılamaz."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.WizardStep1.WizardNameDuplicate",
|
||||
"en": "A wizard with this name already exists. Use a different name or edit the existing record from Wizard Manager.",
|
||||
"tr": "Bu adda bir Wizard zaten var. Farklı bir ad verin ya da mevcut kaydı Wizard Manager üzerinden düzenleyin."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.WizardStep1.MenuCodeSameAsParent",
|
||||
"en": "The menu code cannot be the same as the parent menu code; the parent menu would be overwritten by this record.",
|
||||
"tr": "Menü kodu üst menü kodu ile aynı olamaz; üst menü bu kaydın üzerine yazılır."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.WizardStep1.MenuCodeDuplicate",
|
||||
"en": "A menu with this code already exists. Choose a different wizard name or menu code.",
|
||||
"tr": "Bu kodla bir menü zaten var. Farklı bir Wizard adı ya da menü kodu seçin."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.WizardStep1.Optional",
|
||||
|
|
|
|||
|
|
@ -27,3 +27,28 @@ export const deleteWizardFile = (fileName: string) =>
|
|||
url: `/api/app/list-form-wizard/file`,
|
||||
params: { fileName },
|
||||
})
|
||||
|
||||
/**
|
||||
* Wizard dosyasini ve bagli seed dosyalarini (custom/crud/sql) tek bir zip olarak indirir.
|
||||
* Zip icindeki klasor duzeni seed kapsam klasoru ile aynidir; baska bir ortamda dogrudan acilabilir.
|
||||
*/
|
||||
export const exportWizardFile = (fileName: string) =>
|
||||
apiService.fetchData<Blob>({
|
||||
method: 'GET',
|
||||
url: `/api/app/list-form-wizard/export`,
|
||||
params: { fileName },
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
/** Blob'u tarayicida indirme olarak tetikler. */
|
||||
export const downloadBlob = (blob: Blob, fileName: string) => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.style.display = 'none'
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
|
@ -34,7 +34,12 @@ import WizardStep6 from './WizardStep6'
|
|||
import { Container } from '@/components/shared'
|
||||
import { sqlDataTypeToDbType } from '../edit/options'
|
||||
import { useStoreActions } from '@/store/store'
|
||||
import { deleteWizardFile, getWizardFile, postListFormWizard } from '@/services/wizard.service'
|
||||
import {
|
||||
deleteWizardFile,
|
||||
getWizardFile,
|
||||
getWizardFiles,
|
||||
postListFormWizard,
|
||||
} from '@/services/wizard.service'
|
||||
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
|
||||
import WizardStepComponent from './WizardStepComponent'
|
||||
import WizardStepCustomComponent from './WizardStepCustomComponent'
|
||||
|
|
@ -250,6 +255,13 @@ const deriveListFormCode = (name: string) => {
|
|||
return sanitized ? `App.Wizard.${sanitized}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard adı; menü kodu, permission adı, dil anahtarı ve seed dosyası adının tek kaynağıdır.
|
||||
* Bu yüzden yalnızca ASCII harf/rakam/alt çizgi kabul edilir — boşluk ya da Türkçe karakter
|
||||
* üretilen anahtarları ve dosya adını bozar. Backend aynı kuralı `WizardNamePattern` ile uygular.
|
||||
*/
|
||||
const WIZARD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* Adım kimlikleri. `resolvedStep` geri düşerken `id < currentStep` karşılaştırması
|
||||
* yaptığı için kimlikler görüntüleme sırasıyla artan olmak zorundadır.
|
||||
|
|
@ -320,6 +332,8 @@ const Wizard = () => {
|
|||
const isEditMode = !!editFileName
|
||||
const [isLoadingEditData, setIsLoadingEditData] = useState(false)
|
||||
const editDataLoadedRef = useRef(false)
|
||||
// Düzenleme modunda açılan kaydın kendi adı ve menü kodu; çakışma kontrolünde muaf tutulur.
|
||||
const editedIdentityRef = useRef<{ wizardName: string; menuCode: string } | null>(null)
|
||||
|
||||
// ── Data Source ──
|
||||
const [isLoadingDataSource, setIsLoadingDataSource] = useState(false)
|
||||
|
|
@ -435,13 +449,15 @@ const Wizard = () => {
|
|||
// Auto-check isTenant / isBranch based on column presence
|
||||
formikRef.current?.setFieldValue('isTenant', hasTenantColumn)
|
||||
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
|
||||
// Auto-select first column as key field
|
||||
if (cols.length > 0) {
|
||||
const first = cols[0]
|
||||
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
|
||||
// Anahtar alan: tabloda Id sutunu varsa her zaman odur; sutun sirasi anahtari
|
||||
// belirlemez. Id yoksa son care olarak ilk sutuna dusulur.
|
||||
const keyColumn =
|
||||
cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
|
||||
if (keyColumn) {
|
||||
formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName)
|
||||
formikRef.current?.setFieldValue(
|
||||
'keyFieldDbSourceType',
|
||||
sqlDataTypeToDbType(first.dataType),
|
||||
sqlDataTypeToDbType(keyColumn.dataType),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -506,6 +522,18 @@ const Wizard = () => {
|
|||
}
|
||||
|
||||
// ── Menu ──
|
||||
// Deploy'dan önce ad çakışmasını gösterebilmek için kayıtlı wizard dosyalarının adları.
|
||||
const [existingWizardNames, setExistingWizardNames] = useState<string[]>([])
|
||||
const loadExistingWizardNames = async () => {
|
||||
try {
|
||||
const res = await getWizardFiles()
|
||||
setExistingWizardNames((res.data ?? []).map((f) => (f.wizardName ?? '').trim()))
|
||||
} catch {
|
||||
// Liste okunamazsa istemci tarafı uyarı verilemez; deploy'da backend doğrulaması devrede kalır.
|
||||
setExistingWizardNames([])
|
||||
}
|
||||
}
|
||||
|
||||
const [isLoadingMenu, setIsLoadingMenu] = useState(false)
|
||||
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
|
||||
const [rawMenuItems, setRawMenuItems] = useState<(MenuItem & { id?: string })[]>([])
|
||||
|
|
@ -540,6 +568,7 @@ const Wizard = () => {
|
|||
getDataSourceList()
|
||||
getMenuList()
|
||||
getPermissionGroupList()
|
||||
loadExistingWizardNames()
|
||||
}, [])
|
||||
|
||||
// ── Load edit data after reference lists are ready ──
|
||||
|
|
@ -559,6 +588,12 @@ const Wizard = () => {
|
|||
if (!seed?.wizard) return
|
||||
|
||||
const w = seed.wizard
|
||||
// Düzenlenen kaydın kendi adı ve menü kodu çakışma sayılmaz; deploy sırasında
|
||||
// eski dosya ve menü kaydı zaten siliniyor.
|
||||
editedIdentityRef.current = {
|
||||
wizardName: (w.wizardName ?? '').trim(),
|
||||
menuCode: (w.menuCode ?? '').trim(),
|
||||
}
|
||||
|
||||
// Populate formik values
|
||||
formikRef.current?.setValues({
|
||||
|
|
@ -725,6 +760,49 @@ const Wizard = () => {
|
|||
setAutoText('languageTextDescTr')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard adının deploy'u bozacak bir ada dönüşmediğini kontrol eder ve ilk hatayı döndürür.
|
||||
* Aynı kurallar backend'de `ValidateWizardName` / `ValidateWizardUniquenessAsync` ile de
|
||||
* uygulanır; buradaki kontrol yalnızca kullanıcıyı 11 adım sonra değil, adı yazarken uyarır.
|
||||
*/
|
||||
const getWizardNameError = (values: ListFormWizardDto): string | null => {
|
||||
const name = (values.wizardName ?? '').trim()
|
||||
if (!name) return null
|
||||
|
||||
if (!WIZARD_NAME_PATTERN.test(name)) {
|
||||
return translate('::App.WizardStep1.WizardNameInvalid')
|
||||
}
|
||||
|
||||
const edited = editedIdentityRef.current
|
||||
const isSameAsEdited = (value: string, editedValue?: string) =>
|
||||
!!editedValue && value.toLowerCase() === editedValue.toLowerCase()
|
||||
|
||||
if (
|
||||
!isSameAsEdited(name, edited?.wizardName) &&
|
||||
existingWizardNames.some((existing) => existing.toLowerCase() === name.toLowerCase())
|
||||
) {
|
||||
return translate('::App.WizardStep1.WizardNameDuplicate')
|
||||
}
|
||||
|
||||
const menuCode = (values.menuCode ?? '').trim()
|
||||
if (!menuCode) return null
|
||||
|
||||
// En kritik durum: menü kodu üst menü kodu ile aynıysa wizard üst menüyü kendi
|
||||
// kaydıyla ezer ve menü ağacında üst menü kaybolur.
|
||||
if (menuCode.toLowerCase() === (values.menuParentCode ?? '').trim().toLowerCase()) {
|
||||
return translate('::App.WizardStep1.MenuCodeSameAsParent')
|
||||
}
|
||||
|
||||
if (
|
||||
!isSameAsEdited(menuCode, edited?.menuCode) &&
|
||||
rawMenuItems.some((item) => (item.code ?? '').toLowerCase() === menuCode.toLowerCase())
|
||||
) {
|
||||
return translate('::App.WizardStep1.MenuCodeDuplicate')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const applyPermissionGroupFromRoot = (rootCode: string) => {
|
||||
const rootItem = rawMenuItems.find((i) => i.code === rootCode)
|
||||
|
||||
|
|
@ -1100,6 +1178,7 @@ const Wizard = () => {
|
|||
errors={errors}
|
||||
touched={touched}
|
||||
wizardName={values.wizardName}
|
||||
wizardNameError={getWizardNameError(values)}
|
||||
onWizardNameChange={handleWizardNameChange}
|
||||
rawMenuItems={rawMenuItems}
|
||||
menuTree={menuTree}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,15 @@ import {
|
|||
FaListUl,
|
||||
FaLink,
|
||||
FaExternalLinkAlt,
|
||||
FaFileExport,
|
||||
} from 'react-icons/fa'
|
||||
import DbMigrateButton from '@/components/shared/DbMigrateButton'
|
||||
import { deleteWizardFile, getWizardFiles } from '@/services/wizard.service'
|
||||
import {
|
||||
deleteWizardFile,
|
||||
downloadBlob,
|
||||
exportWizardFile,
|
||||
getWizardFiles,
|
||||
} from '@/services/wizard.service'
|
||||
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { useStoreState } from '@/store/store'
|
||||
|
|
@ -41,6 +47,7 @@ const WizardFileManager = () => {
|
|||
const [files, setFiles] = useState<WizardFileInfoDto[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deletingFile, setDeletingFile] = useState<string | null>(null)
|
||||
const [exportingFile, setExportingFile] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
|
|
@ -75,6 +82,28 @@ const WizardFileManager = () => {
|
|||
loadFiles()
|
||||
}, [loadFiles])
|
||||
|
||||
// Zip'i sunucu uretir; istemci yalnizca indirmeyi tetikler. Dosya adi Content-Disposition
|
||||
// basligindan okunur, okunamazsa wizard adina duser.
|
||||
const handleExport = async (file: WizardFileInfoDto) => {
|
||||
setExportingFile(file.fileName)
|
||||
try {
|
||||
const res = await exportWizardFile(file.fileName)
|
||||
const disposition = String(res.headers?.['content-disposition'] ?? '')
|
||||
const match = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
|
||||
const fallback = `${file.wizardName || file.fileName.replace(/\.json$/i, '')}.zip`
|
||||
downloadBlob(res.data, match ? decodeURIComponent(match[1]) : fallback)
|
||||
} catch (err: any) {
|
||||
toast.push(
|
||||
<Notification type="danger">
|
||||
{translate('::App.ListForm.WizardFileExportError')}: {err?.message ?? 'Unknown error'}
|
||||
</Notification>,
|
||||
{ placement: 'bottom-end' },
|
||||
)
|
||||
} finally {
|
||||
setExportingFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirm) return
|
||||
setDeletingFile(confirm.fileName)
|
||||
|
|
@ -251,6 +280,17 @@ const WizardFileManager = () => {
|
|||
<FaExternalLinkAlt />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="plain"
|
||||
className="text-sky-500 hover:bg-sky-50 dark:hover:bg-sky-900/20"
|
||||
type="button"
|
||||
title={translate('::App.ListForm.WizardFileExport')}
|
||||
loading={exportingFile === f.fileName}
|
||||
onClick={() => handleExport(f)}
|
||||
>
|
||||
<FaFileExport />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="plain"
|
||||
|
|
|
|||
|
|
@ -359,6 +359,8 @@ export interface WizardStep1Props {
|
|||
errors: FormikErrors<ListFormWizardDto>
|
||||
touched: FormikTouched<ListFormWizardDto>
|
||||
wizardName: string
|
||||
/** Ad kuralı ya da çakışma hatası; dolu ise adım ilerletilemez. */
|
||||
wizardNameError: string | null
|
||||
onWizardNameChange: (name: string) => void
|
||||
rawMenuItems: (MenuItem & { id?: string })[]
|
||||
menuTree: MenuTreeNode[]
|
||||
|
|
@ -386,6 +388,7 @@ const WizardStep1 = ({
|
|||
errors,
|
||||
touched,
|
||||
wizardName,
|
||||
wizardNameError,
|
||||
onWizardNameChange,
|
||||
rawMenuItems,
|
||||
menuTree,
|
||||
|
|
@ -449,7 +452,8 @@ const WizardStep1 = ({
|
|||
!values.languageTextMenuTr && translate('::App.WizardStep4.MenuTr'),
|
||||
!values.menuIcon && translate('::App.WizardStep4.MenuIcon'),
|
||||
].filter(Boolean) as string[]
|
||||
const step1CanGo = step1Missing.length === 0
|
||||
// Geçersiz ya da çakışan ad deploy'da menü/permission kayıtlarını bozar; adım burada durur.
|
||||
const step1CanGo = step1Missing.length === 0 && !wizardNameError
|
||||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
|
|
@ -457,6 +461,8 @@ const WizardStep1 = ({
|
|||
<FormItem
|
||||
label={translate('::App.WizardStep1.WizardName')}
|
||||
asterisk={true}
|
||||
invalid={!!wizardNameError}
|
||||
errorMessage={wizardNameError ?? undefined}
|
||||
extra={
|
||||
<span className="text-xs ml-2 text-gray-400">
|
||||
{translate('::App.WizardStep1.WizardNameHint')}
|
||||
|
|
@ -469,6 +475,7 @@ const WizardStep1 = ({
|
|||
placeholder="Routes, Products, Orders"
|
||||
value={wizardName}
|
||||
autoFocus
|
||||
invalid={!!wizardNameError}
|
||||
onChange={(e) => onWizardNameChange(e.target.value.replace(/\s/g, ''))}
|
||||
/>
|
||||
</FormItem>
|
||||
|
|
|
|||
Loading…
Reference in a new issue