WizardManager için Import özelliği eklendi
This commit is contained in:
parent
010594ac27
commit
eb77285e59
32 changed files with 2065 additions and 190 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@ logs/
|
|||
# ve API konteynerine mount edilir. Takip edilirse deploy'daki `git pull` yerel
|
||||
# değişiklikle çakışır ve dosya eskir.
|
||||
configs/change-logs/change-logs.json
|
||||
# Wizard import staging klasoru (gecici; oturum bitince silinir)
|
||||
configs/seeds/.imports/
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Volo.Abp.Content;
|
||||
|
||||
|
|
@ -13,5 +13,19 @@ public interface IListFormWizardAppService
|
|||
|
||||
/// <summary>Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur.</summary>
|
||||
Task<IRemoteStreamContent> ExportFile(string fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export zip'ini staging klasorune acar ve her dosya icin hedefteki karsiligiyla
|
||||
/// karsilastirma sonucunu dondurur. Bu adim hicbir hedef dosyaya dokunmaz.
|
||||
/// </summary>
|
||||
Task<WizardImportAnalysisDto> AnalyzeImport(IRemoteStreamContent file);
|
||||
|
||||
/// <summary>Analizde donen tek bir dosyayi hedefe yazar; onceki icerik rollback icin yedeklenir.</summary>
|
||||
Task<WizardImportApplyResultDto> ApplyImport(WizardImportApplyInput input);
|
||||
|
||||
/// <summary>Import oturumunu basariyla kapatir ve staging/yedek klasorunu siler.</summary>
|
||||
Task CompleteImport(string importId);
|
||||
|
||||
/// <summary>O ana kadar yazilan dosyalari geri alir ve staging klasorunu siler.</summary>
|
||||
Task RollbackImport(string importId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using Sozsoft.Platform.Enums;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms;
|
||||
|
||||
|
|
@ -13,6 +14,14 @@ public class ListFormWizardDto
|
|||
/// </summary>
|
||||
public WizardComponentKindEnum ComponentKind { get; set; } = WizardComponentKindEnum.List;
|
||||
|
||||
/// <summary>
|
||||
/// Duzenleme modunda, yerine gecilecek seed dosyasinin adi. Doluysa istek bir guncellemedir:
|
||||
/// sunucu once bu dosyayi ve urettigi kayitlari siler, sonra yenisini uretir.
|
||||
/// Yalnizca istek girdisidir; seed dosyasina yazilmaz.
|
||||
/// </summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string EditFileName { get; set; }
|
||||
|
||||
public string WizardName { get; set; }
|
||||
public string ListFormCode { get; set; }
|
||||
public string MenuCode { get; set; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
using System.Collections.Generic;
|
||||
using Sozsoft.Platform.Enums;
|
||||
|
||||
namespace Sozsoft.Platform.ListForms;
|
||||
|
||||
/// <summary>
|
||||
/// Import edilen zip icindeki tek bir seed dosyasinin analiz sonucu.
|
||||
/// Yol her zaman kapsam klasorune (host / tenants/{tenantId}) goreli tutulur.
|
||||
/// </summary>
|
||||
public class WizardImportEntryDto
|
||||
{
|
||||
/// <summary>Kapsam klasorune goreli yol, ornegin <c>custom/Orders.json</c>.</summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Yolun ilk segmenti; listede gruplama icin kullanilir (wizard, crud, custom, sql, postgres).</summary>
|
||||
public string Folder { get; set; }
|
||||
|
||||
public string FileName { get; set; }
|
||||
|
||||
public WizardImportEntryStatusEnum Status { get; set; }
|
||||
|
||||
public long IncomingSize { get; set; }
|
||||
|
||||
public long ExistingSize { get; set; }
|
||||
|
||||
/// <summary>Zip'ten gelen icerik; yalnizca Conflict durumunda doldurulur.</summary>
|
||||
public string IncomingContent { get; set; }
|
||||
|
||||
/// <summary>Hedefteki mevcut icerik; yalnizca Conflict durumunda doldurulur.</summary>
|
||||
public string ExistingContent { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Zip analizinin tamami; uygulama adimi bu <see cref="ImportId"/> ile yurutulur.</summary>
|
||||
public class WizardImportAnalysisDto
|
||||
{
|
||||
/// <summary>Staging klasorunu ve rollback kaydini tanimlayan oturum kimligi.</summary>
|
||||
public string ImportId { get; set; }
|
||||
|
||||
/// <summary>Dosyalarin yazilacagi kapsam klasoru (<c>host</c> ya da <c>tenants/{tenantId}</c>).</summary>
|
||||
public string ScopeFolder { get; set; }
|
||||
|
||||
public List<WizardImportEntryDto> Entries { get; set; } = [];
|
||||
|
||||
/// <summary>Guvenlik/format nedeniyle atlanan zip girdileri icin aciklamalar.</summary>
|
||||
public List<string> Warnings { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>Tek bir dosyanin hedefe yazilmasi istegi.</summary>
|
||||
public class WizardImportApplyInput
|
||||
{
|
||||
public string ImportId { get; set; }
|
||||
|
||||
/// <summary>Analizde donen kapsam-goreli yol.</summary>
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Kullanicinin diff ekraninda birlestirdigi icerik. Bos birakilirsa zip'ten gelen
|
||||
/// icerik oldugu gibi yazilir.
|
||||
/// </summary>
|
||||
public string Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Tek dosya yazma sonucu; ilerleme gostergesinde satir olarak gosterilir.</summary>
|
||||
public class WizardImportApplyResultDto
|
||||
{
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Dosya hedefte zaten varken uzerine yazildiysa true.</summary>
|
||||
public bool Overwritten { get; set; }
|
||||
|
||||
public long Size { get; set; }
|
||||
}
|
||||
|
|
@ -11,12 +11,20 @@ using Microsoft.Extensions.Logging;
|
|||
using Sozsoft.Platform.Data.Seeds;
|
||||
using Sozsoft.Platform.DeveloperKit;
|
||||
using Sozsoft.Platform.Entities;
|
||||
using static Sozsoft.Platform.PlatformConsts;
|
||||
using Volo.Abp.Application.Dtos;
|
||||
using Volo.Abp.Application.Services;
|
||||
using Volo.Abp.Domain.Repositories;
|
||||
|
||||
namespace Platform.Api.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Custom component yonetimi. Sinif seviyesindeki <see cref="AuthorizeAttribute"/> yalnizca
|
||||
/// kimlik dogrulamasi ister; ekrani gorme ve CRUD haklari asagida ABP policy adlarina baglanir.
|
||||
/// <c>App.DeveloperKit.Components</c> okuma, <c>.Create</c>/<c>.Update</c>/<c>.Delete</c> aksiyon
|
||||
/// yetkileridir. <see cref="GetActiveComponentsAsync"/> calisma zamani okuma yoludur ve yalnizca
|
||||
/// oturum acmis olmayi ister.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class CustomComponentAppService : CrudAppService<
|
||||
CustomComponent,
|
||||
|
|
@ -47,6 +55,12 @@ public class CustomComponentAppService : CrudAppService<
|
|||
{
|
||||
_crudEndpointRepository = crudEndpointRepository;
|
||||
_configuration = configuration;
|
||||
|
||||
GetPolicyName = AppCodes.DeveloperKits.Components.Component;
|
||||
GetListPolicyName = AppCodes.DeveloperKits.Components.Component;
|
||||
CreatePolicyName = AppCodes.DeveloperKits.Components.Create;
|
||||
UpdatePolicyName = AppCodes.DeveloperKits.Components.Update;
|
||||
DeletePolicyName = AppCodes.DeveloperKits.Components.Delete;
|
||||
}
|
||||
|
||||
public override async Task<CustomComponentDto> CreateAsync(CreateUpdateCustomComponentDto input)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -31,6 +32,10 @@ namespace Sozsoft.Platform.ListForms;
|
|||
/// <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.
|
||||
/// <para>
|
||||
/// Sinif seviyesindeki yetki ekrani gorme (okuma) hakkidir; aksiyonlar ayrica kendi alt
|
||||
/// yetkilerini ister: <c>.Create</c>, <c>.Update</c>, <c>.Delete</c>, <c>.Export</c>, <c>.Import</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Authorize(AppCodes.Listforms.Wizard)]
|
||||
public class ListFormWizardAppService(
|
||||
|
|
@ -76,6 +81,19 @@ public class ListFormWizardAppService(
|
|||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
// Duzenleme, eski dosyanin (ve urettigi kayitlarin) silinip yeniden uretilmesidir.
|
||||
// Ayrimi istemci degil sunucu yapar; boylece yetki dogru politikaya baglanir ve
|
||||
// silme ile yeniden uretme arasinda yarim kalmis bir durum olusmaz.
|
||||
var editFileName = input.EditFileName?.Trim();
|
||||
var isEdit = !string.IsNullOrWhiteSpace(editFileName);
|
||||
|
||||
await AuthorizationService.CheckAsync(isEdit
|
||||
? AppCodes.Listforms.Wizards.Update
|
||||
: AppCodes.Listforms.Wizards.Create);
|
||||
|
||||
if (isEdit)
|
||||
await DeleteFileInternalAsync(editFileName!);
|
||||
|
||||
var wizardName = input.WizardName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(wizardName))
|
||||
throw new UserFriendlyException("Wizard adı zorunludur.");
|
||||
|
|
@ -583,6 +601,9 @@ public class ListFormWizardAppService(
|
|||
var outputPath = ResolveWizardSeedOutputPath();
|
||||
Directory.CreateDirectory(outputPath);
|
||||
|
||||
// Duzenleme bilgisi yalnizca istege aittir; seed dosyasinda yeri yoktur.
|
||||
input.EditFileName = null;
|
||||
|
||||
var seedData = new WizardSeedFileDto
|
||||
{
|
||||
Wizard = input,
|
||||
|
|
@ -694,7 +715,17 @@ public class ListFormWizardAppService(
|
|||
}
|
||||
|
||||
[UnitOfWork]
|
||||
public async Task DeleteFile(string fileName)
|
||||
[Authorize(AppCodes.Listforms.Wizards.Delete)]
|
||||
public Task DeleteFile(string fileName)
|
||||
{
|
||||
return DeleteFileInternalAsync(fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed dosyasini ve wizard'in urettigi kayitlari siler. Yetki kontrolu cagiran uca aittir:
|
||||
/// silme yolunda <c>Delete</c>, duzenleme yolunda <c>Update</c> politikasi kullanilir.
|
||||
/// </summary>
|
||||
private async Task DeleteFileInternalAsync(string fileName)
|
||||
{
|
||||
var filePath = ResolveWizardSeedFilePath(fileName);
|
||||
|
||||
|
|
@ -906,6 +937,7 @@ public class ListFormWizardAppService(
|
|||
/// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Authorize(AppCodes.Listforms.Wizards.Export)]
|
||||
[HttpGet("api/app/list-form-wizard/export")]
|
||||
public async Task<IRemoteStreamContent> ExportFile(string fileName)
|
||||
{
|
||||
|
|
@ -951,6 +983,404 @@ public class ListFormWizardAppService(
|
|||
return new RemoteStreamContent(memory, $"{safeName}.zip", "application/zip");
|
||||
}
|
||||
|
||||
// ── Import ─────────────────────────────────────────────────────────────────────
|
||||
// Import iki adimlidir: once zip staging klasorune acilir ve her dosya hedefteki
|
||||
// karsiligiyla karsilastirilir (AnalyzeImport), kullanici catismalari cozdukten sonra
|
||||
// dosyalar tek tek yazilir (ApplyImport). Yazilan her dosyanin onceki hali staging
|
||||
// altindaki backup klasorune alindigi icin hata durumunda RollbackImport ile tum
|
||||
// toplu islem geri alinabilir.
|
||||
|
||||
/// <summary>Staging oturumlarinin tutuldugu klasor; seed kokunun altinda nokta ile baslar, seeder taramalarina girmez.</summary>
|
||||
private const string ImportSessionsFolder = ".imports";
|
||||
private const string ImportStagedFolder = "staged";
|
||||
private const string ImportBackupFolder = "backup";
|
||||
private const string ImportAppliedLogFile = "applied.log";
|
||||
|
||||
private const long ImportMaxEntryBytes = 5 * 1024 * 1024;
|
||||
private const long ImportMaxArchiveBytes = 50 * 1024 * 1024;
|
||||
private const int ImportMaxEntryCount = 500;
|
||||
|
||||
/// <summary>Kapsam klasorunde import edilmesine izin verilen kok klasorler.</summary>
|
||||
private static readonly string[] ImportAllowedRootFolders =
|
||||
[
|
||||
SeedPathResolver.WizardFolder,
|
||||
SeedPathResolver.CrudFolder,
|
||||
SeedPathResolver.CustomFolder,
|
||||
SeedPathResolver.SqlFolder,
|
||||
SeedPathResolver.PostgresFolder,
|
||||
];
|
||||
|
||||
/// <summary>SQL saglayici klasorlerinin altinda izin verilen script klasorleri.</summary>
|
||||
private static readonly string[] ImportAllowedScriptFolders =
|
||||
[
|
||||
SeedPathResolver.ObjectFolder,
|
||||
SeedPathResolver.ExecuteFolder,
|
||||
];
|
||||
|
||||
private static readonly UTF8Encoding ImportEncoding = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
[Authorize(AppCodes.Listforms.Wizards.Import)]
|
||||
[HttpPost("api/app/list-form-wizard/analyze-import")]
|
||||
public async Task<WizardImportAnalysisDto> AnalyzeImport([FromForm] IRemoteStreamContent file)
|
||||
{
|
||||
if (file == null)
|
||||
throw new UserFriendlyException("Dosya bulunamadı.");
|
||||
|
||||
CleanupStaleImportSessions();
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await using (var upload = file.GetStream())
|
||||
await upload.CopyToAsync(buffer);
|
||||
|
||||
if (buffer.Length == 0)
|
||||
throw new UserFriendlyException("Dosya boş.");
|
||||
|
||||
if (buffer.Length > ImportMaxArchiveBytes)
|
||||
throw new UserFriendlyException("Zip dosyası çok büyük.");
|
||||
|
||||
buffer.Position = 0;
|
||||
|
||||
ZipArchive archive;
|
||||
try
|
||||
{
|
||||
archive = new ZipArchive(buffer, ZipArchiveMode.Read);
|
||||
}
|
||||
catch (InvalidDataException)
|
||||
{
|
||||
throw new UserFriendlyException("Geçerli bir zip dosyası değil.");
|
||||
}
|
||||
|
||||
var importId = GuidGenerator.Create().ToString("N");
|
||||
var sessionPath = GetImportSessionPath(importId);
|
||||
var stagedRoot = Path.Combine(sessionPath, ImportStagedFolder);
|
||||
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
|
||||
|
||||
var result = new WizardImportAnalysisDto
|
||||
{
|
||||
ImportId = importId,
|
||||
ScopeFolder = SeedPathResolver.GetScopeFolderName(CurrentTenant.Id),
|
||||
};
|
||||
|
||||
using (archive)
|
||||
{
|
||||
Directory.CreateDirectory(stagedRoot);
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var zipEntry in archive.Entries)
|
||||
{
|
||||
// Klasor girdilerinin adi bostur; icerik tasimazlar.
|
||||
if (string.IsNullOrEmpty(zipEntry.Name))
|
||||
continue;
|
||||
|
||||
var relativePath = NormalizeImportEntryPath(zipEntry.FullName);
|
||||
if (relativePath == null)
|
||||
{
|
||||
result.Warnings.Add($"{zipEntry.FullName}: desteklenmeyen klasör/dosya, atlandı.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (zipEntry.Length > ImportMaxEntryBytes)
|
||||
{
|
||||
result.Warnings.Add($"{relativePath}: dosya çok büyük, atlandı.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seen.Add(relativePath))
|
||||
{
|
||||
result.Warnings.Add($"{relativePath}: zip içinde birden fazla kez var, ilki kullanıldı.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seen.Count > ImportMaxEntryCount)
|
||||
throw new UserFriendlyException("Zip içindeki dosya sayısı çok fazla.");
|
||||
|
||||
byte[] incoming;
|
||||
await using (var source = zipEntry.Open())
|
||||
using (var entryBuffer = new MemoryStream())
|
||||
{
|
||||
await source.CopyToAsync(entryBuffer);
|
||||
incoming = entryBuffer.ToArray();
|
||||
}
|
||||
|
||||
var stagedPath = Path.Combine(stagedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
|
||||
await File.WriteAllBytesAsync(stagedPath, incoming);
|
||||
|
||||
var targetPath = Path.Combine(scopeRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
||||
var segments = relativePath.Split('/');
|
||||
|
||||
var entry = new WizardImportEntryDto
|
||||
{
|
||||
Path = relativePath,
|
||||
Folder = segments[0],
|
||||
FileName = segments[^1],
|
||||
IncomingSize = incoming.LongLength,
|
||||
};
|
||||
|
||||
if (!File.Exists(targetPath))
|
||||
{
|
||||
entry.Status = WizardImportEntryStatusEnum.New;
|
||||
}
|
||||
else
|
||||
{
|
||||
var existing = await File.ReadAllBytesAsync(targetPath);
|
||||
entry.ExistingSize = existing.LongLength;
|
||||
|
||||
var incomingText = NormalizeImportText(incoming);
|
||||
var existingText = NormalizeImportText(existing);
|
||||
|
||||
if (string.Equals(incomingText, existingText, StringComparison.Ordinal))
|
||||
{
|
||||
entry.Status = WizardImportEntryStatusEnum.Identical;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Karsilastirma ekrani yalnizca farkli dosyalar icin acildigindan
|
||||
// icerikler sadece bu durumda tasinir.
|
||||
entry.Status = WizardImportEntryStatusEnum.Conflict;
|
||||
entry.IncomingContent = incomingText;
|
||||
entry.ExistingContent = existingText;
|
||||
}
|
||||
}
|
||||
|
||||
result.Entries.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Entries.Count == 0)
|
||||
{
|
||||
DeleteImportSession(sessionPath);
|
||||
throw new UserFriendlyException("Zip içinde import edilebilecek seed dosyası bulunamadı.");
|
||||
}
|
||||
|
||||
result.Entries = [.. result.Entries.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase)];
|
||||
return result;
|
||||
}
|
||||
|
||||
[Authorize(AppCodes.Listforms.Wizards.Import)]
|
||||
[HttpPost("api/app/list-form-wizard/apply-import")]
|
||||
public async Task<WizardImportApplyResultDto> ApplyImport(WizardImportApplyInput input)
|
||||
{
|
||||
Check.NotNull(input, nameof(input));
|
||||
|
||||
var sessionPath = GetExistingImportSessionPath(input.ImportId);
|
||||
var relativePath = NormalizeImportEntryPath(input.Path)
|
||||
?? throw new UserFriendlyException("Geçersiz dosya yolu.");
|
||||
|
||||
var systemPath = relativePath.Replace('/', Path.DirectorySeparatorChar);
|
||||
var stagedPath = Path.Combine(sessionPath, ImportStagedFolder, systemPath);
|
||||
if (!File.Exists(stagedPath))
|
||||
throw new UserFriendlyException($"Import oturumunda dosya bulunamadı: {relativePath}");
|
||||
|
||||
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
|
||||
var targetPath = Path.GetFullPath(Path.Combine(scopeRoot, systemPath));
|
||||
if (!targetPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UserFriendlyException("Geçersiz dosya yolu.");
|
||||
|
||||
// Icerik gonderildiyse kullanici diff ekraninda birlestirmis demektir; gonderilmediyse
|
||||
// zip'ten gelen dosya oldugu gibi yazilir.
|
||||
var content = input.Content == null
|
||||
? await File.ReadAllBytesAsync(stagedPath)
|
||||
: ImportEncoding.GetBytes(input.Content);
|
||||
|
||||
var overwritten = File.Exists(targetPath);
|
||||
if (overwritten)
|
||||
{
|
||||
var backupPath = Path.Combine(sessionPath, ImportBackupFolder, systemPath);
|
||||
// Ayni dosya ikinci kez yazilirsa ilk yedek korunur; rollback en bastaki hale doner.
|
||||
if (!File.Exists(backupPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
|
||||
File.Copy(targetPath, backupPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Kayit yazma isleminden once atilir; yazma sirasinda surec olse bile rollback dosyayi bulur.
|
||||
await File.AppendAllTextAsync(
|
||||
Path.Combine(sessionPath, ImportAppliedLogFile),
|
||||
$"{(overwritten ? "E" : "N")}|{relativePath}{Environment.NewLine}");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
await File.WriteAllBytesAsync(targetPath, content);
|
||||
|
||||
logger.LogInformation("Wizard import applied: {RelativePath} (overwritten: {Overwritten})", relativePath, overwritten);
|
||||
|
||||
return new WizardImportApplyResultDto
|
||||
{
|
||||
Path = relativePath,
|
||||
Overwritten = overwritten,
|
||||
Size = content.LongLength,
|
||||
};
|
||||
}
|
||||
|
||||
[Authorize(AppCodes.Listforms.Wizards.Import)]
|
||||
[HttpPost("api/app/list-form-wizard/complete-import")]
|
||||
public Task CompleteImport(string importId)
|
||||
{
|
||||
DeleteImportSession(GetExistingImportSessionPath(importId));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Authorize(AppCodes.Listforms.Wizards.Import)]
|
||||
[HttpPost("api/app/list-form-wizard/rollback-import")]
|
||||
public async Task RollbackImport(string importId)
|
||||
{
|
||||
var sessionPath = GetExistingImportSessionPath(importId);
|
||||
var logPath = Path.Combine(sessionPath, ImportAppliedLogFile);
|
||||
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
|
||||
|
||||
if (File.Exists(logPath))
|
||||
{
|
||||
var lines = await File.ReadAllLinesAsync(logPath);
|
||||
|
||||
// Yazma sirasinin tersine gidilir; ayni dosya birden fazla kez yazildiysa
|
||||
// en eski yedek (ilk kayit) en sonda uygulanir.
|
||||
foreach (var line in lines.Reverse())
|
||||
{
|
||||
var parts = line.Split('|', 2);
|
||||
if (parts.Length != 2)
|
||||
continue;
|
||||
|
||||
var systemPath = parts[1].Replace('/', Path.DirectorySeparatorChar);
|
||||
var targetPath = Path.GetFullPath(Path.Combine(scopeRoot, systemPath));
|
||||
if (!targetPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (parts[0] == "E")
|
||||
{
|
||||
var backupPath = Path.Combine(sessionPath, ImportBackupFolder, systemPath);
|
||||
if (File.Exists(backupPath))
|
||||
File.Copy(backupPath, targetPath, overwrite: true);
|
||||
}
|
||||
else if (File.Exists(targetPath))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Wizard import rollback failed for {RelativePath}", parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DeleteImportSession(sessionPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zip girdisini kapsam klasorune goreli yola cevirir. Zip, kapsam klasorunun icinden
|
||||
/// (<c>wizard/x.json</c>) ya da depo kokunden (<c>configs/seeds/host/wizard/x.json</c>)
|
||||
/// alinmis olabilir; her iki durumda da izin verilen ilk kok klasorden itibaren okunur.
|
||||
/// Kural disi bir yol icin null doner.
|
||||
/// </summary>
|
||||
private static string? NormalizeImportEntryPath(string? entryPath)
|
||||
{
|
||||
var normalized = entryPath?.Replace('\\', '/').Trim('/');
|
||||
if (string.IsNullOrWhiteSpace(normalized) || normalized.Contains("..", StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var start = Array.FindIndex(segments, s => ImportAllowedRootFolders.Contains(s, StringComparer.OrdinalIgnoreCase));
|
||||
if (start < 0)
|
||||
return null;
|
||||
|
||||
segments = segments[start..];
|
||||
|
||||
var isScriptFolder = segments[0].Equals(SeedPathResolver.SqlFolder, StringComparison.OrdinalIgnoreCase)
|
||||
|| segments[0].Equals(SeedPathResolver.PostgresFolder, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// sql/postgres altinda object|execute klasoru zorunludur; digerlerinde alt klasor yoktur.
|
||||
if (isScriptFolder)
|
||||
{
|
||||
if (segments.Length != 3 || !ImportAllowedScriptFolders.Contains(segments[1], StringComparer.OrdinalIgnoreCase))
|
||||
return null;
|
||||
}
|
||||
else if (segments.Length != 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileName = segments[^1];
|
||||
var extension = Path.GetExtension(fileName);
|
||||
var expectedExtension = isScriptFolder ? ".sql" : ".json";
|
||||
|
||||
if (!extension.Equals(expectedExtension, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
if (fileName.Length == extension.Length || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
|
||||
return null;
|
||||
|
||||
return string.Join('/', segments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Karsilastirmayi satir sonu ve BOM farklarindan bagimsiz hale getirir; ayni icerigin
|
||||
/// farkli platformlarda uretilmis hali catisma olarak gorunmez.
|
||||
/// </summary>
|
||||
private static string NormalizeImportText(byte[] bytes)
|
||||
{
|
||||
return Encoding.UTF8.GetString(bytes)
|
||||
.TrimStart('')
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace("\r", "\n")
|
||||
.TrimEnd();
|
||||
}
|
||||
|
||||
private string GetImportSessionPath(string importId)
|
||||
{
|
||||
return Path.Combine(SeedPathResolver.GetPath(_configuration, ImportSessionsFolder), importId);
|
||||
}
|
||||
|
||||
private string GetExistingImportSessionPath(string importId)
|
||||
{
|
||||
// Oturum kimligi klasor adina donustugu icin yalnizca uretilen format kabul edilir.
|
||||
if (string.IsNullOrWhiteSpace(importId) || !Guid.TryParseExact(importId, "N", out _))
|
||||
throw new UserFriendlyException("Geçersiz import oturumu.");
|
||||
|
||||
var sessionPath = GetImportSessionPath(importId);
|
||||
if (!Directory.Exists(sessionPath))
|
||||
throw new UserFriendlyException("Import oturumu bulunamadı veya süresi dolmuş.");
|
||||
|
||||
return sessionPath;
|
||||
}
|
||||
|
||||
private void DeleteImportSession(string sessionPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(sessionPath))
|
||||
Directory.Delete(sessionPath, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Wizard import session could not be deleted: {SessionPath}", sessionPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Yarim kalan (tarayici kapatilmis) oturumlarin klasorlerini temizler.</summary>
|
||||
private void CleanupStaleImportSessions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = SeedPathResolver.GetPath(_configuration, ImportSessionsFolder);
|
||||
if (!Directory.Exists(root))
|
||||
return;
|
||||
|
||||
foreach (var directory in Directory.GetDirectories(root))
|
||||
{
|
||||
if (Directory.GetLastWriteTimeUtc(directory) < DateTime.UtcNow.AddDays(-1))
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Stale wizard import sessions could not be cleaned up.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
{
|
||||
"Languages": [
|
||||
{
|
||||
"cultureName": "ar",
|
||||
|
|
@ -26033,6 +26033,168 @@
|
|||
"key": "App.PublicDemo.MailCompanyInfo",
|
||||
"en": "Company Information",
|
||||
"tr": "Firma Bilgileri"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportButton",
|
||||
"en": "Import",
|
||||
"tr": "İçe Aktar"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportTitle",
|
||||
"en": "Import wizard package (zip)",
|
||||
"tr": "Wizard paketini içe aktar (zip)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportAnalyzing",
|
||||
"en": "Analyzing package...",
|
||||
"tr": "Paket inceleniyor..."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportNew",
|
||||
"en": "new",
|
||||
"tr": "yeni"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportIdentical",
|
||||
"en": "no change",
|
||||
"tr": "değişiklik yok"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportConflict",
|
||||
"en": "conflict",
|
||||
"tr": "çakışma"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportResolvedApply",
|
||||
"en": "will be imported",
|
||||
"tr": "aktarılacak"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportResolvedSkip",
|
||||
"en": "skipped",
|
||||
"tr": "atlanacak"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportInclude",
|
||||
"en": "Include in import",
|
||||
"tr": "İçe aktarmaya dahil et"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportShowIdentical",
|
||||
"en": "identical files, show",
|
||||
"tr": "aynı dosya var, göster"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportHideIdentical",
|
||||
"en": "Hide identical files",
|
||||
"tr": "Aynı dosyaları gizle"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportExistingSide",
|
||||
"en": "Current file",
|
||||
"tr": "Mevcut dosya"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportIncomingSide",
|
||||
"en": "Incoming file (editable)",
|
||||
"tr": "Gelen dosya (düzenlenebilir)"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportKeepExisting",
|
||||
"en": "Keep current",
|
||||
"tr": "Mevcut kalsın"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportUseThis",
|
||||
"en": "Import this content",
|
||||
"tr": "Bu içeriği aktar"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportResetIncoming",
|
||||
"en": "Reset",
|
||||
"tr": "Sıfırla"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportUnresolved",
|
||||
"en": "conflicts must be resolved before import.",
|
||||
"tr": "çakışma import öncesinde çözülmeli."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportReady",
|
||||
"en": "All files are reviewed, import can be started.",
|
||||
"tr": "Tüm dosyalar gözden geçirildi, import başlatılabilir."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportWarnings",
|
||||
"en": "Skipped entries",
|
||||
"tr": "Atlanan kayıtlar"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportStart",
|
||||
"en": "Start import",
|
||||
"tr": "İçe aktarmayı başlat"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportWriting",
|
||||
"en": "Writing",
|
||||
"tr": "Yazılıyor"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportCreated",
|
||||
"en": "created",
|
||||
"tr": "oluşturuldu"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportOverwritten",
|
||||
"en": "overwritten",
|
||||
"tr": "üzerine yazıldı"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportSuccess",
|
||||
"en": "Import completed successfully.",
|
||||
"tr": "İçe aktarma başarıyla tamamlandı."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportRunMigrateHint",
|
||||
"en": "Run DB Migrate to load the imported seed files into the database.",
|
||||
"tr": "İçe aktarılan seed dosyalarının veritabanına işlenmesi için DB Migrate çalıştırın."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportRolledBack",
|
||||
"en": "Import failed, all written files were restored.",
|
||||
"tr": "İçe aktarma başarısız oldu, yazılan tüm dosyalar geri alındı."
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.ListForm.WizardImportRollbackFailed",
|
||||
"en": "Rollback could not be completed",
|
||||
"tr": "Geri alma tamamlanamadı"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2480,6 +2480,33 @@
|
|||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.DeveloperKit.Components.Create",
|
||||
"ParentName": "App.DeveloperKit.Components",
|
||||
"DisplayName": "App.Platform.Create",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.DeveloperKit.Components.Update",
|
||||
"ParentName": "App.DeveloperKit.Components",
|
||||
"DisplayName": "App.Platform.Update",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.DeveloperKit.Components.Delete",
|
||||
"ParentName": "App.DeveloperKit.Components",
|
||||
"DisplayName": "App.Platform.Delete",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.DeveloperKit.DynamicServices",
|
||||
|
|
@ -2561,6 +2588,51 @@
|
|||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.Listforms.Wizard.Create",
|
||||
"ParentName": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Platform.Create",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.Listforms.Wizard.Update",
|
||||
"ParentName": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Platform.Update",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.Listforms.Wizard.Delete",
|
||||
"ParentName": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Platform.Delete",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.Listforms.Wizard.Export",
|
||||
"ParentName": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Platform.Export",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Saas",
|
||||
"Name": "App.Listforms.Wizard.Import",
|
||||
"ParentName": "App.Listforms.Wizard",
|
||||
"DisplayName": "App.Platform.Import",
|
||||
"IsEnabled": true,
|
||||
"MultiTenancySide": 2,
|
||||
"MenuGroup": "Erp|Kurs"
|
||||
},
|
||||
{
|
||||
"GroupName": "App.Administration",
|
||||
"Name": "App.Setting",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
namespace Sozsoft.Platform.Enums;
|
||||
|
||||
/// <summary>Import edilen zip girdisinin hedef kapsam klasorundeki dosyayla karsilastirma sonucu.</summary>
|
||||
public enum WizardImportEntryStatusEnum
|
||||
{
|
||||
/// <summary>Hedefte ayni yolda dosya yok; dogrudan kopyalanir.</summary>
|
||||
New = 0,
|
||||
|
||||
/// <summary>Hedefteki dosya ile icerik ayni; kullaniciya sorulmaz, islem yapilmaz.</summary>
|
||||
Identical = 1,
|
||||
|
||||
/// <summary>Hedefte farkli icerikli bir dosya var; kullanici karsilastirip karar vermelidir.</summary>
|
||||
Conflict = 2,
|
||||
}
|
||||
|
|
@ -442,6 +442,19 @@ public static class PlatformConsts
|
|||
public const string Listform = Default + ".Listform";
|
||||
public const string ListformField = Default + ".ListformField";
|
||||
public const string Chart = Default + ".Chart";
|
||||
|
||||
/// <summary>
|
||||
/// Wizard dosya yoneticisinin aksiyon yetkileri. Hepsi <see cref="Wizard"/> altindadir;
|
||||
/// <see cref="Wizard"/> ekrani gorme (okuma) yetkisidir.
|
||||
/// </summary>
|
||||
public static class Wizards
|
||||
{
|
||||
public const string Create = Wizard + ".Create";
|
||||
public const string Update = Wizard + ".Update";
|
||||
public const string Delete = Wizard + ".Delete";
|
||||
public const string Export = Wizard + ".Export";
|
||||
public const string Import = Wizard + ".Import";
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notifications
|
||||
|
|
@ -486,6 +499,19 @@ public static class PlatformConsts
|
|||
public const string Put = CustomEndpoints + ".Put";
|
||||
public const string Remove = CustomEndpoints + ".Remove";
|
||||
|
||||
/// <summary>
|
||||
/// Custom component yoneticisinin yetkileri. <see cref="Component"/> ekrani gorme
|
||||
/// (okuma) yetkisidir; aksiyonlar onun altindaki alt yetkileri ister.
|
||||
/// </summary>
|
||||
public static class Components
|
||||
{
|
||||
public const string Component = Default + ".Components";
|
||||
|
||||
public const string Create = Component + ".Create";
|
||||
public const string Update = Component + ".Update";
|
||||
public const string Delete = Component + ".Delete";
|
||||
}
|
||||
|
||||
public static class DynamicServices
|
||||
{
|
||||
public const string DynamicService = Default + ".DynamicServices";
|
||||
|
|
|
|||
148
configs/seeds/host/wizard/20260818220914_RoleList.json
Normal file
148
configs/seeds/host/wizard/20260818220914_RoleList.json
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
{
|
||||
"Wizard": {
|
||||
"ComponentKind": 1,
|
||||
"WizardName": "RoleList",
|
||||
"ListFormCode": "App.Wizard.RoleList",
|
||||
"MenuCode": "App.Wizard.RoleList",
|
||||
"MenuOrder": 1,
|
||||
"CustomComponentName": "RoleListComponent",
|
||||
"MenuUrl": "/admin/roles-list",
|
||||
"IsTenant": false,
|
||||
"IsBranch": false,
|
||||
"IsOrganizationUnit": false,
|
||||
"AllowAdding": true,
|
||||
"AllowUpdating": true,
|
||||
"AllowDeleting": true,
|
||||
"AllowDetail": false,
|
||||
"ConfirmDelete": true,
|
||||
"DefaultLayout": "grid",
|
||||
"Grid": true,
|
||||
"Card": true,
|
||||
"Pivot": true,
|
||||
"Tree": false,
|
||||
"Chart": true,
|
||||
"Gantt": false,
|
||||
"Scheduler": false,
|
||||
"Todo": false,
|
||||
"LanguageTextMenuEn": "Role List",
|
||||
"LanguageTextMenuTr": "Role List",
|
||||
"LanguageTextTitleEn": "Role List",
|
||||
"LanguageTextTitleTr": "Role List",
|
||||
"LanguageTextDescEn": "Role List",
|
||||
"LanguageTextDescTr": "Role List",
|
||||
"LanguageTextMenuParentEn": "Sample",
|
||||
"LanguageTextMenuParentTr": "Sample",
|
||||
"PermissionGroupName": "App.Wizard.Sample",
|
||||
"MenuParentCode": "App.Wizard.Sample",
|
||||
"MenuParentShortName": "Sam",
|
||||
"MenuParentIcon": "FcBookmark",
|
||||
"MenuIcon": "FcBiohazard",
|
||||
"DataSourceCode": "Default",
|
||||
"DataSourceConnectionString": "",
|
||||
"SelectCommandType": 1,
|
||||
"SelectCommand": "",
|
||||
"KeyFieldName": "",
|
||||
"KeyFieldDbSourceType": 11,
|
||||
"TreeOptionDto": {
|
||||
"KeyExpr": "",
|
||||
"ParentIdExpr": "",
|
||||
"HasItemsExpr": "",
|
||||
"RootValue": "",
|
||||
"ExpandedRowKeys": [],
|
||||
"AutoExpandAll": false,
|
||||
"RecursiveSelection": false,
|
||||
"TitleExpr": null,
|
||||
"StartExpr": null,
|
||||
"EndExpr": null,
|
||||
"ProgressExpr": null
|
||||
},
|
||||
"GanttOptionDto": {
|
||||
"KeyExpr": "",
|
||||
"ParentIdExpr": "",
|
||||
"HasItemsExpr": "",
|
||||
"RootValue": "",
|
||||
"ExpandedRowKeys": [],
|
||||
"AutoExpandAll": false,
|
||||
"RecursiveSelection": false,
|
||||
"TitleExpr": "",
|
||||
"ScaleType": "weeks",
|
||||
"StartExpr": "",
|
||||
"EndExpr": "",
|
||||
"ProgressExpr": "",
|
||||
"AllowEditing": false,
|
||||
"AllowTaskAdding": false,
|
||||
"AllowTaskUpdating": false,
|
||||
"AllowTaskResourceUpdating": false,
|
||||
"AllowTaskDeleting": false,
|
||||
"AllowDependencyAdding": false,
|
||||
"AllowDependencyDeleting": false,
|
||||
"AllowResourceAdding": false,
|
||||
"AllowResourceUpdating": false,
|
||||
"AllowResourceDeleting": false
|
||||
},
|
||||
"SchedulerOptionDto": {
|
||||
"TextExpr": "",
|
||||
"UserNameExpr": null,
|
||||
"DescriptionExpr": null,
|
||||
"StartDateExpr": "",
|
||||
"EndDateExpr": "",
|
||||
"AllDayExpr": "",
|
||||
"RecurrenceRuleExpr": "",
|
||||
"RecurrenceExceptionExpr": "",
|
||||
"StartDayHour": 8,
|
||||
"EndDayHour": 20,
|
||||
"DefaultView": "week",
|
||||
"ShowAllDayPanel": true,
|
||||
"CellDuration": 30,
|
||||
"FirstDayOfWeek": 1,
|
||||
"CrossScrollingEnabled": false,
|
||||
"AllowResizing": false,
|
||||
"AllowDragging": false,
|
||||
"AllowDeleting": false,
|
||||
"AllowEditing": false,
|
||||
"AllowAdding": false
|
||||
},
|
||||
"TodoOptionDto": {
|
||||
"TitleExpr": "",
|
||||
"StatusExpr": "",
|
||||
"DescriptionExpr": "",
|
||||
"DueDateExpr": "",
|
||||
"TagExpr": "",
|
||||
"AssigneeExpr": "",
|
||||
"PriorityExpr": "",
|
||||
"CompletedExpr": "",
|
||||
"OrderExpr": "",
|
||||
"StatusOrder": "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging",
|
||||
"AllowDragging": true
|
||||
},
|
||||
"WorkflowDto": {
|
||||
"ApprovalUserFieldName": "",
|
||||
"ApprovalDateFieldName": "",
|
||||
"ApprovalStatusFieldName": "",
|
||||
"ApprovalDescriptionFieldName": "",
|
||||
"ApprovalIsFilterUserName": false,
|
||||
"ApprovalIsResetWorkflow": false,
|
||||
"Criteria": []
|
||||
},
|
||||
"Groups": [],
|
||||
"SubForms": [],
|
||||
"Widgets": []
|
||||
},
|
||||
"IsDeletedField": false,
|
||||
"IsCreatedField": false,
|
||||
"InsertedRecords": {
|
||||
"LanguageKeys": [
|
||||
"App.Wizard.RoleList"
|
||||
],
|
||||
"PermissionGroupNames": [
|
||||
"App.Wizard.Sample"
|
||||
],
|
||||
"PermissionNames": [
|
||||
"App.Wizard.RoleList"
|
||||
],
|
||||
"MenuCodes": [
|
||||
"App.Wizard.RoleList"
|
||||
],
|
||||
"DataSourceCodes": []
|
||||
}
|
||||
}
|
||||
|
|
@ -15,3 +15,31 @@ export const PUBLIC_PAGE_DESIGN = {
|
|||
SERVICES: 'App.Services.Design',
|
||||
CONTACT: 'App.Contact.Design',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Wizard dosya yöneticisi yetkileri. `PermissionsData.json` içinde tanımlıdır ve seeder ile
|
||||
* yönetilir; buradaki kontroller yalnızca butonları gizler, asıl kontrol
|
||||
* `ListFormWizardAppService` üzerindedir.
|
||||
*/
|
||||
export const WIZARD_PERMISSION = {
|
||||
/** Ekranı görme (okuma) yetkisi; alt yetkilerin tamamının üstüdür. */
|
||||
DEFAULT: 'App.Listforms.Wizard',
|
||||
CREATE: 'App.Listforms.Wizard.Create',
|
||||
UPDATE: 'App.Listforms.Wizard.Update',
|
||||
DELETE: 'App.Listforms.Wizard.Delete',
|
||||
EXPORT: 'App.Listforms.Wizard.Export',
|
||||
IMPORT: 'App.Listforms.Wizard.Import',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Custom component yöneticisi yetkileri. `PermissionsData.json` içinde tanımlıdır ve seeder ile
|
||||
* yönetilir; buradaki kontroller yalnızca butonları gizler, asıl kontrol
|
||||
* `CustomComponentAppService` üzerindeki ABP policy adlarındadır.
|
||||
*/
|
||||
export const COMPONENT_PERMISSION = {
|
||||
/** Ekranı görme (okuma) yetkisi; alt yetkilerin tamamının üstüdür. */
|
||||
DEFAULT: 'App.DeveloperKit.Components',
|
||||
CREATE: 'App.DeveloperKit.Components.Create',
|
||||
UPDATE: 'App.DeveloperKit.Components.Update',
|
||||
DELETE: 'App.DeveloperKit.Components.Delete',
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ export enum WizardComponentKindEnum {
|
|||
export interface ListFormWizardDto {
|
||||
componentKind: WizardComponentKindEnum
|
||||
|
||||
/**
|
||||
* Düzenleme modunda, yerine geçilecek seed dosyasının adı. Doluysa sunucu önce bu dosyayı
|
||||
* ve ürettiği kayıtları siler, sonra yenisini üretir; istek güncelleme yetkisiyle çalışır.
|
||||
*/
|
||||
editFileName?: string
|
||||
|
||||
wizardName: string
|
||||
listFormCode: string
|
||||
menuCode: string
|
||||
|
|
@ -149,3 +155,38 @@ export interface WizardSeedFileDto {
|
|||
isCreatedField: boolean
|
||||
insertedRecords: WizardInsertedRecordsDto
|
||||
}
|
||||
|
||||
/** Import edilen dosyanin hedefteki karsiligiyla karsilastirma sonucu; backend enum'u ile birebir. */
|
||||
export enum WizardImportEntryStatusEnum {
|
||||
New = 0,
|
||||
Identical = 1,
|
||||
Conflict = 2,
|
||||
}
|
||||
|
||||
export interface WizardImportEntryDto {
|
||||
/** Kapsam klasorune goreli yol, ornegin `custom/Orders.json`. */
|
||||
path: string
|
||||
folder: string
|
||||
fileName: string
|
||||
status: WizardImportEntryStatusEnum
|
||||
incomingSize: number
|
||||
existingSize: number
|
||||
/** Yalnizca Conflict durumunda doludur. */
|
||||
incomingContent?: string
|
||||
/** Yalnizca Conflict durumunda doludur. */
|
||||
existingContent?: string
|
||||
}
|
||||
|
||||
export interface WizardImportAnalysisDto {
|
||||
importId: string
|
||||
/** Dosyalarin yazilacagi kapsam klasoru (`host` ya da `tenants/{tenantId}`). */
|
||||
scopeFolder: string
|
||||
entries: WizardImportEntryDto[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface WizardImportApplyResultDto {
|
||||
path: string
|
||||
overwritten: boolean
|
||||
size: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { ListFormWizardDto, WizardFileInfoDto, WizardSeedFileDto } from '@/proxy/admin/wizard/models'
|
||||
import {
|
||||
ListFormWizardDto,
|
||||
WizardFileInfoDto,
|
||||
WizardImportAnalysisDto,
|
||||
WizardImportApplyResultDto,
|
||||
WizardSeedFileDto,
|
||||
} from '@/proxy/admin/wizard/models'
|
||||
import apiService from './api.service'
|
||||
|
||||
export const postListFormWizard = (input: ListFormWizardDto) =>
|
||||
|
|
@ -52,3 +58,45 @@ export const downloadBlob = (blob: Blob, fileName: string) => {
|
|||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zip'i sunucudaki gecici bir oturuma acar ve her dosyayi hedefteki karsiligiyla karsilastirir.
|
||||
* Hicbir hedef dosyaya dokunmaz; donen `importId` uygulama adimlarinda kullanilir.
|
||||
*/
|
||||
export const analyzeWizardImport = (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('FileName', file.name)
|
||||
formData.append('ContentType', file.type || 'application/zip')
|
||||
formData.append('ContentLength', file.size.toString())
|
||||
|
||||
return apiService.fetchData<WizardImportAnalysisDto>({
|
||||
method: 'POST',
|
||||
url: `/api/app/list-form-wizard/analyze-import`,
|
||||
data: formData as any,
|
||||
})
|
||||
}
|
||||
|
||||
/** Tek bir dosyayi hedefe yazar. `content` verilmezse zip'ten gelen icerik oldugu gibi yazilir. */
|
||||
export const applyWizardImport = (importId: string, path: string, content?: string) =>
|
||||
apiService.fetchData<WizardImportApplyResultDto>({
|
||||
method: 'POST',
|
||||
url: `/api/app/list-form-wizard/apply-import`,
|
||||
data: { importId, path, content: content ?? null },
|
||||
})
|
||||
|
||||
/** Import oturumunu kapatir; staging ve yedek klasorleri silinir. */
|
||||
export const completeWizardImport = (importId: string) =>
|
||||
apiService.fetchData({
|
||||
method: 'POST',
|
||||
url: `/api/app/list-form-wizard/complete-import`,
|
||||
params: { importId },
|
||||
})
|
||||
|
||||
/** O ana kadar yazilmis dosyalari geri alir ve oturumu kapatir. */
|
||||
export const rollbackWizardImport = (importId: string) =>
|
||||
apiService.fetchData({
|
||||
method: 'POST',
|
||||
url: `/api/app/list-form-wizard/rollback-import`,
|
||||
params: { importId },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,31 +15,27 @@ import { getDataSources } from '@/services/data-source.service'
|
|||
import { sqlObjectManagerService } from '@/services/sql-query-manager.service'
|
||||
import type { SqlObjectExplorerDto, DatabaseColumnDto } from '@/proxy/sql-query-manager/models'
|
||||
import { MenuItem } from '@/proxy/menus/menu'
|
||||
import WizardStep1, {
|
||||
// Adım bileşenleri, sihirbazda göründükleri sırayla.
|
||||
import WizardStepMenu, {
|
||||
MenuTreeNode,
|
||||
buildMenuTree,
|
||||
filterNonLinkNodes,
|
||||
findRootCode,
|
||||
} from './WizardStep1'
|
||||
import WizardStep2 from './WizardStep2'
|
||||
import WizardStep3, { WizardGroup, WizardGroupItem } from './WizardStep3'
|
||||
import WizardStep7 from './WizardStep7'
|
||||
import WizardStep8 from './WizardStep8'
|
||||
import WizardStep9 from './WizardStep9'
|
||||
import WizardStep10 from './WizardStep10'
|
||||
import WizardStep11 from './WizardStep11'
|
||||
import WizardStep4 from './WizardStep4'
|
||||
import WizardStep5 from './WizardStep5'
|
||||
import WizardStep6 from './WizardStep6'
|
||||
} from './WizardStepMenu'
|
||||
import WizardStepDataSettings from './WizardStepDataSettings'
|
||||
import WizardStepFields, { WizardGroup, WizardGroupItem } from './WizardStepFields'
|
||||
import WizardStepSubForms from './WizardStepSubForms'
|
||||
import WizardStepWidgets from './WizardStepWidgets'
|
||||
import WizardStepWorkflow from './WizardStepWorkflow'
|
||||
import WizardStepTodoLayout from './WizardStepTodoLayout'
|
||||
import WizardStepTreeLayout from './WizardStepTreeLayout'
|
||||
import WizardStepGanttLayout from './WizardStepGanttLayout'
|
||||
import WizardStepSchedulerLayout from './WizardStepSchedulerLayout'
|
||||
import WizardStepDeploy from './WizardStepDeploy'
|
||||
import { Container } from '@/components/shared'
|
||||
import { sqlDataTypeToDbType } from '../edit/options'
|
||||
import { useStoreActions } from '@/store/store'
|
||||
import {
|
||||
deleteWizardFile,
|
||||
getWizardFile,
|
||||
getWizardFiles,
|
||||
postListFormWizard,
|
||||
} from '@/services/wizard.service'
|
||||
import { getWizardFile, getWizardFiles, postListFormWizard } from '@/services/wizard.service'
|
||||
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
|
||||
import WizardStepComponent from './WizardStepComponent'
|
||||
import WizardStepCustomComponent from './WizardStepCustomComponent'
|
||||
|
|
@ -451,8 +447,7 @@ const Wizard = () => {
|
|||
formikRef.current?.setFieldValue('isBranch', colNames.has('branchid'))
|
||||
// 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]
|
||||
const keyColumn = cols.find((c) => c.columnName.toLowerCase() === 'id') ?? cols[0]
|
||||
if (keyColumn) {
|
||||
formikRef.current?.setFieldValue('keyFieldName', keyColumn.columnName)
|
||||
formikRef.current?.setFieldValue(
|
||||
|
|
@ -949,16 +944,16 @@ const Wizard = () => {
|
|||
const values = formik.values
|
||||
const isCustom = values.componentKind === WizardComponentKindEnum.Custom
|
||||
|
||||
try {
|
||||
// Edit modunda: önce eski dosyayı sil (DB kayıtlarını temizler)
|
||||
if (isEditMode && editFileName) {
|
||||
await deleteWizardFile(editFileName)
|
||||
}
|
||||
// Edit modunda eski dosyanın silinmesini (ve DB kayıtlarının temizlenmesini) sunucu yapar;
|
||||
// istek `editFileName` ile geldiğinde güncelleme yetkisiyle çalışır.
|
||||
const editing = isEditMode && editFileName ? { editFileName } : {}
|
||||
|
||||
try {
|
||||
// Custom yolunda ListForm'a ait hiçbir koleksiyon gönderilmez.
|
||||
if (isCustom) {
|
||||
await postListFormWizard({
|
||||
...values,
|
||||
...editing,
|
||||
groups: [],
|
||||
subForms: [],
|
||||
widgets: [],
|
||||
|
|
@ -979,6 +974,7 @@ const Wizard = () => {
|
|||
|
||||
await postListFormWizard({
|
||||
...values,
|
||||
...editing,
|
||||
groups: editingGroups.map((g) => ({
|
||||
caption: g.caption,
|
||||
colCount: g.colCount,
|
||||
|
|
@ -1173,7 +1169,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 3: Menu ───────────────────────────────────── */}
|
||||
{resolvedStep === STEP.menu && (
|
||||
<WizardStep1
|
||||
<WizardStepMenu
|
||||
values={values}
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
|
|
@ -1209,7 +1205,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 4: Data Settings ───────────────────────────── */}
|
||||
{resolvedStep === STEP.settings && (
|
||||
<WizardStep2
|
||||
<WizardStepDataSettings
|
||||
values={values}
|
||||
errors={errors}
|
||||
touched={touched}
|
||||
|
|
@ -1240,7 +1236,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 5: List Form Fields ───────────────────────────── */}
|
||||
{resolvedStep === STEP.fields && (
|
||||
<WizardStep3
|
||||
<WizardStepFields
|
||||
selectedColumns={selectedColumns}
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
groups={editingGroups}
|
||||
|
|
@ -1255,7 +1251,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 6: Sub Forms ───────────────────────────── */}
|
||||
{resolvedStep === STEP.subForms && (
|
||||
<WizardStep4
|
||||
<WizardStepSubForms
|
||||
subForms={subForms}
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
translate={translate}
|
||||
|
|
@ -1267,7 +1263,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 7: Widgets ───────────────────────────── */}
|
||||
{resolvedStep === STEP.widgets && (
|
||||
<WizardStep5
|
||||
<WizardStepWidgets
|
||||
widgets={widgets}
|
||||
translate={translate}
|
||||
onChange={setWidgets}
|
||||
|
|
@ -1278,7 +1274,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 8: Workflow ───────────────────────────── */}
|
||||
{resolvedStep === STEP.workflow && (
|
||||
<WizardStep6
|
||||
<WizardStepWorkflow
|
||||
listFormCode={values.listFormCode}
|
||||
workflow={workflow}
|
||||
criteria={criteria}
|
||||
|
|
@ -1293,7 +1289,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 9: Todo / Kanban ────────────────────── */}
|
||||
{resolvedStep === STEP.todo && values.todo && (
|
||||
<WizardStep7
|
||||
<WizardStepTodoLayout
|
||||
selectedColumns={selectedColumns}
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
translate={translate}
|
||||
|
|
@ -1304,7 +1300,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 10: Tree ─────────────────────────────── */}
|
||||
{resolvedStep === STEP.tree && values.tree && (
|
||||
<WizardStep8
|
||||
<WizardStepTreeLayout
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
translate={translate}
|
||||
onBack={() => goToAdjacentStep(-1)}
|
||||
|
|
@ -1314,7 +1310,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 11: Gantt ────────────────────────────── */}
|
||||
{resolvedStep === STEP.gantt && values.gantt && (
|
||||
<WizardStep9
|
||||
<WizardStepGanttLayout
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
translate={translate}
|
||||
onBack={() => goToAdjacentStep(-1)}
|
||||
|
|
@ -1324,7 +1320,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 12: Scheduler ───────────────────────── */}
|
||||
{resolvedStep === STEP.scheduler && values.scheduler && (
|
||||
<WizardStep10
|
||||
<WizardStepSchedulerLayout
|
||||
selectCommandColumns={selectCommandColumns}
|
||||
translate={translate}
|
||||
onBack={() => goToAdjacentStep(-1)}
|
||||
|
|
@ -1334,7 +1330,7 @@ const Wizard = () => {
|
|||
|
||||
{/* ─── Step 13: Deploy ──────────────────────────── */}
|
||||
{resolvedStep === STEP.deploy && (
|
||||
<WizardStep11
|
||||
<WizardStepDeploy
|
||||
values={values}
|
||||
wizardName={values.wizardName}
|
||||
selectedColumns={selectedColumns}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import classNames from 'classnames'
|
||||
import { Button, Dialog, Input, Notification, toast } from '@/components/ui'
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
FaLink,
|
||||
FaExternalLinkAlt,
|
||||
FaFileExport,
|
||||
FaFileImport,
|
||||
} from 'react-icons/fa'
|
||||
import DbMigrateButton from '@/components/shared/DbMigrateButton'
|
||||
import {
|
||||
|
|
@ -31,6 +32,9 @@ import { useStoreState } from '@/store/store'
|
|||
import { ROUTES_ENUM } from '@/routes/route.constant'
|
||||
import { WizardComponentKindEnum, WizardFileInfoDto } from '@/proxy/admin/wizard/models'
|
||||
import { useNavigationIcons } from '@/proxy/menus/navigation-icon.config'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
import { WIZARD_PERMISSION } from '@/constants/permission.constant'
|
||||
import WizardImportDialog from './WizardImportDialog'
|
||||
|
||||
interface ConfirmState {
|
||||
fileName: string
|
||||
|
|
@ -50,6 +54,16 @@ const WizardFileManager = () => {
|
|||
const [exportingFile, setExportingFile] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Butonlar yalnizca gizlenir; asil kontrol ListFormWizardAppService'te yapilir.
|
||||
const { checkPermission } = usePermission()
|
||||
const canCreate = checkPermission(WIZARD_PERMISSION.CREATE)
|
||||
const canUpdate = checkPermission(WIZARD_PERMISSION.UPDATE)
|
||||
const canDelete = checkPermission(WIZARD_PERMISSION.DELETE)
|
||||
const canExport = checkPermission(WIZARD_PERMISSION.EXPORT)
|
||||
const canImport = checkPermission(WIZARD_PERMISSION.IMPORT)
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
|
|
@ -104,6 +118,14 @@ const WizardFileManager = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Zip secildikten sonra tum akis WizardImportDialog icinde yurur; ayni dosya yeniden
|
||||
// secilebilsin diye input degeri her seferinde sifirlanir.
|
||||
const handleImportFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (file) setImportFile(file)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirm) return
|
||||
setDeletingFile(confirm.fileName)
|
||||
|
|
@ -166,18 +188,44 @@ const WizardFileManager = () => {
|
|||
>
|
||||
<FaSync />
|
||||
</Button>
|
||||
{canImport && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
type="button"
|
||||
title={translate('::App.ListForm.WizardImportTitle')}
|
||||
className="flex items-center"
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
>
|
||||
<FaFileImport className="sm:mr-1" />
|
||||
<span className="hidden sm:inline">
|
||||
{translate('::App.ListForm.WizardImportButton')}
|
||||
</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={handleImportFileChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DbMigrateButton hideLabelOnMobile />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onClick={() => navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard)}
|
||||
className="flex items-center"
|
||||
>
|
||||
<FaPlus className="mr-1" />
|
||||
<span className="hidden sm:inline">
|
||||
{translate('::App.ListFormWizard.AddNewRecord')}
|
||||
</span>
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="solid"
|
||||
className="flex items-center"
|
||||
onClick={() => navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard)}
|
||||
>
|
||||
<FaPlus className="mr-1" />
|
||||
<span className="hidden sm:inline">
|
||||
{translate('::App.ListFormWizard.AddNewRecord')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -280,44 +328,50 @@ 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"
|
||||
className="text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
|
||||
type="button"
|
||||
title={translate('::App.Platform.Edit')}
|
||||
onClick={() =>
|
||||
navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard, {
|
||||
state: { editFileName: f.fileName },
|
||||
})
|
||||
}
|
||||
>
|
||||
<FaEdit />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="plain"
|
||||
className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
type="button"
|
||||
title={translate('::App.Platform.Delete')}
|
||||
loading={deletingFile === f.fileName}
|
||||
onClick={() =>
|
||||
setConfirm({ fileName: f.fileName, wizardName: f.wizardName || f.fileName })
|
||||
}
|
||||
>
|
||||
<FaTrash />
|
||||
</Button>
|
||||
{canExport && (
|
||||
<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>
|
||||
)}
|
||||
{canUpdate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="plain"
|
||||
className="text-indigo-500 hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
|
||||
type="button"
|
||||
title={translate('::App.Platform.Edit')}
|
||||
onClick={() =>
|
||||
navigate(ROUTES_ENUM.protected.saas.listFormManagement.wizard, {
|
||||
state: { editFileName: f.fileName },
|
||||
})
|
||||
}
|
||||
>
|
||||
<FaEdit />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="plain"
|
||||
className="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
type="button"
|
||||
title={translate('::App.Platform.Delete')}
|
||||
loading={deletingFile === f.fileName}
|
||||
onClick={() =>
|
||||
setConfirm({ fileName: f.fileName, wizardName: f.wizardName || f.fileName })
|
||||
}
|
||||
>
|
||||
<FaTrash />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -325,6 +379,14 @@ const WizardFileManager = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{importFile && (
|
||||
<WizardImportDialog
|
||||
file={importFile}
|
||||
onClose={() => setImportFile(null)}
|
||||
onImported={loadFiles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm Dialog */}
|
||||
<Dialog
|
||||
isOpen={confirm !== null}
|
||||
|
|
|
|||
662
ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx
Normal file
662
ui/src/views/admin/listForm/wizard/WizardImportDialog.tsx
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { DiffEditor } from '@monaco-editor/react'
|
||||
import { Button, Dialog, Progress } from '@/components/ui'
|
||||
import {
|
||||
FaCheck,
|
||||
FaCheckCircle,
|
||||
FaCodeBranch,
|
||||
FaExclamationTriangle,
|
||||
FaFileAlt,
|
||||
FaFileImport,
|
||||
FaPlusCircle,
|
||||
FaTimes,
|
||||
FaTimesCircle,
|
||||
FaUndo,
|
||||
} from 'react-icons/fa'
|
||||
import {
|
||||
analyzeWizardImport,
|
||||
applyWizardImport,
|
||||
completeWizardImport,
|
||||
rollbackWizardImport,
|
||||
} from '@/services/wizard.service'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import {
|
||||
WizardImportAnalysisDto,
|
||||
WizardImportEntryDto,
|
||||
WizardImportEntryStatusEnum,
|
||||
} from '@/proxy/admin/wizard/models'
|
||||
|
||||
interface WizardImportDialogProps {
|
||||
file: File | null
|
||||
onClose: () => void
|
||||
/** En az bir dosya yazildiginda tetiklenir; liste yenilenir. */
|
||||
onImported: () => void
|
||||
}
|
||||
|
||||
/** Kullanicinin bir dosya icin verdigi karar. */
|
||||
interface EntryDecision {
|
||||
/** false ise dosya hedefe yazilmaz. */
|
||||
apply: boolean
|
||||
/** Catisma ekraninda karar verildi mi; verilmeden import baslatilamaz. */
|
||||
resolved: boolean
|
||||
/** Diff ekraninda birlestirilmis icerik; yalnizca catismali dosyalarda dolar. */
|
||||
content?: string
|
||||
}
|
||||
|
||||
type ImportPhase = 'analyzing' | 'review' | 'running' | 'finished' | 'rolledBack'
|
||||
|
||||
interface AppliedRow {
|
||||
path: string
|
||||
ok: boolean
|
||||
overwritten: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
const statusOrder: Record<WizardImportEntryStatusEnum, number> = {
|
||||
[WizardImportEntryStatusEnum.Conflict]: 0,
|
||||
[WizardImportEntryStatusEnum.New]: 1,
|
||||
[WizardImportEntryStatusEnum.Identical]: 2,
|
||||
}
|
||||
|
||||
const languageOf = (fileName: string) => (fileName.toLowerCase().endsWith('.sql') ? 'sql' : 'json')
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
if (!size) return '0 B'
|
||||
if (size < 1024) return `${size} B`
|
||||
return `${(size / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
/**
|
||||
* Wizard export zip'ini kapsam klasorune aktarir.
|
||||
* Akis: analiz → catisma cozumu (diff) → toplu yazma. Yazma sirasinda bir hata olursa
|
||||
* o ana kadar yazilan tum dosyalar sunucudaki yedeklerden geri alinir.
|
||||
*/
|
||||
const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogProps) => {
|
||||
const { translate } = useLocalization()
|
||||
const mode = useStoreState((state) => state.theme.mode)
|
||||
|
||||
const [phase, setPhase] = useState<ImportPhase>('analyzing')
|
||||
const [analysis, setAnalysis] = useState<WizardImportAnalysisDto | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<string, EntryDecision>>({})
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [applied, setApplied] = useState<AppliedRow[]>([])
|
||||
const [currentPath, setCurrentPath] = useState<string | null>(null)
|
||||
const [showIdentical, setShowIdentical] = useState(false)
|
||||
const [editorEpoch, setEditorEpoch] = useState(0)
|
||||
|
||||
// Diff editorunde yazilan icerik her tusa basista state'e yazilmaz; karar verilirken okunur.
|
||||
const draftRef = useRef<string>('')
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const list = [...(analysis?.entries ?? [])]
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
statusOrder[a.status] - statusOrder[b.status] ||
|
||||
a.path.localeCompare(b.path, undefined, { sensitivity: 'base' }),
|
||||
)
|
||||
return list
|
||||
}, [analysis])
|
||||
|
||||
const conflicts = useMemo(
|
||||
() => entries.filter((e) => e.status === WizardImportEntryStatusEnum.Conflict),
|
||||
[entries],
|
||||
)
|
||||
const unresolvedCount = useMemo(
|
||||
() => conflicts.filter((e) => !decisions[e.path]?.resolved).length,
|
||||
[conflicts, decisions],
|
||||
)
|
||||
const applyList = useMemo(
|
||||
() => entries.filter((e) => decisions[e.path]?.apply),
|
||||
[entries, decisions],
|
||||
)
|
||||
const visibleEntries = useMemo(
|
||||
() =>
|
||||
showIdentical
|
||||
? entries
|
||||
: entries.filter((e) => e.status !== WizardImportEntryStatusEnum.Identical),
|
||||
[entries, showIdentical],
|
||||
)
|
||||
const identicalCount = useMemo(
|
||||
() => entries.filter((e) => e.status === WizardImportEntryStatusEnum.Identical).length,
|
||||
[entries],
|
||||
)
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => entries.find((e) => e.path === selectedPath) ?? null,
|
||||
[entries, selectedPath],
|
||||
)
|
||||
|
||||
// Analiz: zip sunucuda gecici bir oturuma acilir, hedef klasore henuz dokunulmaz.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
if (!file) return
|
||||
setPhase('analyzing')
|
||||
setError(null)
|
||||
try {
|
||||
const res = await analyzeWizardImport(file)
|
||||
if (cancelled) return
|
||||
|
||||
const data = res.data
|
||||
const initial: Record<string, EntryDecision> = {}
|
||||
data.entries.forEach((entry) => {
|
||||
initial[entry.path] = {
|
||||
// Yeni dosyalar sorulmadan kopyalanir, ayni olanlar atlanir, catismalilar karar bekler.
|
||||
apply: entry.status === WizardImportEntryStatusEnum.New,
|
||||
resolved: entry.status !== WizardImportEntryStatusEnum.Conflict,
|
||||
content:
|
||||
entry.status === WizardImportEntryStatusEnum.Conflict
|
||||
? entry.incomingContent
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
setAnalysis(data)
|
||||
setDecisions(initial)
|
||||
setPhase('review')
|
||||
|
||||
const firstConflict = data.entries.find(
|
||||
(e) => e.status === WizardImportEntryStatusEnum.Conflict,
|
||||
)
|
||||
if (firstConflict) setSelectedPath(firstConflict.path)
|
||||
} catch (err: any) {
|
||||
if (cancelled) return
|
||||
setError(err?.response?.data?.error?.message ?? err?.message ?? 'Unknown error')
|
||||
setPhase('review')
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [file])
|
||||
|
||||
// Diff editorunun tasidigi taslak, secim degistikce ilgili dosyanin son haline doner.
|
||||
useEffect(() => {
|
||||
if (!selectedEntry) {
|
||||
draftRef.current = ''
|
||||
return
|
||||
}
|
||||
draftRef.current = decisions[selectedEntry.path]?.content ?? selectedEntry.incomingContent ?? ''
|
||||
// decisions bagimliligi kasitli olarak disarida: her tus vurusunda editor sifirlanmamalidir.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPath])
|
||||
|
||||
const decide = useCallback((path: string, decision: EntryDecision) => {
|
||||
setDecisions((prev) => ({ ...prev, [path]: decision }))
|
||||
}, [])
|
||||
|
||||
const goToNextConflict = useCallback(
|
||||
(fromPath: string) => {
|
||||
const index = conflicts.findIndex((c) => c.path === fromPath)
|
||||
const next = conflicts.slice(index + 1).find((c) => !decisions[c.path]?.resolved)
|
||||
setSelectedPath(next ? next.path : null)
|
||||
},
|
||||
[conflicts, decisions],
|
||||
)
|
||||
|
||||
const handleTakeIncoming = (entry: WizardImportEntryDto) => {
|
||||
decide(entry.path, { apply: true, resolved: true, content: draftRef.current })
|
||||
goToNextConflict(entry.path)
|
||||
}
|
||||
|
||||
const handleKeepExisting = (entry: WizardImportEntryDto) => {
|
||||
decide(entry.path, { apply: false, resolved: true })
|
||||
goToNextConflict(entry.path)
|
||||
}
|
||||
|
||||
/** Editordeki elle yapilan degisiklikleri atip zip'ten gelen icerige doner. */
|
||||
const handleResetDraft = (entry: WizardImportEntryDto) => {
|
||||
draftRef.current = entry.incomingContent ?? ''
|
||||
decide(entry.path, {
|
||||
...(decisions[entry.path] ?? { apply: true, resolved: false }),
|
||||
content: entry.incomingContent,
|
||||
})
|
||||
// Editor icerigi prop ile ayni kalabilecegi icin bilesen key ile yeniden kurulur.
|
||||
setEditorEpoch((v) => v + 1)
|
||||
}
|
||||
|
||||
/** Dosyalari sirayla yazar; ilk hatada tum toplu islem geri alinir. */
|
||||
const handleStart = async () => {
|
||||
if (!analysis) return
|
||||
|
||||
setPhase('running')
|
||||
setApplied([])
|
||||
setError(null)
|
||||
|
||||
const rows: AppliedRow[] = []
|
||||
|
||||
for (const entry of applyList) {
|
||||
setCurrentPath(entry.path)
|
||||
try {
|
||||
const res = await applyWizardImport(
|
||||
analysis.importId,
|
||||
entry.path,
|
||||
decisions[entry.path]?.content,
|
||||
)
|
||||
rows.push({ path: entry.path, ok: true, overwritten: res.data.overwritten })
|
||||
setApplied([...rows])
|
||||
} catch (err: any) {
|
||||
const message = err?.response?.data?.error?.message ?? err?.message ?? 'Unknown error'
|
||||
rows.push({ path: entry.path, ok: false, overwritten: false, message })
|
||||
setApplied([...rows])
|
||||
setCurrentPath(null)
|
||||
setError(message)
|
||||
|
||||
try {
|
||||
await rollbackWizardImport(analysis.importId)
|
||||
} catch (rollbackErr: any) {
|
||||
setError(
|
||||
`${message} — ${translate('::App.ListForm.WizardImportRollbackFailed')}: ${
|
||||
rollbackErr?.message ?? 'Unknown error'
|
||||
}`,
|
||||
)
|
||||
}
|
||||
|
||||
setPhase('rolledBack')
|
||||
onImported()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentPath(null)
|
||||
try {
|
||||
await completeWizardImport(analysis.importId)
|
||||
} catch {
|
||||
// Oturum klasoru zaten gecici; temizlenemezse import basarili sayilir.
|
||||
}
|
||||
setPhase('finished')
|
||||
onImported()
|
||||
}
|
||||
|
||||
/** Import baslamadan kapatilirsa sunucudaki staging klasoru birakilmaz. */
|
||||
const handleClose = () => {
|
||||
if (phase === 'running') return
|
||||
if (analysis && phase === 'review') {
|
||||
rollbackWizardImport(analysis.importId).catch(() => undefined)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const progressPercent = applyList.length
|
||||
? Math.round((applied.length / applyList.length) * 100)
|
||||
: 0
|
||||
|
||||
const renderStatusBadge = (entry: WizardImportEntryDto) => {
|
||||
const decision = decisions[entry.path]
|
||||
|
||||
if (entry.status === WizardImportEntryStatusEnum.Identical)
|
||||
return (
|
||||
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-[11px] text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{translate('::App.ListForm.WizardImportIdentical')}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (entry.status === WizardImportEntryStatusEnum.New)
|
||||
return (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{translate('::App.ListForm.WizardImportNew')}
|
||||
</span>
|
||||
)
|
||||
|
||||
if (!decision?.resolved)
|
||||
return (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{translate('::App.ListForm.WizardImportConflict')}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
'rounded-full px-2 py-0.5 text-[11px]',
|
||||
decision.apply
|
||||
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
|
||||
: 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300',
|
||||
)}
|
||||
>
|
||||
{translate(
|
||||
decision.apply
|
||||
? '::App.ListForm.WizardImportResolvedApply'
|
||||
: '::App.ListForm.WizardImportResolvedSkip',
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const renderReview = () => (
|
||||
<div className="flex min-h-0 flex-1 gap-3">
|
||||
{/* Dosya listesi */}
|
||||
<div className="flex w-72 shrink-0 flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2 text-xs text-gray-500 dark:border-gray-700">
|
||||
<span className="font-mono truncate">{analysis?.scopeFolder}</span>
|
||||
<span>
|
||||
{applyList.length}/{entries.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{visibleEntries.map((entry) => {
|
||||
const decision = decisions[entry.path]
|
||||
const isConflict = entry.status === WizardImportEntryStatusEnum.Conflict
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={classNames(
|
||||
'flex w-full cursor-pointer items-center gap-2 border-b border-gray-100 px-3 py-2 text-left last:border-b-0 dark:border-gray-800',
|
||||
selectedPath === entry.path
|
||||
? 'bg-indigo-50 dark:bg-indigo-900/20'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800',
|
||||
)}
|
||||
onClick={() => setSelectedPath(entry.path)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') setSelectedPath(entry.path)
|
||||
}}
|
||||
>
|
||||
{isConflict ? (
|
||||
<FaCodeBranch className="shrink-0 text-amber-500" />
|
||||
) : entry.status === WizardImportEntryStatusEnum.New ? (
|
||||
<FaPlusCircle className="shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<FaFileAlt className="shrink-0 text-gray-400" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-gray-800 dark:text-gray-200">
|
||||
{entry.fileName}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-[11px] text-gray-400">
|
||||
{entry.path}
|
||||
</span>
|
||||
</span>
|
||||
{renderStatusBadge(entry)}
|
||||
{!isConflict && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
checked={!!decision?.apply}
|
||||
title={translate('::App.ListForm.WizardImportInclude')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={(e) =>
|
||||
decide(entry.path, {
|
||||
apply: e.target.checked,
|
||||
resolved: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{identicalCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="border-t border-gray-200 px-3 py-2 text-left text-[11px] text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
onClick={() => setShowIdentical((v) => !v)}
|
||||
>
|
||||
{showIdentical
|
||||
? translate('::App.ListForm.WizardImportHideIdentical')
|
||||
: `${identicalCount} ${translate('::App.ListForm.WizardImportShowIdentical')}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Karsilastirma / detay */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{selectedEntry && selectedEntry.status === WizardImportEntryStatusEnum.Conflict ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-gray-700">
|
||||
<FaCodeBranch className="text-amber-500" />
|
||||
<span className="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{selectedEntry.path}
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400">
|
||||
{formatSize(selectedEntry.existingSize)} → {formatSize(selectedEntry.incomingSize)}
|
||||
</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="plain"
|
||||
type="button"
|
||||
icon={<FaUndo />}
|
||||
onClick={() => handleResetDraft(selectedEntry)}
|
||||
>
|
||||
{translate('::App.ListForm.WizardImportResetIncoming')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
type="button"
|
||||
onClick={() => handleKeepExisting(selectedEntry)}
|
||||
>
|
||||
{translate('::App.ListForm.WizardImportKeepExisting')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="solid"
|
||||
type="button"
|
||||
onClick={() => handleTakeIncoming(selectedEntry)}
|
||||
>
|
||||
{translate('::App.ListForm.WizardImportUseThis')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-b border-gray-200 px-3 py-1 text-[11px] text-gray-400 dark:border-gray-700">
|
||||
<span className="flex-1">{translate('::App.ListForm.WizardImportExistingSide')}</span>
|
||||
<span className="flex-1">{translate('::App.ListForm.WizardImportIncomingSide')}</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<DiffEditor
|
||||
key={`${selectedEntry.path}#${editorEpoch}`}
|
||||
height="100%"
|
||||
language={languageOf(selectedEntry.fileName)}
|
||||
original={selectedEntry.existingContent ?? ''}
|
||||
modified={
|
||||
decisions[selectedEntry.path]?.content ?? selectedEntry.incomingContent ?? ''
|
||||
}
|
||||
theme={mode === 'dark' ? 'vs-dark' : 'light'}
|
||||
options={{
|
||||
renderSideBySide: true,
|
||||
originalEditable: false,
|
||||
readOnly: false,
|
||||
automaticLayout: true,
|
||||
fontSize: 12,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
const modified = editor.getModifiedEditor()
|
||||
draftRef.current = modified.getValue()
|
||||
modified.onDidChangeModelContent(() => {
|
||||
draftRef.current = modified.getValue()
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-gray-400">
|
||||
<FaFileImport size={32} />
|
||||
{unresolvedCount > 0 ? (
|
||||
<span>
|
||||
{unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')}
|
||||
</span>
|
||||
) : (
|
||||
<span>{translate('::App.ListForm.WizardImportReady')}</span>
|
||||
)}
|
||||
{analysis && analysis.warnings.length > 0 && (
|
||||
<div className="mt-3 w-full max-w-xl rounded-lg bg-amber-50 p-3 text-left text-xs text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||||
<div className="mb-1 flex items-center gap-1 font-medium">
|
||||
<FaExclamationTriangle />
|
||||
{translate('::App.ListForm.WizardImportWarnings')}
|
||||
</div>
|
||||
<ul className="list-disc space-y-0.5 pl-4">
|
||||
{analysis.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderProgress = () => (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<Progress
|
||||
percent={progressPercent}
|
||||
color={phase === 'rolledBack' ? 'red-500' : undefined}
|
||||
customInfo={`${applied.length}/${applyList.length}`}
|
||||
/>
|
||||
{currentPath && (
|
||||
<div className="truncate font-mono text-xs text-gray-500">
|
||||
{translate('::App.ListForm.WizardImportWriting')}: {currentPath}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
{applied.map((row) => (
|
||||
<div
|
||||
key={row.path}
|
||||
className="flex items-center gap-2 border-b border-gray-100 px-3 py-1.5 text-xs last:border-b-0 dark:border-gray-800"
|
||||
>
|
||||
{row.ok ? (
|
||||
<FaCheckCircle className="shrink-0 text-emerald-500" />
|
||||
) : (
|
||||
<FaTimesCircle className="shrink-0 text-red-500" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-gray-700 dark:text-gray-300">
|
||||
{row.path}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400">
|
||||
{row.ok
|
||||
? translate(
|
||||
row.overwritten
|
||||
? '::App.ListForm.WizardImportOverwritten'
|
||||
: '::App.ListForm.WizardImportCreated',
|
||||
)
|
||||
: row.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{phase === 'rolledBack' && (
|
||||
<div className="rounded-lg bg-red-50 p-3 text-xs text-red-700 dark:bg-red-900/20 dark:text-red-300">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<FaExclamationTriangle />
|
||||
{translate('::App.ListForm.WizardImportRolledBack')}
|
||||
</div>
|
||||
{error && <div className="mt-1 break-all">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
{phase === 'finished' && (
|
||||
<div className="rounded-lg bg-emerald-50 p-3 text-xs text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
<FaCheckCircle />
|
||||
{translate('::App.ListForm.WizardImportSuccess')}
|
||||
</div>
|
||||
<div className="mt-1">{translate('::App.ListForm.WizardImportRunMigrateHint')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
preventScroll
|
||||
isOpen={file !== null}
|
||||
width={1360}
|
||||
contentClassName="flex flex-col"
|
||||
closable={phase !== 'running'}
|
||||
onClose={handleClose}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<Dialog.Body className="mt-5 flex h-[calc(100vh-14rem)] max-h-[calc(90vh-7rem)] min-h-[360px] !flex-none flex-col gap-3 !overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
|
||||
<FaFileImport className="text-sky-500" />
|
||||
<h5 className="mr-1">{translate('::App.ListForm.WizardImportTitle')}</h5>
|
||||
<span className="truncate font-mono text-xs text-gray-400">{file?.name}</span>
|
||||
{phase === 'review' && analysis && (
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2 text-[11px]">
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||
{entries.filter((e) => e.status === WizardImportEntryStatusEnum.New).length}{' '}
|
||||
{translate('::App.ListForm.WizardImportNew')}
|
||||
</span>
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
{conflicts.length} {translate('::App.ListForm.WizardImportConflict')}
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-200 px-2 py-0.5 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
{entries.filter((e) => e.status === WizardImportEntryStatusEnum.Identical).length}{' '}
|
||||
{translate('::App.ListForm.WizardImportIdentical')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{phase === 'analyzing' && (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-gray-400">
|
||||
<span className="animate-pulse">
|
||||
{translate('::App.ListForm.WizardImportAnalyzing')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'review' && error && (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-red-500">
|
||||
<FaExclamationTriangle size={28} />
|
||||
<span className="break-all text-center">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'review' && !error && analysis && renderReview()}
|
||||
|
||||
{(phase === 'running' || phase === 'finished' || phase === 'rolledBack') &&
|
||||
renderProgress()}
|
||||
</Dialog.Body>
|
||||
|
||||
<Dialog.Footer className="mt-1 flex items-center justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
{phase === 'review' && !error && unresolvedCount > 0 && (
|
||||
<span className="mr-auto text-xs text-amber-600 dark:text-amber-400">
|
||||
{unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="plain"
|
||||
icon={<FaTimes />}
|
||||
disabled={phase === 'running'}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{translate(
|
||||
phase === 'finished' || phase === 'rolledBack'
|
||||
? '::App.Platform.Close'
|
||||
: '::App.Platform.Cancel',
|
||||
)}
|
||||
</Button>
|
||||
{phase === 'review' && !error && (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="solid"
|
||||
icon={<FaCheck />}
|
||||
disabled={unresolvedCount > 0 || applyList.length === 0}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{translate('::App.ListForm.WizardImportStart')} ({applyList.length})
|
||||
</Button>
|
||||
)}
|
||||
</Dialog.Footer>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default WizardImportDialog
|
||||
|
|
@ -73,6 +73,7 @@ const WizardStepCustomComponent = ({
|
|||
<Select
|
||||
field={field}
|
||||
form={form}
|
||||
size="md"
|
||||
isClearable={true}
|
||||
options={componentOptions}
|
||||
placeholder={translate('::App.WizardStepComponent.SelectComponent')}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { usePermission } from '@/utils/hooks/usePermission'
|
|||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WizardStep2Props {
|
||||
export interface WizardStepDataSettingsProps {
|
||||
values: ListFormWizardDto
|
||||
errors: FormikErrors<ListFormWizardDto>
|
||||
touched: FormikTouched<ListFormWizardDto>
|
||||
|
|
@ -50,9 +50,14 @@ export interface WizardStep2Props {
|
|||
onNext: () => void
|
||||
}
|
||||
|
||||
// ─── WizardStep2 ──────────────────────────────────────────────────────────────
|
||||
// ─── WizardStepDataSettings ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WizardStep2 = ({
|
||||
/**
|
||||
* Veri ayarları adımı: ListForm kodu, veri kaynağı, select komutu (tablo/view/sp/query) ve
|
||||
* listenin izin verdiği işlemler (ekleme/güncelleme/silme/detay) ile layout seçenekleri.
|
||||
* Sonraki alan adımının okuduğu kolon listesi buradaki select komutundan çözülür.
|
||||
*/
|
||||
const WizardStepDataSettings = ({
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
|
|
@ -75,7 +80,7 @@ const WizardStep2 = ({
|
|||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStep2Props) => {
|
||||
}: WizardStepDataSettingsProps) => {
|
||||
const [showTableDesignerDialog, setShowTableDesignerDialog] = useState(false)
|
||||
const [designTableData, setDesignTableData] = useState<{
|
||||
schemaName: string
|
||||
|
|
@ -826,4 +831,4 @@ const WizardStep2 = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep2
|
||||
export default WizardStepDataSettings
|
||||
|
|
@ -11,7 +11,7 @@ import {
|
|||
FaRocket,
|
||||
FaSpinner,
|
||||
} from 'react-icons/fa'
|
||||
import { WizardGroup } from './WizardStep3'
|
||||
import { WizardGroup } from './WizardStepFields'
|
||||
import { dbSourceTypeOptions, selectCommandTypeOptions } from '../edit/options'
|
||||
import { ListFormWizardDto, WizardComponentKindEnum } from '@/proxy/admin/wizard/models'
|
||||
import {
|
||||
|
|
@ -24,7 +24,7 @@ import { formatWorkflowApprovers } from '@/utils/workflow/workflowHelpers'
|
|||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WizardStep11Props {
|
||||
export interface WizardStepDeployProps {
|
||||
values: ListFormWizardDto
|
||||
wizardName: string
|
||||
selectedColumns: Set<string>
|
||||
|
|
@ -201,8 +201,13 @@ function Row({ label, value }: { label: string; value?: string | number }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── WizardStep11 ─────────────────────────────────────────────────────────────
|
||||
const WizardStep11 = ({
|
||||
// ─── WizardStepDeploy ─────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Deploy adımı: önceki adımlarda toplanan her şeyin özetini gösterir ve onay sonrası
|
||||
* wizard'ı sunucuya gönderir. Sunucu bu istekten menü, izin, dil, ListForm ve seed
|
||||
* dosyasını üretir; özet ekranında bağlantı cümlesi maskelenerek gösterilir.
|
||||
*/
|
||||
const WizardStepDeploy = ({
|
||||
values,
|
||||
wizardName,
|
||||
selectedColumns,
|
||||
|
|
@ -215,7 +220,7 @@ const WizardStep11 = ({
|
|||
translate,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: WizardStep11Props) => {
|
||||
}: WizardStepDeployProps) => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const [isDone, setIsDone] = useState(false)
|
||||
|
|
@ -829,4 +834,4 @@ const WizardStep11 = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep11
|
||||
export default WizardStepDeploy
|
||||
|
|
@ -62,7 +62,7 @@ export interface WizardGroup {
|
|||
items: WizardGroupItem[]
|
||||
}
|
||||
|
||||
export interface WizardStep3Props {
|
||||
export interface WizardStepFieldsProps {
|
||||
selectedColumns: Set<string>
|
||||
selectCommandColumns: DatabaseColumnDto[]
|
||||
groups: WizardGroup[]
|
||||
|
|
@ -641,9 +641,7 @@ function SortableItem({
|
|||
{/* Bottom row: ColSpan + Editing Form + Required */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{translate('::App.WizardStep3.Span')}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">{translate('::App.WizardStep3.Span')}</span>
|
||||
<select
|
||||
value={item.colSpan}
|
||||
onChange={(e) => onColSpanChange(Number(e.target.value))}
|
||||
|
|
@ -739,9 +737,7 @@ function GroupCard({
|
|||
/>
|
||||
{/* ColCount */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className="text-xs text-gray-400">
|
||||
{translate('::App.WizardStep3.Cols')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{translate('::App.WizardStep3.Cols')}</span>
|
||||
{[1, 2, 3].map((n) => (
|
||||
<Button
|
||||
key={n}
|
||||
|
|
@ -839,8 +835,13 @@ function GroupCard({
|
|||
)
|
||||
}
|
||||
|
||||
// ─── WizardStep3 ──────────────────────────────────────────────────────────────
|
||||
const WizardStep3 = ({
|
||||
// ─── WizardStepFields ──────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Alan adımı: select komutundan gelen kolonların ListForm alanlarına dönüştürüldüğü yer.
|
||||
* Kolon grupları, editör tipi/seçenekleri, zorunluluk, lookup ve TR/EN başlıklar burada
|
||||
* düzenlenir; üretilen gruplar deploy'da ListFormField kayıtlarına çevrilir.
|
||||
*/
|
||||
const WizardStepFields = ({
|
||||
selectedColumns,
|
||||
selectCommandColumns,
|
||||
groups,
|
||||
|
|
@ -850,7 +851,7 @@ const WizardStep3 = ({
|
|||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStep3Props) => {
|
||||
}: WizardStepFieldsProps) => {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [overGroupId, setOverGroupId] = useState<string | null>(null)
|
||||
const [isHelperOpen, setIsHelperOpen] = useState(false)
|
||||
|
|
@ -1266,4 +1267,4 @@ const WizardStep3 = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep3
|
||||
export default WizardStepFields
|
||||
|
|
@ -7,14 +7,24 @@ import { useMemo } from 'react'
|
|||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
||||
import { gantScaleOptions } from '../edit/options'
|
||||
|
||||
interface WizardStep9Props {
|
||||
interface WizardStepGanttLayoutProps {
|
||||
selectCommandColumns: DatabaseColumnDto[]
|
||||
translate: (key: string) => string
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const WizardStep9 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep9Props) => {
|
||||
/**
|
||||
* Gantt layout adımı: görev başlığı, başlangıç/bitiş tarihi, ilerleme ve hiyerarşi
|
||||
* (anahtar / üst anahtar) kolonlarının eşleştirilmesi ile ölçek ve genişletme davranışı.
|
||||
* Yalnızca Gantt layout'u seçiliyken gösterilir.
|
||||
*/
|
||||
const WizardStepGanttLayout = ({
|
||||
selectCommandColumns,
|
||||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStepGanttLayoutProps) => {
|
||||
const { values } = useFormikContext<ListFormWizardDto>()
|
||||
const fieldOptions = useMemo<SelectBoxOption[]>(
|
||||
() =>
|
||||
|
|
@ -125,4 +135,4 @@ const WizardStep9 = ({ selectCommandColumns, translate, onBack, onNext }: Wizard
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep9
|
||||
export default WizardStepGanttLayout
|
||||
|
|
@ -352,9 +352,9 @@ function MenuTreeInline({
|
|||
)
|
||||
}
|
||||
|
||||
// ─── WizardStep1 ──────────────────────────────────────────────────────────────
|
||||
// ─── WizardStepMenu ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface WizardStep1Props {
|
||||
export interface WizardStepMenuProps {
|
||||
values: ListFormWizardDto
|
||||
errors: FormikErrors<ListFormWizardDto>
|
||||
touched: FormikTouched<ListFormWizardDto>
|
||||
|
|
@ -383,7 +383,12 @@ export interface WizardStep1Props {
|
|||
translate: (key: string) => string
|
||||
}
|
||||
|
||||
const WizardStep1 = ({
|
||||
/**
|
||||
* Menü adımı: wizard adı, menü kodu/sırası, üst menü, ikon ve izin grubu burada belirlenir.
|
||||
* Menü ağacını kurup ad/kod çakışmalarını adım geçilmeden önce doğrular; ürettiği menü
|
||||
* kaydı hem List hem Custom yolunda deploy'un çıktısıdır.
|
||||
*/
|
||||
const WizardStepMenu = ({
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
|
|
@ -402,7 +407,7 @@ const WizardStep1 = ({
|
|||
onBack,
|
||||
onNext,
|
||||
translate,
|
||||
}: WizardStep1Props) => {
|
||||
}: WizardStepMenuProps) => {
|
||||
const [menuDialogOpen, setMenuDialogOpen] = useState(false)
|
||||
const [menuDialogParentCode, setMenuDialogParentCode] = useState('')
|
||||
// Dolu ise dialog düzenleme modunda açılır.
|
||||
|
|
@ -745,4 +750,4 @@ const WizardStep1 = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep1
|
||||
export default WizardStepMenu
|
||||
|
|
@ -7,14 +7,23 @@ import { useMemo } from 'react'
|
|||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
||||
import { firstDayOfWeekOptions, schedulerViewOptions } from '../edit/options'
|
||||
|
||||
interface WizardStep10Props {
|
||||
interface WizardStepSchedulerLayoutProps {
|
||||
selectCommandColumns: DatabaseColumnDto[]
|
||||
translate: (key: string) => string
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const WizardStep10 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep10Props) => {
|
||||
/**
|
||||
* Takvim layout adımı: randevu başlığı, başlangıç/bitiş ve tüm gün gibi Scheduler
|
||||
* görünümünün kolon eşleşmeleri. Yalnızca Scheduler layout'u seçiliyken gösterilir.
|
||||
*/
|
||||
const WizardStepSchedulerLayout = ({
|
||||
selectCommandColumns,
|
||||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStepSchedulerLayoutProps) => {
|
||||
const { values } = useFormikContext<ListFormWizardDto>()
|
||||
const fieldOptions = useMemo<SelectBoxOption[]>(
|
||||
() =>
|
||||
|
|
@ -160,4 +169,4 @@ const WizardStep10 = ({ selectCommandColumns, translate, onBack, onNext }: Wizar
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep10
|
||||
export default WizardStepSchedulerLayout
|
||||
|
|
@ -51,7 +51,12 @@ const createEmptySubForm = (): SubFormDto => ({
|
|||
searchParams: new URLSearchParams(),
|
||||
})
|
||||
|
||||
function WizardStep4({
|
||||
/**
|
||||
* Alt form adımı: ana kayda bağlı sekmelerde açılacak ListForm'ların (master-detail)
|
||||
* tanımlandığı yer. Her satır bir sekme başlığı, bağlanacak ListForm kodu ve yenileme
|
||||
* davranışı taşır.
|
||||
*/
|
||||
function WizardStepSubForms({
|
||||
subForms,
|
||||
selectCommandColumns,
|
||||
translate,
|
||||
|
|
@ -481,4 +486,4 @@ function WizardStep4({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep4
|
||||
export default WizardStepSubForms
|
||||
|
|
@ -7,7 +7,7 @@ import { Field, type FieldProps, useFormikContext } from 'formik'
|
|||
import { useMemo, useState } from 'react'
|
||||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
||||
|
||||
interface WizardStep7Props {
|
||||
interface WizardStepTodoLayoutProps {
|
||||
selectedColumns: Set<string>
|
||||
selectCommandColumns: DatabaseColumnDto[]
|
||||
translate: (key: string) => string
|
||||
|
|
@ -54,13 +54,17 @@ const mappings: Array<{
|
|||
{ name: 'orderExpr', translationKey: '::App.ListFormFieldEdit.TodoOrderExpr' },
|
||||
]
|
||||
|
||||
const WizardStep7 = ({
|
||||
/**
|
||||
* Todo / Kanban layout adımı: kart başlığı, durum kolonu, sıra alanı gibi Kanban görünümünün
|
||||
* ihtiyaç duyduğu kolon eşleşmeleri. Yalnızca Todo layout'u seçiliyken gösterilir.
|
||||
*/
|
||||
const WizardStepTodoLayout = ({
|
||||
selectedColumns,
|
||||
selectCommandColumns,
|
||||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStep7Props) => {
|
||||
}: WizardStepTodoLayoutProps) => {
|
||||
const { values, setFieldValue } = useFormikContext<ListFormWizardDto>()
|
||||
const [validationError, setValidationError] = useState('')
|
||||
const fieldOptions = useMemo<SelectBoxOption[]>(
|
||||
|
|
@ -160,4 +164,4 @@ const WizardStep7 = ({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep7
|
||||
export default WizardStepTodoLayout
|
||||
|
|
@ -6,14 +6,23 @@ import { Field, type FieldProps, useFormikContext } from 'formik'
|
|||
import { useMemo } from 'react'
|
||||
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa'
|
||||
|
||||
interface WizardStep8Props {
|
||||
interface WizardStepTreeLayoutProps {
|
||||
selectCommandColumns: DatabaseColumnDto[]
|
||||
translate: (key: string) => string
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
const WizardStep8 = ({ selectCommandColumns, translate, onBack, onNext }: WizardStep8Props) => {
|
||||
/**
|
||||
* Ağaç layout adımı: kayıtları hiyerarşik gösterebilmek için anahtar ve üst anahtar
|
||||
* kolonlarının eşleştirilmesi. Yalnızca Tree layout'u seçiliyken gösterilir.
|
||||
*/
|
||||
const WizardStepTreeLayout = ({
|
||||
selectCommandColumns,
|
||||
translate,
|
||||
onBack,
|
||||
onNext,
|
||||
}: WizardStepTreeLayoutProps) => {
|
||||
const { values } = useFormikContext<ListFormWizardDto>()
|
||||
const fieldOptions = useMemo<SelectBoxOption[]>(
|
||||
() =>
|
||||
|
|
@ -85,4 +94,4 @@ const WizardStep8 = ({ selectCommandColumns, translate, onBack, onNext }: Wizard
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep8
|
||||
export default WizardStepTreeLayout
|
||||
|
|
@ -54,7 +54,11 @@ const emptyWidget: WidgetEditDto = {
|
|||
isActive: true,
|
||||
}
|
||||
|
||||
function WizardStep5({ widgets, translate, onChange, onBack, onNext }: Props) {
|
||||
/**
|
||||
* Widget adımı: listenin üstünde gösterilecek özet kartlarının (SQL sorgusu, kolon genişliği,
|
||||
* stil sınıfı) tanımlandığı yer.
|
||||
*/
|
||||
function WizardStepWidgets({ widgets, translate, onChange, onBack, onNext }: Props) {
|
||||
const [dialogIndex, setDialogIndex] = useState<number | null>(null)
|
||||
const [deleteIndex, setDeleteIndex] = useState<number | null>(null)
|
||||
|
||||
|
|
@ -352,4 +356,4 @@ function WizardStep5({ widgets, translate, onChange, onBack, onNext }: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep5
|
||||
export default WizardStepWidgets
|
||||
|
|
@ -49,7 +49,11 @@ const toDesignerCriteria = (items: ListFormWorkflowCriteriaDto[]): WorkflowCrite
|
|||
const toWizardCriteria = (items: WorkflowCriteriaDto[]): ListFormWorkflowCriteriaDto[] =>
|
||||
items.map(({ ...item }) => item)
|
||||
|
||||
function WizardStep6({
|
||||
/**
|
||||
* Onay akışı adımı: kaydın hangi durumlarda hangi role/kullanıcıya düşeceğini belirleyen
|
||||
* workflow düğümleri ve kriterleri burada kurgulanır.
|
||||
*/
|
||||
function WizardStepWorkflow({
|
||||
listFormCode,
|
||||
workflow,
|
||||
criteria,
|
||||
|
|
@ -302,18 +306,12 @@ function WizardStep6({
|
|||
<FormContainer>
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
{[
|
||||
[
|
||||
'approvalUserFieldName',
|
||||
'::App.ListFormEditWorkflow.ApprovalUserFieldName',
|
||||
],
|
||||
['approvalUserFieldName', '::App.ListFormEditWorkflow.ApprovalUserFieldName'],
|
||||
[
|
||||
'approvalStatusFieldName',
|
||||
'::App.ListFormEditWorkflow.ApprovalStatusFieldName',
|
||||
],
|
||||
[
|
||||
'approvalDateFieldName',
|
||||
'::App.ListFormEditWorkflow.ApprovalDateFieldName',
|
||||
],
|
||||
['approvalDateFieldName', '::App.ListFormEditWorkflow.ApprovalDateFieldName'],
|
||||
[
|
||||
'approvalDescriptionFieldName',
|
||||
'::App.ListFormEditWorkflow.ApprovalDescriptionFieldName',
|
||||
|
|
@ -360,9 +358,7 @@ function WizardStep6({
|
|||
)
|
||||
})}
|
||||
<FormItem
|
||||
label={translate(
|
||||
'::App.ListFormEditWorkflow.ApprovalIsFilterUserName',
|
||||
)}
|
||||
label={translate('::App.ListFormEditWorkflow.ApprovalIsFilterUserName')}
|
||||
>
|
||||
<Field name="approvalIsFilterUserName">
|
||||
{({ field, form }: FieldProps<boolean>) => (
|
||||
|
|
@ -489,4 +485,4 @@ function WizardStep6({
|
|||
)
|
||||
}
|
||||
|
||||
export default WizardStep6
|
||||
export default WizardStepWorkflow
|
||||
|
|
@ -5,6 +5,8 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
|
|||
import { Formik, Form, Field } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { Button, Checkbox, FormContainer, FormItem, Input } from '@/components/ui'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
|
||||
import Dialog from '@/components/ui/Dialog'
|
||||
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
|
||||
import {
|
||||
|
|
@ -42,6 +44,12 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
|
|||
|
||||
const isEditing = !!id
|
||||
|
||||
// Kaydetme hakki moda gore degisir: mevcut kayit guncelleme, yeni kayit olusturma yetkisi ister.
|
||||
const { checkPermission } = usePermission()
|
||||
const canSave = checkPermission(
|
||||
isEditing ? COMPONENT_PERMISSION.UPDATE : COMPONENT_PERMISSION.CREATE,
|
||||
)
|
||||
|
||||
// Initial values for Formik
|
||||
const [initialValues, setInitialValues] = useState({
|
||||
name: '',
|
||||
|
|
@ -270,7 +278,7 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
|
|||
type="button"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !values.name.trim() || !isValid}
|
||||
disabled={!canSave || isSubmitting || !values.name.trim() || !isValid}
|
||||
className="flex items-center gap-2"
|
||||
onClick={submitForm}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { Button } from '@/components/ui'
|
|||
import Dialog from '@/components/ui/Dialog'
|
||||
import ComponentEditor from './ComponentEditor'
|
||||
import { parseComponentDependencies } from '@/contexts/componentRuntime'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
|
||||
|
||||
const ComponentManager: React.FC = () => {
|
||||
const {
|
||||
|
|
@ -42,6 +44,10 @@ const ComponentManager: React.FC = () => {
|
|||
const activeComponents = components?.filter((c) => c.isActive).length || 0
|
||||
const inactiveComponents = totalComponents - activeComponents
|
||||
const { translate } = useLocalization()
|
||||
const { checkPermission } = usePermission()
|
||||
const canCreate = checkPermission(COMPONENT_PERMISSION.CREATE)
|
||||
const canUpdate = checkPermission(COMPONENT_PERMISSION.UPDATE)
|
||||
const canDelete = checkPermission(COMPONENT_PERMISSION.DELETE)
|
||||
|
||||
useEffect(() => {
|
||||
void refreshComponents()
|
||||
|
|
@ -159,17 +165,19 @@ const ComponentManager: React.FC = () => {
|
|||
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
|
||||
{translate('::App.Platform.Refresh')}
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setEditorComponentId(null)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
{translate('::App.DeveloperKitComponent.New')}
|
||||
</Button>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => setEditorComponentId(null)}
|
||||
>
|
||||
<FaPlus className="w-4 h-4" />
|
||||
{translate('::App.DeveloperKitComponent.New')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Components List */}
|
||||
|
|
@ -244,6 +252,7 @@ const ComponentManager: React.FC = () => {
|
|||
type="button"
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
disabled={!canUpdate}
|
||||
className={`!inline-flex !h-auto items-center gap-1 rounded !px-2 py-1 text-xs font-medium transition-colors`}
|
||||
onClick={() => handleToggleActive(component.id, !component.isActive)}
|
||||
>
|
||||
|
|
@ -261,23 +270,25 @@ const ComponentManager: React.FC = () => {
|
|||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="blue-600"
|
||||
title={translate('::App.Platform.Edit')}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
ROUTES_ENUM.protected.saas.developerKit.componentsEdit.replace(
|
||||
':id',
|
||||
component.id,
|
||||
),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaRegEdit className="w-4 h-4" />
|
||||
</Button>
|
||||
{canUpdate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="blue-600"
|
||||
title={translate('::App.Platform.Edit')}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
ROUTES_ENUM.protected.saas.developerKit.componentsEdit.replace(
|
||||
':id',
|
||||
component.id,
|
||||
),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
>
|
||||
<FaRegEdit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
|
|
@ -302,15 +313,17 @@ const ComponentManager: React.FC = () => {
|
|||
>
|
||||
<FaCog className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="red-600"
|
||||
title={translate('::App.Platform.Delete')}
|
||||
onClick={() => handleDelete(component.id)}
|
||||
>
|
||||
<FaTrashAlt className="w-4 h-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="red-600"
|
||||
title={translate('::App.Platform.Delete')}
|
||||
onClick={() => handleDelete(component.id)}
|
||||
>
|
||||
<FaTrashAlt className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -333,7 +346,7 @@ const ComponentManager: React.FC = () => {
|
|||
? translate('::App.DeveloperKit.EmptyFilteredDescription')
|
||||
: translate('::App.EmptyInitial.InitialDescription')}
|
||||
</p>
|
||||
{!searchTerm && filterActive === 'all' && (
|
||||
{canCreate && !searchTerm && filterActive === 'all' && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
MenuTreeNode,
|
||||
buildMenuTree,
|
||||
filterNonLinkNodes,
|
||||
} from '@/views/admin/listForm/wizard/WizardStep1'
|
||||
} from '@/views/admin/listForm/wizard/WizardStepMenu'
|
||||
import { MenuAddDialog } from '../shared/MenuAddDialog'
|
||||
import { useStoreActions, useStoreState } from '@/store'
|
||||
import { usePermission } from '@/utils/hooks/usePermission'
|
||||
|
|
|
|||
Loading…
Reference in a new issue