diff --git a/.gitignore b/.gitignore index 7eea4de7..c2cafb96 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ logs/ # Deploy sırasında git tag'lerinden üretilir (configs/change-logs/generate-change-logs.sh) # 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 \ No newline at end of file +configs/change-logs/change-logs.json +# Wizard import staging klasoru (gecici; oturum bitince silinir) +configs/seeds/.imports/ diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs index 8fee504d..1452eec0 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/IListFormWizardAppService.cs @@ -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 /// Wizard dosyasini ve bagli seed dosyalarini zip olarak dondurur. Task ExportFile(string fileName); -} + /// + /// Export zip'ini staging klasorune acar ve her dosya icin hedefteki karsiligiyla + /// karsilastirma sonucunu dondurur. Bu adim hicbir hedef dosyaya dokunmaz. + /// + Task AnalyzeImport(IRemoteStreamContent file); + + /// Analizde donen tek bir dosyayi hedefe yazar; onceki icerik rollback icin yedeklenir. + Task ApplyImport(WizardImportApplyInput input); + + /// Import oturumunu basariyla kapatir ve staging/yedek klasorunu siler. + Task CompleteImport(string importId); + + /// O ana kadar yazilan dosyalari geri alir ve staging klasorunu siler. + Task RollbackImport(string importId); +} diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs index 04558a4f..6c515f41 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs @@ -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 /// public WizardComponentKindEnum ComponentKind { get; set; } = WizardComponentKindEnum.List; + /// + /// 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. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string EditFileName { get; set; } + public string WizardName { get; set; } public string ListFormCode { get; set; } public string MenuCode { get; set; } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs new file mode 100644 index 00000000..b43c3553 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/WizardImportDtos.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Sozsoft.Platform.Enums; + +namespace Sozsoft.Platform.ListForms; + +/// +/// Import edilen zip icindeki tek bir seed dosyasinin analiz sonucu. +/// Yol her zaman kapsam klasorune (host / tenants/{tenantId}) goreli tutulur. +/// +public class WizardImportEntryDto +{ + /// Kapsam klasorune goreli yol, ornegin custom/Orders.json. + public string Path { get; set; } + + /// Yolun ilk segmenti; listede gruplama icin kullanilir (wizard, crud, custom, sql, postgres). + 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; } + + /// Zip'ten gelen icerik; yalnizca Conflict durumunda doldurulur. + public string IncomingContent { get; set; } + + /// Hedefteki mevcut icerik; yalnizca Conflict durumunda doldurulur. + public string ExistingContent { get; set; } +} + +/// Zip analizinin tamami; uygulama adimi bu ile yurutulur. +public class WizardImportAnalysisDto +{ + /// Staging klasorunu ve rollback kaydini tanimlayan oturum kimligi. + public string ImportId { get; set; } + + /// Dosyalarin yazilacagi kapsam klasoru (host ya da tenants/{tenantId}). + public string ScopeFolder { get; set; } + + public List Entries { get; set; } = []; + + /// Guvenlik/format nedeniyle atlanan zip girdileri icin aciklamalar. + public List Warnings { get; set; } = []; +} + +/// Tek bir dosyanin hedefe yazilmasi istegi. +public class WizardImportApplyInput +{ + public string ImportId { get; set; } + + /// Analizde donen kapsam-goreli yol. + public string Path { get; set; } + + /// + /// Kullanicinin diff ekraninda birlestirdigi icerik. Bos birakilirsa zip'ten gelen + /// icerik oldugu gibi yazilir. + /// + public string Content { get; set; } +} + +/// Tek dosya yazma sonucu; ilerleme gostergesinde satir olarak gosterilir. +public class WizardImportApplyResultDto +{ + public string Path { get; set; } + + /// Dosya hedefte zaten varken uzerine yazildiysa true. + public bool Overwritten { get; set; } + + public long Size { get; set; } +} diff --git a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs index 67b81f7b..865f7fe1 100644 --- a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs +++ b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs @@ -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; +/// +/// Custom component yonetimi. Sinif seviyesindeki yalnizca +/// kimlik dogrulamasi ister; ekrani gorme ve CRUD haklari asagida ABP policy adlarina baglanir. +/// App.DeveloperKit.Components okuma, .Create/.Update/.Delete aksiyon +/// yetkileridir. calisma zamani okuma yoludur ve yalnizca +/// oturum acmis olmayi ister. +/// [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 CreateAsync(CreateUpdateCustomComponentDto input) diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index 354888d4..f5eb6a25 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -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; /// /// Wizard, global menu/permission/language kayitlari ve seed dosyalari uretir. /// Bu nedenle sadece host tarafinda tanimli olan App.Listforms.Wizard yetkisi ile calisir. +/// +/// Sinif seviyesindeki yetki ekrani gorme (okuma) hakkidir; aksiyonlar ayrica kendi alt +/// yetkilerini ister: .Create, .Update, .Delete, .Export, .Import. +/// /// [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); + } + + /// + /// Seed dosyasini ve wizard'in urettigi kayitlari siler. Yetki kontrolu cagiran uca aittir: + /// silme yolunda Delete, duzenleme yolunda Update politikasi kullanilir. + /// + private async Task DeleteFileInternalAsync(string fileName) { var filePath = ResolveWizardSeedFilePath(fileName); @@ -906,6 +937,7 @@ public class ListFormWizardAppService( /// {sql|postgres}/{object|execute}/{nesne}.sql ve varsa crud/{nesne}.json. /// /// + [Authorize(AppCodes.Listforms.Wizards.Export)] [HttpGet("api/app/list-form-wizard/export")] public async Task 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. + + /// Staging oturumlarinin tutuldugu klasor; seed kokunun altinda nokta ile baslar, seeder taramalarina girmez. + 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; + + /// Kapsam klasorunde import edilmesine izin verilen kok klasorler. + private static readonly string[] ImportAllowedRootFolders = + [ + SeedPathResolver.WizardFolder, + SeedPathResolver.CrudFolder, + SeedPathResolver.CustomFolder, + SeedPathResolver.SqlFolder, + SeedPathResolver.PostgresFolder, + ]; + + /// SQL saglayici klasorlerinin altinda izin verilen script klasorleri. + 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 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(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 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); + } + + /// + /// Zip girdisini kapsam klasorune goreli yola cevirir. Zip, kapsam klasorunun icinden + /// (wizard/x.json) ya da depo kokunden (configs/seeds/host/wizard/x.json) + /// alinmis olabilir; her iki durumda da izin verilen ilk kok klasorden itibaren okunur. + /// Kural disi bir yol icin null doner. + /// + 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); + } + + /// + /// Karsilastirmayi satir sonu ve BOM farklarindan bagimsiz hale getirir; ayni icerigin + /// farkli platformlarda uretilmis hali catisma olarak gorunmez. + /// + 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); + } + } + + /// Yarim kalan (tarayici kapatilmis) oturumlarin klasorlerini temizler. + 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."); + } + } + /// /// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin /// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 0782cd25..634a2e66 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json @@ -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ı" } ] } diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionsData.json index 2b23359d..58e234de 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/PermissionsData.json @@ -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", diff --git a/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportEntryStatusEnum.cs b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportEntryStatusEnum.cs new file mode 100644 index 00000000..d3bfa0a6 --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain.Shared/Enums/WizardImportEntryStatusEnum.cs @@ -0,0 +1,14 @@ +namespace Sozsoft.Platform.Enums; + +/// Import edilen zip girdisinin hedef kapsam klasorundeki dosyayla karsilastirma sonucu. +public enum WizardImportEntryStatusEnum +{ + /// Hedefte ayni yolda dosya yok; dogrudan kopyalanir. + New = 0, + + /// Hedefteki dosya ile icerik ayni; kullaniciya sorulmaz, islem yapilmaz. + Identical = 1, + + /// Hedefte farkli icerikli bir dosya var; kullanici karsilastirip karar vermelidir. + Conflict = 2, +} diff --git a/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs b/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs index cc39205f..5d39273a 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/PlatformConsts.cs @@ -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"; + + /// + /// Wizard dosya yoneticisinin aksiyon yetkileri. Hepsi altindadir; + /// ekrani gorme (okuma) yetkisidir. + /// + 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"; + /// + /// Custom component yoneticisinin yetkileri. ekrani gorme + /// (okuma) yetkisidir; aksiyonlar onun altindaki alt yetkileri ister. + /// + 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"; diff --git a/configs/seeds/host/wizard/20260818220914_RoleList.json b/configs/seeds/host/wizard/20260818220914_RoleList.json new file mode 100644 index 00000000..287e5e44 --- /dev/null +++ b/configs/seeds/host/wizard/20260818220914_RoleList.json @@ -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": [] + } +} \ No newline at end of file diff --git a/ui/src/constants/permission.constant.ts b/ui/src/constants/permission.constant.ts index a5acc93c..317232e1 100644 --- a/ui/src/constants/permission.constant.ts +++ b/ui/src/constants/permission.constant.ts @@ -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 diff --git a/ui/src/proxy/admin/wizard/models.ts b/ui/src/proxy/admin/wizard/models.ts index c2a76818..178246d6 100644 --- a/ui/src/proxy/admin/wizard/models.ts +++ b/ui/src/proxy/admin/wizard/models.ts @@ -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 +} diff --git a/ui/src/services/wizard.service.ts b/ui/src/services/wizard.service.ts index daac2eca..5a28157e 100644 --- a/ui/src/services/wizard.service.ts +++ b/ui/src/services/wizard.service.ts @@ -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) => @@ -51,4 +57,46 @@ export const downloadBlob = (blob: Blob, fileName: string) => { link.click() document.body.removeChild(link) window.URL.revokeObjectURL(url) -} \ No newline at end of file +} + +/** + * 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({ + 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({ + 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 }, + }) diff --git a/ui/src/views/admin/listForm/wizard/Wizard.tsx b/ui/src/views/admin/listForm/wizard/Wizard.tsx index 09b007e9..88d8354a 100644 --- a/ui/src/views/admin/listForm/wizard/Wizard.tsx +++ b/ui/src/views/admin/listForm/wizard/Wizard.tsx @@ -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 && ( - { {/* ─── Step 4: Data Settings ───────────────────────────── */} {resolvedStep === STEP.settings && ( - { {/* ─── Step 5: List Form Fields ───────────────────────────── */} {resolvedStep === STEP.fields && ( - { {/* ─── Step 6: Sub Forms ───────────────────────────── */} {resolvedStep === STEP.subForms && ( - { {/* ─── Step 7: Widgets ───────────────────────────── */} {resolvedStep === STEP.widgets && ( - { {/* ─── Step 8: Workflow ───────────────────────────── */} {resolvedStep === STEP.workflow && ( - { {/* ─── Step 9: Todo / Kanban ────────────────────── */} {resolvedStep === STEP.todo && values.todo && ( - { {/* ─── Step 10: Tree ─────────────────────────────── */} {resolvedStep === STEP.tree && values.tree && ( - goToAdjacentStep(-1)} @@ -1314,7 +1310,7 @@ const Wizard = () => { {/* ─── Step 11: Gantt ────────────────────────────── */} {resolvedStep === STEP.gantt && values.gantt && ( - goToAdjacentStep(-1)} @@ -1324,7 +1320,7 @@ const Wizard = () => { {/* ─── Step 12: Scheduler ───────────────────────── */} {resolvedStep === STEP.scheduler && values.scheduler && ( - goToAdjacentStep(-1)} @@ -1334,7 +1330,7 @@ const Wizard = () => { {/* ─── Step 13: Deploy ──────────────────────────── */} {resolvedStep === STEP.deploy && ( - { const [exportingFile, setExportingFile] = useState(null) const [confirm, setConfirm] = useState(null) const [search, setSearch] = useState('') + const [importFile, setImportFile] = useState(null) + const importInputRef = useRef(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) => { + 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 = () => { > + {canImport && ( + <> + + + + )} - + {canCreate && ( + + )} @@ -280,44 +328,50 @@ const WizardFileManager = () => { )} - - - + {canExport && ( + + )} + {canUpdate && ( + + )} + {canDelete && ( + + )} ) @@ -325,6 +379,14 @@ const WizardFileManager = () => { + {importFile && ( + setImportFile(null)} + onImported={loadFiles} + /> + )} + {/* Delete Confirm Dialog */} 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.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('analyzing') + const [analysis, setAnalysis] = useState(null) + const [error, setError] = useState(null) + const [decisions, setDecisions] = useState>({}) + const [selectedPath, setSelectedPath] = useState(null) + const [applied, setApplied] = useState([]) + const [currentPath, setCurrentPath] = useState(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('') + + 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 = {} + 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 ( + + {translate('::App.ListForm.WizardImportIdentical')} + + ) + + if (entry.status === WizardImportEntryStatusEnum.New) + return ( + + {translate('::App.ListForm.WizardImportNew')} + + ) + + if (!decision?.resolved) + return ( + + {translate('::App.ListForm.WizardImportConflict')} + + ) + + return ( + + {translate( + decision.apply + ? '::App.ListForm.WizardImportResolvedApply' + : '::App.ListForm.WizardImportResolvedSkip', + )} + + ) + } + + const renderReview = () => ( +
+ {/* Dosya listesi */} +
+
+ {analysis?.scopeFolder} + + {applyList.length}/{entries.length} + +
+
+ {visibleEntries.map((entry) => { + const decision = decisions[entry.path] + const isConflict = entry.status === WizardImportEntryStatusEnum.Conflict + return ( +
setSelectedPath(entry.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setSelectedPath(entry.path) + }} + > + {isConflict ? ( + + ) : entry.status === WizardImportEntryStatusEnum.New ? ( + + ) : ( + + )} + + + {entry.fileName} + + + {entry.path} + + + {renderStatusBadge(entry)} + {!isConflict && ( + e.stopPropagation()} + onChange={(e) => + decide(entry.path, { + apply: e.target.checked, + resolved: true, + }) + } + /> + )} +
+ ) + })} +
+ {identicalCount > 0 && ( + + )} +
+ + {/* Karsilastirma / detay */} +
+ {selectedEntry && selectedEntry.status === WizardImportEntryStatusEnum.Conflict ? ( + <> +
+ + + {selectedEntry.path} + + + {formatSize(selectedEntry.existingSize)} → {formatSize(selectedEntry.incomingSize)} + +
+ + + +
+
+
+ {translate('::App.ListForm.WizardImportExistingSide')} + {translate('::App.ListForm.WizardImportIncomingSide')} +
+
+ { + const modified = editor.getModifiedEditor() + draftRef.current = modified.getValue() + modified.onDidChangeModelContent(() => { + draftRef.current = modified.getValue() + }) + }} + /> +
+ + ) : ( +
+ + {unresolvedCount > 0 ? ( + + {unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')} + + ) : ( + {translate('::App.ListForm.WizardImportReady')} + )} + {analysis && analysis.warnings.length > 0 && ( +
+
+ + {translate('::App.ListForm.WizardImportWarnings')} +
+
    + {analysis.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} +
+ )} +
+
+ ) + + const renderProgress = () => ( +
+ + {currentPath && ( +
+ {translate('::App.ListForm.WizardImportWriting')}: {currentPath} +
+ )} +
+ {applied.map((row) => ( +
+ {row.ok ? ( + + ) : ( + + )} + + {row.path} + + + {row.ok + ? translate( + row.overwritten + ? '::App.ListForm.WizardImportOverwritten' + : '::App.ListForm.WizardImportCreated', + ) + : row.message} + +
+ ))} +
+ {phase === 'rolledBack' && ( +
+
+ + {translate('::App.ListForm.WizardImportRolledBack')} +
+ {error &&
{error}
} +
+ )} + {phase === 'finished' && ( +
+
+ + {translate('::App.ListForm.WizardImportSuccess')} +
+
{translate('::App.ListForm.WizardImportRunMigrateHint')}
+
+ )} +
+ ) + + return ( + + +
+ +
{translate('::App.ListForm.WizardImportTitle')}
+ {file?.name} + {phase === 'review' && analysis && ( +
+ + {entries.filter((e) => e.status === WizardImportEntryStatusEnum.New).length}{' '} + {translate('::App.ListForm.WizardImportNew')} + + + {conflicts.length} {translate('::App.ListForm.WizardImportConflict')} + + + {entries.filter((e) => e.status === WizardImportEntryStatusEnum.Identical).length}{' '} + {translate('::App.ListForm.WizardImportIdentical')} + +
+ )} +
+ + {phase === 'analyzing' && ( +
+ + {translate('::App.ListForm.WizardImportAnalyzing')} + +
+ )} + + {phase === 'review' && error && ( +
+ + {error} +
+ )} + + {phase === 'review' && !error && analysis && renderReview()} + + {(phase === 'running' || phase === 'finished' || phase === 'rolledBack') && + renderProgress()} +
+ + + {phase === 'review' && !error && unresolvedCount > 0 && ( + + {unresolvedCount} {translate('::App.ListForm.WizardImportUnresolved')} + + )} + + {phase === 'review' && !error && ( + + )} + +
+ ) +} + +export default WizardImportDialog diff --git a/ui/src/views/admin/listForm/wizard/WizardStepCustomComponent.tsx b/ui/src/views/admin/listForm/wizard/WizardStepCustomComponent.tsx index 3048df39..56d190d9 100644 --- a/ui/src/views/admin/listForm/wizard/WizardStepCustomComponent.tsx +++ b/ui/src/views/admin/listForm/wizard/WizardStepCustomComponent.tsx @@ -73,6 +73,7 @@ const WizardStepCustomComponent = ({ onColSpanChange(Number(e.target.value))} @@ -739,9 +737,7 @@ function GroupCard({ /> {/* ColCount */}
- - {translate('::App.WizardStep3.Cols')} - + {translate('::App.WizardStep3.Cols')} {[1, 2, 3].map((n) => ( -
- -
+ {canCreate && ( +
+ +
+ )}
{/* 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 = () => {
- + {canUpdate && ( + + )} - + {canDelete && ( + + )}
@@ -333,7 +346,7 @@ const ComponentManager: React.FC = () => { ? translate('::App.DeveloperKit.EmptyFilteredDescription') : translate('::App.EmptyInitial.InitialDescription')}

- {!searchTerm && filterActive === 'all' && ( + {canCreate && !searchTerm && filterActive === 'all' && (