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.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Volo.Abp.Content;
|
||||||
|
|
||||||
namespace Sozsoft.Platform.ListForms;
|
namespace Sozsoft.Platform.ListForms;
|
||||||
|
|
||||||
|
|
@ -9,5 +10,8 @@ public interface IListFormWizardAppService
|
||||||
Task<List<WizardFileInfoDto>> GetFiles();
|
Task<List<WizardFileInfoDto>> GetFiles();
|
||||||
Task<WizardSeedFileDto> GetFile(string fileName);
|
Task<WizardSeedFileDto> GetFile(string fileName);
|
||||||
Task DeleteFile(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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Sozsoft.Languages.Entities;
|
using Sozsoft.Languages.Entities;
|
||||||
using Sozsoft.Languages.Languages;
|
using Sozsoft.Languages.Languages;
|
||||||
using Sozsoft.Platform.Entities;
|
using Sozsoft.Platform.Entities;
|
||||||
using Sozsoft.Platform.Enums;
|
using Sozsoft.Platform.Enums;
|
||||||
using Volo.Abp;
|
using Volo.Abp;
|
||||||
|
using Volo.Abp.Content;
|
||||||
using Volo.Abp.Domain.Repositories;
|
using Volo.Abp.Domain.Repositories;
|
||||||
using Volo.Abp.MultiTenancy;
|
using Volo.Abp.MultiTenancy;
|
||||||
using Volo.Abp.PermissionManagement;
|
using Volo.Abp.PermissionManagement;
|
||||||
|
|
@ -19,6 +22,7 @@ using Sozsoft.Languages;
|
||||||
using Sozsoft.Platform.Data.Seeds;
|
using Sozsoft.Platform.Data.Seeds;
|
||||||
using Sozsoft.Platform.DynamicData;
|
using Sozsoft.Platform.DynamicData;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static Sozsoft.Platform.PlatformConsts;
|
using static Sozsoft.Platform.PlatformConsts;
|
||||||
|
|
||||||
|
|
@ -86,6 +90,11 @@ public class ListFormWizardAppService(
|
||||||
input.ListFormCode = listFormCode;
|
input.ListFormCode = listFormCode;
|
||||||
input.MenuCode = code;
|
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,
|
// 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.
|
// alanlar, workflow) hiç üretilmez; menü doğrudan CustomComponent rotasına bağlanır.
|
||||||
var isCustomComponent = input.ComponentKind == WizardComponentKindEnum.Custom;
|
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>
|
/// <summary>
|
||||||
/// Wizard seed dosyalarinin yazilacagi wizard dizinini dondurur; kapsam aktif tenant'i izler
|
/// 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>).
|
/// (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);
|
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)
|
private async Task<HashSet<string>> GetTableColumnNamesAsync(string dataSourceCode, SelectCommandTypeEnum commandType, string selectCommand)
|
||||||
{
|
{
|
||||||
if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure)
|
if (commandType == SelectCommandTypeEnum.Query || commandType == SelectCommandTypeEnum.StoredProcedure)
|
||||||
|
|
|
||||||
|
|
@ -16116,6 +16116,18 @@
|
||||||
"en": "Wizard file deleted successfully.",
|
"en": "Wizard file deleted successfully.",
|
||||||
"tr": "Wizard dosyası başarıyla silindi."
|
"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",
|
"resourceName": "Platform",
|
||||||
"key": "App.Listforms.WizardManager",
|
"key": "App.Listforms.WizardManager",
|
||||||
|
|
@ -16164,6 +16176,30 @@
|
||||||
"en": "Used to generate ListForm Code and Menu Code",
|
"en": "Used to generate ListForm Code and Menu Code",
|
||||||
"tr": "ListForm Kodu ve Menü Kodu oluşturmak için kullanılır"
|
"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",
|
"resourceName": "Platform",
|
||||||
"key": "App.WizardStep1.Optional",
|
"key": "App.WizardStep1.Optional",
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,28 @@ export const deleteWizardFile = (fileName: string) =>
|
||||||
url: `/api/app/list-form-wizard/file`,
|
url: `/api/app/list-form-wizard/file`,
|
||||||
params: { fileName },
|
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 { Container } from '@/components/shared'
|
||||||
import { sqlDataTypeToDbType } from '../edit/options'
|
import { sqlDataTypeToDbType } from '../edit/options'
|
||||||
import { useStoreActions } from '@/store/store'
|
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 { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
|
||||||
import WizardStepComponent from './WizardStepComponent'
|
import WizardStepComponent from './WizardStepComponent'
|
||||||
import WizardStepCustomComponent from './WizardStepCustomComponent'
|
import WizardStepCustomComponent from './WizardStepCustomComponent'
|
||||||
|
|
@ -250,6 +255,13 @@ const deriveListFormCode = (name: string) => {
|
||||||
return sanitized ? `App.Wizard.${sanitized}` : ''
|
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ı
|
* 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.
|
* 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 isEditMode = !!editFileName
|
||||||
const [isLoadingEditData, setIsLoadingEditData] = useState(false)
|
const [isLoadingEditData, setIsLoadingEditData] = useState(false)
|
||||||
const editDataLoadedRef = useRef(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 ──
|
// ── Data Source ──
|
||||||
const [isLoadingDataSource, setIsLoadingDataSource] = useState(false)
|
const [isLoadingDataSource, setIsLoadingDataSource] = useState(false)
|
||||||
|
|
@ -435,13 +449,15 @@ const Wizard = () => {
|
||||||
// Auto-check isTenant / isBranch based on column presence
|
// Auto-check isTenant / isBranch based on column presence
|
||||||
formikRef.current?.setFieldValue('isTenant', hasTenantColumn)
|
formikRef.current?.setFieldValue('isTenant', hasTenantColumn)
|
||||||
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
|
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
|
||||||
// Auto-select first column as key field
|
// Anahtar alan: tabloda Id sutunu varsa her zaman odur; sutun sirasi anahtari
|
||||||
if (cols.length > 0) {
|
// belirlemez. Id yoksa son care olarak ilk sutuna dusulur.
|
||||||
const first = cols[0]
|
const keyColumn =
|
||||||
formikRef.current?.setFieldValue('keyFieldName', first.columnName)
|
cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
|
||||||
|
if (keyColumn) {
|
||||||
|
formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName)
|
||||||
formikRef.current?.setFieldValue(
|
formikRef.current?.setFieldValue(
|
||||||
'keyFieldDbSourceType',
|
'keyFieldDbSourceType',
|
||||||
sqlDataTypeToDbType(first.dataType),
|
sqlDataTypeToDbType(keyColumn.dataType),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -506,6 +522,18 @@ const Wizard = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Menu ──
|
// ── 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 [isLoadingMenu, setIsLoadingMenu] = useState(false)
|
||||||
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
|
const [menuTree, setMenuTree] = useState<MenuTreeNode[]>([])
|
||||||
const [rawMenuItems, setRawMenuItems] = useState<(MenuItem & { id?: string })[]>([])
|
const [rawMenuItems, setRawMenuItems] = useState<(MenuItem & { id?: string })[]>([])
|
||||||
|
|
@ -540,6 +568,7 @@ const Wizard = () => {
|
||||||
getDataSourceList()
|
getDataSourceList()
|
||||||
getMenuList()
|
getMenuList()
|
||||||
getPermissionGroupList()
|
getPermissionGroupList()
|
||||||
|
loadExistingWizardNames()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ── Load edit data after reference lists are ready ──
|
// ── Load edit data after reference lists are ready ──
|
||||||
|
|
@ -559,6 +588,12 @@ const Wizard = () => {
|
||||||
if (!seed?.wizard) return
|
if (!seed?.wizard) return
|
||||||
|
|
||||||
const w = seed.wizard
|
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
|
// Populate formik values
|
||||||
formikRef.current?.setValues({
|
formikRef.current?.setValues({
|
||||||
|
|
@ -725,6 +760,49 @@ const Wizard = () => {
|
||||||
setAutoText('languageTextDescTr')
|
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 applyPermissionGroupFromRoot = (rootCode: string) => {
|
||||||
const rootItem = rawMenuItems.find((i) => i.code === rootCode)
|
const rootItem = rawMenuItems.find((i) => i.code === rootCode)
|
||||||
|
|
||||||
|
|
@ -1100,6 +1178,7 @@ const Wizard = () => {
|
||||||
errors={errors}
|
errors={errors}
|
||||||
touched={touched}
|
touched={touched}
|
||||||
wizardName={values.wizardName}
|
wizardName={values.wizardName}
|
||||||
|
wizardNameError={getWizardNameError(values)}
|
||||||
onWizardNameChange={handleWizardNameChange}
|
onWizardNameChange={handleWizardNameChange}
|
||||||
rawMenuItems={rawMenuItems}
|
rawMenuItems={rawMenuItems}
|
||||||
menuTree={menuTree}
|
menuTree={menuTree}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,15 @@ import {
|
||||||
FaListUl,
|
FaListUl,
|
||||||
FaLink,
|
FaLink,
|
||||||
FaExternalLinkAlt,
|
FaExternalLinkAlt,
|
||||||
|
FaFileExport,
|
||||||
} from 'react-icons/fa'
|
} from 'react-icons/fa'
|
||||||
import DbMigrateButton from '@/components/shared/DbMigrateButton'
|
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 { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { useStoreState } from '@/store/store'
|
import { useStoreState } from '@/store/store'
|
||||||
|
|
@ -41,6 +47,7 @@ const WizardFileManager = () => {
|
||||||
const [files, setFiles] = useState<WizardFileInfoDto[]>([])
|
const [files, setFiles] = useState<WizardFileInfoDto[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [deletingFile, setDeletingFile] = useState<string | null>(null)
|
const [deletingFile, setDeletingFile] = useState<string | null>(null)
|
||||||
|
const [exportingFile, setExportingFile] = useState<string | null>(null)
|
||||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
|
@ -75,6 +82,28 @@ const WizardFileManager = () => {
|
||||||
loadFiles()
|
loadFiles()
|
||||||
}, [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 () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!confirm) return
|
if (!confirm) return
|
||||||
setDeletingFile(confirm.fileName)
|
setDeletingFile(confirm.fileName)
|
||||||
|
|
@ -251,6 +280,17 @@ const WizardFileManager = () => {
|
||||||
<FaExternalLinkAlt />
|
<FaExternalLinkAlt />
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,8 @@ export interface WizardStep1Props {
|
||||||
errors: FormikErrors<ListFormWizardDto>
|
errors: FormikErrors<ListFormWizardDto>
|
||||||
touched: FormikTouched<ListFormWizardDto>
|
touched: FormikTouched<ListFormWizardDto>
|
||||||
wizardName: string
|
wizardName: string
|
||||||
|
/** Ad kuralı ya da çakışma hatası; dolu ise adım ilerletilemez. */
|
||||||
|
wizardNameError: string | null
|
||||||
onWizardNameChange: (name: string) => void
|
onWizardNameChange: (name: string) => void
|
||||||
rawMenuItems: (MenuItem & { id?: string })[]
|
rawMenuItems: (MenuItem & { id?: string })[]
|
||||||
menuTree: MenuTreeNode[]
|
menuTree: MenuTreeNode[]
|
||||||
|
|
@ -386,6 +388,7 @@ const WizardStep1 = ({
|
||||||
errors,
|
errors,
|
||||||
touched,
|
touched,
|
||||||
wizardName,
|
wizardName,
|
||||||
|
wizardNameError,
|
||||||
onWizardNameChange,
|
onWizardNameChange,
|
||||||
rawMenuItems,
|
rawMenuItems,
|
||||||
menuTree,
|
menuTree,
|
||||||
|
|
@ -449,7 +452,8 @@ const WizardStep1 = ({
|
||||||
!values.languageTextMenuTr && translate('::App.WizardStep4.MenuTr'),
|
!values.languageTextMenuTr && translate('::App.WizardStep4.MenuTr'),
|
||||||
!values.menuIcon && translate('::App.WizardStep4.MenuIcon'),
|
!values.menuIcon && translate('::App.WizardStep4.MenuIcon'),
|
||||||
].filter(Boolean) as string[]
|
].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 (
|
return (
|
||||||
<div className="pb-20">
|
<div className="pb-20">
|
||||||
|
|
@ -457,6 +461,8 @@ const WizardStep1 = ({
|
||||||
<FormItem
|
<FormItem
|
||||||
label={translate('::App.WizardStep1.WizardName')}
|
label={translate('::App.WizardStep1.WizardName')}
|
||||||
asterisk={true}
|
asterisk={true}
|
||||||
|
invalid={!!wizardNameError}
|
||||||
|
errorMessage={wizardNameError ?? undefined}
|
||||||
extra={
|
extra={
|
||||||
<span className="text-xs ml-2 text-gray-400">
|
<span className="text-xs ml-2 text-gray-400">
|
||||||
{translate('::App.WizardStep1.WizardNameHint')}
|
{translate('::App.WizardStep1.WizardNameHint')}
|
||||||
|
|
@ -469,6 +475,7 @@ const WizardStep1 = ({
|
||||||
placeholder="Routes, Products, Orders"
|
placeholder="Routes, Products, Orders"
|
||||||
value={wizardName}
|
value={wizardName}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
invalid={!!wizardNameError}
|
||||||
onChange={(e) => onWizardNameChange(e.target.value.replace(/\s/g, ''))}
|
onChange={(e) => onWizardNameChange(e.target.value.replace(/\s/g, ''))}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue