diff --git a/README.md b/README.md index 53325ddf..643b0713 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,8 @@ Aktif veritabanı sağlayıcısına göre `sql/` (SQL Server) ya da `postgres/` | `{sql\|postgres}/object` | Seed sırasında nesneyi oluşturur/günceller. SqlTableDesigner ve SqlQueryManager, deploy ettiği script'i çalıştığı kapsamın bu klasörüne yazar (host bağlamı → `host/`, tenant seçiliyken → `tenants/{tenantId}/`). | | `{sql\|postgres}/execute` | Nesneyi oluşturur; ayrıca tüm migration ve seed'ler bittikten sonra `AfterAllMigrationsSqlExecutor` dosya adından türettiği procedure'ü çalıştırır (dosya adı = procedure adı, `schema.Procedure.sql` de olur). | | `wizard` | ListForm Wizard'ın ürettiği `.json` konfigürasyonları; `WizardDataSeeder` kendi kapsamındaki dosyaları okur. | -| `crud` / `custom` | CrudEndpoint ve CustomEndpoint konfigürasyonları için ayrılmıştır. | +| `crud` | CrudEndpoint konfigürasyonları; `CrudDataSeeder` kendi kapsamındaki dosyaları okur. | +| `custom` | Custom Component konfigürasyonları (`{Name}.json`); Component Manager kaydetme/silme sırasında dosyayı senkronlar, `CustomComponentDataSeeder` kendi kapsamındaki dosyaları okuyup veritabanına uygular. | Klasör kökünde kalmış eski `.sql` dosyaları geriye dönük uyumluluk için hâlâ işlenir, fakat `SqlDataSeeder` bunları uyarı ile loglar — ilgili klasöre taşınmaları beklenir. diff --git a/api/src/Sozsoft.Platform.Application.Contracts/DeveloperKit/CustomComponentDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/DeveloperKit/CustomComponentDto.cs index c691584e..4bb5a004 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/DeveloperKit/CustomComponentDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/DeveloperKit/CustomComponentDto.cs @@ -13,6 +13,12 @@ public class CustomComponentDto : FullAuditedEntityDto public string? Description { get; set; } public bool IsActive { get; set; } = true; public string? Dependencies { get; set; } // JSON string of component names + + /// + /// Data sekmesinde kullanilan API'ler; JSON dizisi. Props icindeki designer dokumanindan + /// kaydetme sirasinda sunucu tarafinda uretilir, istemciden gonderilmez. + /// + public string? DataSources { get; set; } } public class CreateUpdateCustomComponentDto diff --git a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs index d7402803..67b81f7b 100644 --- a/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs +++ b/api/src/Sozsoft.Platform.Application/DeveloperKit/CustomComponentAppService.cs @@ -1,7 +1,14 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.IO; +using System.Text.Encodings.Web; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Sozsoft.Platform.Data.Seeds; using Sozsoft.Platform.DeveloperKit; using Sozsoft.Platform.Entities; using Volo.Abp.Application.Dtos; @@ -18,21 +25,72 @@ public class CustomComponentAppService : CrudAppService< PagedAndSortedResultRequestDto, CreateUpdateCustomComponentDto>, ICustomComponentAppService { - public CustomComponentAppService(IRepository repository) : base(repository) + private static readonly JsonSerializerOptions SeedJsonWriteOptions = new() { + WriteIndented = true, + // Component kodu HTML/JSX karakterleri icerir; kacislar dosyayi okunamaz hale getirir. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly IConfiguration _configuration; + private readonly IRepository _crudEndpointRepository; + + public CustomComponentAppService( + IRepository repository, + IRepository crudEndpointRepository, + IConfiguration configuration) : base(repository) + { + _crudEndpointRepository = crudEndpointRepository; + _configuration = configuration; + } + + public override async Task CreateAsync(CreateUpdateCustomComponentDto input) + { + var result = await base.CreateAsync(input); + await SyncDataSourcesAsync(result.Id); + await SyncSeedFileAsync(result.Id); + return result; } public override async Task UpdateAsync( Guid id, CreateUpdateCustomComponentDto input) { + var existingComponent = await Repository.GetAsync(id); + var previousName = existingComponent.Name; + if (input.Props is null) { - var existingComponent = await Repository.GetAsync(id); input.Props = existingComponent.Props; } - return await base.UpdateAsync(id, input); + var result = await base.UpdateAsync(id, input); + + // Ad degistiyse eski dosya artik hicbir component'i temsil etmez. + if (!string.Equals(previousName, result.Name, StringComparison.Ordinal)) + { + DeleteSeedFile(previousName); + } + + await SyncDataSourcesAsync(id); + await SyncSeedFileAsync(id); + return result; + } + + public override async Task DeleteAsync(Guid id) + { + var component = await Repository.FindAsync(id); + await base.DeleteAsync(id); + + if (component != null) + { + DeleteSeedFile(component.Name); + } } public async Task> GetActiveComponentsAsync() @@ -40,5 +98,203 @@ public class CustomComponentAppService : CrudAppService< var components = await Repository.GetListAsync(x => x.IsActive); return await MapToGetListOutputDtosAsync(components); } -} + /// + /// Seed dosyasini yazar; dosya islemi hatasi CRUD islemini engellemez, yalnizca loglanir. + /// + private async Task SyncSeedFileAsync(Guid id) + { + try + { + var component = await Repository.FindAsync(id); + if (component != null) + { + await WriteSeedFileAsync(component); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Custom component seed file could not be synchronized for {Id}: {Message}", id, ex.Message); + } + } + + private async Task WriteSeedFileAsync(CustomComponent component) + { + var safeFileName = GetSafeFileName(component.Name) + ?? throw new InvalidOperationException($"Invalid component name for a seed file: '{component.Name}'."); + + var outputPath = ResolveCustomSeedOutputPath(); + Directory.CreateDirectory(outputPath); + + var filePath = Path.Combine(outputPath, $"{safeFileName}.json"); + + var seedFile = new CustomComponentSeedFileDto + { + GeneratedAt = Clock.Now.ToUniversalTime(), + CustomComponents = + [ + new CustomComponentSeedItemDto + { + Name = component.Name, + RoutePath = component.RoutePath, + Code = component.Code, + Props = component.Props, + Description = component.Description, + IsActive = component.IsActive, + Dependencies = ParseDependencies(component.Dependencies), + DataSources = ParseDataSources(component.DataSources) + } + ] + }; + + await File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(seedFile, SeedJsonWriteOptions)); + Logger.LogInformation("Custom component seed file saved: {FilePath}", filePath); + + return filePath; + } + + private void DeleteSeedFile(string componentName) + { + try + { + var safeFileName = GetSafeFileName(componentName); + if (safeFileName == null) + { + return; + } + + var filePath = Path.Combine(ResolveCustomSeedOutputPath(), $"{safeFileName}.json"); + if (File.Exists(filePath)) + { + File.Delete(filePath); + Logger.LogInformation("Custom component seed file deleted: {FilePath}", filePath); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Custom component seed file could not be deleted for {Name}: {Message}", componentName, ex.Message); + } + } + + /// + /// Designer dokumanindaki (Props) Data sekmesi endpoint'lerini cozer, her birini CRUD + /// endpoint kataloguyla eslestirir ve sonucu entity'nin DataSources sutununa yazar. + /// Boylece component'in hangi crud/{EntityName}.json seed dosyalarina bagli oldugu + /// dokuman ayristirmadan sorgulanabilir. Hata CRUD islemini engellemez, yalnizca loglanir. + /// + private async Task SyncDataSourcesAsync(Guid id) + { + try + { + var component = await Repository.FindAsync(id); + if (component == null) + { + return; + } + + var sources = await ResolveDataSourcesAsync(component.Props); + var serialized = sources.Count == 0 ? null : JsonSerializer.Serialize(sources, SeedJsonWriteOptions); + + if (string.Equals(component.DataSources, serialized, StringComparison.Ordinal)) + { + return; + } + + component.DataSources = serialized; + await Repository.UpdateAsync(component, autoSave: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Custom component data sources could not be resolved for {Id}: {Message}", id, ex.Message); + } + } + + /// + /// Designer dokumanindaki endpoint'leri veritabanindaki CRUD endpoint kataloguyla eslestirir. + /// + private async Task> ResolveDataSourcesAsync(string? props) + { + var endpoints = await _crudEndpointRepository.GetListAsync(); + var references = endpoints + .Select(x => new CustomComponentDataSourceResolver.CrudEndpointReference + { + EntityName = x.EntityName, + Method = x.Method, + Path = x.Path, + OperationType = x.OperationType + }) + .ToList(); + + return CustomComponentDataSourceResolver.Resolve(props, references); + } + + /// Entity'de JSON string olarak tutulan endpoint listesini cozer. + private static List ParseDataSources(string? dataSources) + { + if (string.IsNullOrWhiteSpace(dataSources)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(dataSources, JsonReadOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + /// Designer dokumanindaki tek bir data source kaydi. + private sealed class DesignerDataSourceDto + { + public string? Name { get; set; } + public string? Method { get; set; } + public string? Url { get; set; } + public string? ResponsePath { get; set; } + } + + /// Entity'de JSON string olarak tutulan bagimlilik listesini cozer. + private static List ParseDependencies(string? dependencies) + { + if (string.IsNullOrWhiteSpace(dependencies)) + { + return []; + } + + try + { + return JsonSerializer.Deserialize>(dependencies, JsonReadOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + /// + /// Custom component seed dosyalarinin yazilacagi klasor; kapsam aktif tenant'i izler + /// (host baglaminda host/custom, tenant baglaminda tenants/{tenantId}/custom). + /// + private string ResolveCustomSeedOutputPath() + { + return SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id, SeedPathResolver.CustomFolder); + } + + /// Component adindan guvenli bir dosya adi uretir; path traversal denemelerini reddeder. + private static string? GetSafeFileName(string componentName) + { + var trimmed = componentName?.Trim(); + if (string.IsNullOrWhiteSpace(trimmed) || + trimmed.Contains('/') || + trimmed.Contains('\\') || + trimmed.Contains("..")) + { + return null; + } + + var safeFileName = string.Concat(trimmed.Split(Path.GetInvalidFileNameChars())); + return string.IsNullOrWhiteSpace(safeFileName) ? null : safeFileName; + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/CustomComponentDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/CustomComponentDataSeeder.cs new file mode 100644 index 00000000..5c7fe7f1 --- /dev/null +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/CustomComponentDataSeeder.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Sozsoft.Platform.Entities; +using Volo.Abp.Data; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Repositories; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Component Manager ile olusturulan custom component'leri +/// {host|tenants/{tenantId}}/custom/*.json dosyalarindan okuyarak veritabanina uygular +/// (kapsam, seed edilen veritabanini izler). +/// +/// Dosyalar component kaydedildikce/silindikce otomatik yazildigi icin kaynak kabul edilir: +/// ayni adli component varsa dosyadaki icerikle guncellenir, yoksa olusturulur. +/// Dosyada olmayan component'ler silinmez. +/// +/// +public class CustomComponentDataSeeder : IDataSeedContributor, ITransientDependency +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true + }; + + private readonly IRepository _repository; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CustomComponentDataSeeder( + IRepository repository, + IConfiguration configuration, + ILogger logger) + { + _repository = repository; + _configuration = configuration; + _logger = logger; + } + + public async Task SeedAsync(DataSeedContext context) + { + var scopeFolderName = SeedPathResolver.GetScopeFolderName(context.TenantId); + var customDataPath = SeedPathResolver.GetScopePath(_configuration, context.TenantId, SeedPathResolver.CustomFolder); + + if (!Directory.Exists(customDataPath)) + { + _logger.LogInformation("Seeds/{Scope}/custom directory not found, skipping CustomComponentDataSeeder.", scopeFolderName); + return; + } + + var jsonFiles = Directory.GetFiles(customDataPath, "*.json").OrderBy(Path.GetFileName).ToArray(); + if (jsonFiles.Length == 0) + { + _logger.LogInformation("No JSON files found in Seeds/{Scope}/custom directory, skipping CustomComponentDataSeeder.", scopeFolderName); + return; + } + + // Eski seed dosyalarinda DataSources blogu yoktur; katalog, bloksuz dosyalarda + // endpoint listesinin Props'tan turetilebilmesi icin bastan okunur. + var crudEndpoints = ReadCrudEndpointCatalog(context.TenantId, scopeFolderName); + + _logger.LogInformation("CustomComponentDataSeeder started. {Count} file(s) to be processed.", jsonFiles.Length); + + foreach (var filePath in jsonFiles) + { + var fileName = Path.GetFileName(filePath); + try + { + var json = await File.ReadAllTextAsync(filePath); + var seedFile = JsonSerializer.Deserialize(json, JsonReadOptions); + + if (seedFile?.CustomComponents == null || seedFile.CustomComponents.Count == 0) + { + _logger.LogWarning("[{File}] No custom component found, skipped.", fileName); + continue; + } + + foreach (var item in seedFile.CustomComponents) + { + await ApplyAsync(fileName, item, crudEndpoints); + } + } + catch (Exception ex) + { + // Tek bir dosyanin hatasi diger component'lerin seed edilmesini engellemez. + _logger.LogError(ex, "Failed to process custom component seed file: {FileName}", fileName); + } + } + + _logger.LogInformation("CustomComponentDataSeeder completed. {Count} file(s) processed.", jsonFiles.Length); + } + + /// + /// Kapsamdaki crud/*.json dosyalarini okuyarak endpoint katalogunu olusturur. + /// Veritabani yerine dosyalardan okunur; boylece seed sirasi CrudEndpointDataSeeder'a bagli kalmaz. + /// + private List ReadCrudEndpointCatalog( + Guid? tenantId, + string scopeFolderName) + { + var references = new List(); + var crudDataPath = SeedPathResolver.GetScopePath(_configuration, tenantId, SeedPathResolver.CrudFolder); + + if (!Directory.Exists(crudDataPath)) + { + return references; + } + + foreach (var filePath in Directory.GetFiles(crudDataPath, "*.json")) + { + try + { + var seedFile = JsonSerializer.Deserialize( + File.ReadAllText(filePath), + JsonReadOptions); + + if (seedFile?.Endpoints == null) + { + continue; + } + + references.AddRange(seedFile.Endpoints.Select(endpoint => + new CustomComponentDataSourceResolver.CrudEndpointReference + { + EntityName = seedFile.EntityName, + Method = endpoint.Method, + Path = endpoint.Path, + OperationType = endpoint.OperationType + })); + } + catch (Exception ex) + { + // Katalog yardimci bilgidir; okunamayan bir dosya component seed'ini durdurmaz. + _logger.LogWarning(ex, "[{Scope}] CRUD seed file could not be read for the data source catalog: {File}", + scopeFolderName, Path.GetFileName(filePath)); + } + } + + return references; + } + + private async Task ApplyAsync( + string fileName, + CustomComponentSeedItemDto item, + List crudEndpoints) + { + var name = item.Name?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + _logger.LogWarning("[{File}] Component name is empty, skipped.", fileName); + return; + } + + var dependencies = JsonSerializer.Serialize(item.Dependencies ?? new List()); + // DataSources blogu olmayan (bu sutundan once yazilmis) dosyalarda liste Props'taki + // designer dokumanindan turetilir; boylece sutun her durumda dolar. + var dataSourceItems = item.DataSources is { Count: > 0 } + ? item.DataSources + : CustomComponentDataSourceResolver.Resolve(item.Props, crudEndpoints); + + // Bos liste sutunu doldurmaya degmez; component'in hic endpoint'i yoksa null kalir. + var dataSources = dataSourceItems.Count > 0 + ? JsonSerializer.Serialize(dataSourceItems, JsonWriteOptions) + : null; + var current = await _repository.FirstOrDefaultAsync(x => x.Name == name); + + if (current == null) + { + await _repository.InsertAsync(new CustomComponent( + name, + item.RoutePath, + item.Code, + item.Props, + item.Description, + item.IsActive, + dependencies, + dataSources), autoSave: true); + + _logger.LogInformation("[{File}] '{Name}' created.", fileName, name); + return; + } + + current.RoutePath = item.RoutePath; + current.Code = item.Code; + current.Props = item.Props; + current.Description = item.Description; + current.IsActive = item.IsActive; + current.Dependencies = dependencies; + current.DataSources = dataSources; + + await _repository.UpdateAsync(current, autoSave: true); + _logger.LogInformation("[{File}] '{Name}' updated.", fileName, name); + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Migrations/LanguagesData.json index 067825c8..62eb4931 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", @@ -8802,6 +8802,30 @@ "en": "No dependencies", "tr": "Bağımlılık yok" }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitComponent.SaveToSeed", + "en": "Save to Seed", + "tr": "Seed'e Kaydet" + }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitComponent.SaveToSeedDescription", + "en": "Writes all component information as JSON under seeds/{scope}/custom.", + "tr": "Bileşenin tüm bilgilerini seeds/{kapsam}/custom altına JSON olarak yazar." + }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitComponent.SeedSaved", + "en": "Seed file saved:", + "tr": "Seed dosyası kaydedildi:" + }, + { + "resourceName": "Platform", + "key": "App.DeveloperKitComponent.SeedSaveFailed", + "en": "Seed file could not be saved.", + "tr": "Seed dosyası kaydedilemedi." + }, { "resourceName": "Platform", "key": "App.ComponentAction.Edit", diff --git a/api/src/Sozsoft.Platform.Domain/Data/CustomComponentDataSourceResolver.cs b/api/src/Sozsoft.Platform.Domain/Data/CustomComponentDataSourceResolver.cs new file mode 100644 index 00000000..a3e1989a --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/CustomComponentDataSourceResolver.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Component'in designer dokumanindaki (Props) Data sekmesi endpoint'lerini cozup +/// CRUD endpoint katalogu ile eslestiren ortak mantik. +/// +/// Ayni cozum hem kaydetme sirasinda (CustomComponentAppService, katalog veritabanindan gelir) +/// hem de seed sirasinda (CustomComponentDataSeeder, katalog crud/*.json dosyalarindan gelir) +/// kullanilir; boylece DataSources sutunu iki yolda da ayni sekilde uretilir. +/// +/// +public static class CustomComponentDataSourceResolver +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// Eslestirmede kullanilan CRUD endpoint tanimi. + public sealed class CrudEndpointReference + { + public string EntityName { get; set; } = string.Empty; + public string Method { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string OperationType { get; set; } = string.Empty; + } + + /// + /// Props icindeki visualDesigner.dataSources listesini okur ve her kaydi method + path + /// ile icinde arar. Eslesmeyen (elle yazilmis ya da custom) + /// endpoint'ler de listede kalir; yalnizca entity/seed dosyasi alanlari bos gelir. + /// + public static List Resolve( + string? props, + IReadOnlyCollection endpoints) + { + var documentSources = ParseDocumentDataSources(props); + if (documentSources.Count == 0) + { + return []; + } + + var resolved = new List(); + + foreach (var source in documentSources) + { + var method = (source.Method ?? string.Empty).Trim().ToUpperInvariant(); + var url = (source.Url ?? string.Empty).Trim(); + if (url.Length == 0) + { + continue; + } + + // Ayni method + path birden fazla bilesene baglanmis olabilir; tekrarlari eleriz. + if (resolved.Exists(x => x.Method == method && string.Equals(x.Url, url, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var endpoint = endpoints.FirstOrDefault(x => + string.Equals(x.Method?.Trim(), method, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Path?.Trim(), url, StringComparison.OrdinalIgnoreCase)); + + resolved.Add(new CustomComponentSeedDataSourceDto + { + Name = (source.Name ?? string.Empty).Trim(), + Method = method, + Url = url, + ResponsePath = string.IsNullOrWhiteSpace(source.ResponsePath) ? null : source.ResponsePath.Trim(), + EntityName = endpoint?.EntityName, + OperationType = endpoint?.OperationType, + SeedFile = endpoint == null + ? null + : $"{SeedPathResolver.CrudFolder}/{endpoint.EntityName}.json" + }); + } + + return resolved; + } + + /// Props JSON'undaki designer dokumanindan yalnizca dataSources dizisini ayiklar. + private static List ParseDocumentDataSources(string? props) + { + if (string.IsNullOrWhiteSpace(props)) + { + return []; + } + + try + { + using var document = JsonDocument.Parse(props); + if (!document.RootElement.TryGetProperty("visualDesigner", out var designer) || + designer.ValueKind != JsonValueKind.Object || + !designer.TryGetProperty("dataSources", out var dataSources) || + dataSources.ValueKind != JsonValueKind.Array) + { + return []; + } + + return JsonSerializer.Deserialize>(dataSources.GetRawText(), JsonReadOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + /// Designer dokumanindaki tek bir data source kaydi. + private sealed class DesignerDataSourceDto + { + public string? Name { get; set; } + public string? Method { get; set; } + public string? Url { get; set; } + public string? ResponsePath { get; set; } + } +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/CustomComponentSeedFile.cs b/api/src/Sozsoft.Platform.Domain/Data/CustomComponentSeedFile.cs new file mode 100644 index 00000000..13758b1c --- /dev/null +++ b/api/src/Sozsoft.Platform.Domain/Data/CustomComponentSeedFile.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; + +namespace Sozsoft.Platform.Data.Seeds; + +/// +/// Bir custom component'in seed dosyasi. +/// Component Manager uzerinden kaydedildiginde/silindiginde +/// {host|tenants/{tenantId}}/custom/{Name}.json olarak yazilir; veritabani silinip +/// yeniden olusturuldugunda CustomComponentDataSeeder ayni dosyalari okuyarak +/// component'leri geri yukler. +/// +/// Dosya duzeni bilincli olarak TenantData.json'daki CustomComponents bloguyla aynidir; +/// boylece eski seed icerigi dogrudan tasinabilir. Liste birden fazla component tasiyabilse de +/// senkronizasyon her component icin tek dosya yazar. +/// +/// +public class CustomComponentSeedFileDto +{ + /// Dosyanin en son yazildigi an (UTC) — yalnizca bilgi amaclidir. + public DateTime GeneratedAt { get; set; } + + public List CustomComponents { get; set; } = []; +} + +/// Component'in seed edilebilir tam tanimi; dosya adi ile ayni olmasi beklenir. +public class CustomComponentSeedItemDto +{ + public string Name { get; set; } = string.Empty; + public string RoutePath { get; set; } = string.Empty; + public string Code { get; set; } = string.Empty; + public string? Props { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + + /// Bagimli olunan component adlari; veritabaninda JSON string olarak tutulur. + public List Dependencies { get; set; } = []; + + /// + /// Component'in Data sekmesinde kullandigi API'ler; veritabaninda JSON string olarak tutulur. + /// Kaydetme sirasinda designer dokumanindan otomatik uretilir. + /// + public List DataSources { get; set; } = []; +} + +/// +/// Component'in kullandigi tek bir API. dolu ise endpoint +/// CRUD Endpoint Manager tarafindan uretilmistir ve tanimi +/// (kapsam klasorune gore crud/{EntityName}.json) dosyasindadir. +/// +public class CustomComponentSeedDataSourceDto +{ + /// Designer'daki gorunur ad. + public string Name { get; set; } = string.Empty; + + public string Method { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + /// Yanit icindeki liste yolu; bos ise yanitin kendisi kullanilir. + public string? ResponsePath { get; set; } + + /// Endpoint bir CRUD endpoint'ine denk geliyorsa entity adi, aksi halde null. + public string? EntityName { get; set; } + + /// CRUD endpoint'inin islem turu (GetAll, GetById, Create, Update, Delete). + public string? OperationType { get; set; } + + /// Endpoint tanimini tasiyan seed dosyasinin kapsam klasorune gore yolu. + public string? SeedFile { get; set; } +} diff --git a/api/src/Sozsoft.Platform.Domain/Data/SeedPathResolver.cs b/api/src/Sozsoft.Platform.Domain/Data/SeedPathResolver.cs index 6b71d945..758743e7 100644 --- a/api/src/Sozsoft.Platform.Domain/Data/SeedPathResolver.cs +++ b/api/src/Sozsoft.Platform.Domain/Data/SeedPathResolver.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -72,7 +72,7 @@ public static class SeedPathResolver /// Crud endpoint konfigurasyon dosyalari. public const string CrudFolder = "crud"; - /// Custom endpoint konfigurasyon dosyalari. + /// Custom component konfigurasyon dosyalari. public const string CustomFolder = "custom"; private const string RepositorySeedsFolder = "seeds"; diff --git a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/CustomComponent.cs b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/CustomComponent.cs index 6b89de43..e91b1ae3 100644 --- a/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/CustomComponent.cs +++ b/api/src/Sozsoft.Platform.Domain/Entities/Tenant/Administration/DeveloperKit/CustomComponent.cs @@ -16,7 +16,15 @@ public class CustomComponent : FullAuditedEntity, IMultiTenant public bool IsActive { get; set; } = true; public string? Dependencies { get; set; } // JSON string of component names - public CustomComponent(string name, string routePath, string code, string? props, string? description, bool isActive, string? dependencies) + /// + /// Component'in Data sekmesinde kullandigi API'ler; JSON dizisi olarak tutulur. + /// Her kayit endpoint'in method/url bilgisinin yani sira cozumlenebiliyorsa + /// kaynak CRUD endpoint'ini (EntityName ve crud/{EntityName}.json seed dosyasi) tasir. + /// Props icindeki designer dokumanindan turetilir; kaydetme sirasinda otomatik yazilir. + /// + public string? DataSources { get; set; } + + public CustomComponent(string name, string routePath, string code, string? props, string? description, bool isActive, string? dependencies, string? dataSources = null) { Name = name; RoutePath = routePath; @@ -25,6 +33,7 @@ public class CustomComponent : FullAuditedEntity, IMultiTenant Description = description; IsActive = isActive; Dependencies = dependencies; + DataSources = dataSources; } } diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs index 1565b4e1..b7ec58cd 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/EntityFrameworkCore/PlatformDbContext.cs @@ -670,6 +670,8 @@ public class PlatformDbContext : b.Property(x => x.Props).HasColumnType("nvarchar(max)"); b.Property(x => x.Description).HasMaxLength(512); b.Property(x => x.Dependencies).HasMaxLength(2048); + // Data sekmesindeki endpoint listesi; adet ve url uzunlugu onceden bilinemez. + b.Property(x => x.DataSources).HasColumnType("nvarchar(max)"); b.HasIndex(x => new { x.TenantId, x.Name }).IsUnique().HasFilter("[IsDeleted] = 0"); b.HasIndex(x => new { x.TenantId, x.RoutePath }).IsUnique().HasFilter("[IsDeleted] = 0"); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.Designer.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.Designer.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.Designer.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.Designer.cs index 5d8beff3..9c634510 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.Designer.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.Designer.cs @@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore; namespace Sozsoft.Platform.Migrations { [DbContext(typeof(PlatformDbContext))] - [Migration("20260817083025_Initial")] + [Migration("20260818091734_Initial")] partial class Initial { /// @@ -1921,6 +1921,9 @@ namespace Sozsoft.Platform.Migrations .HasColumnType("uniqueidentifier") .HasColumnName("CreatorId"); + b.Property("DataSources") + .HasColumnType("nvarchar(max)"); + b.Property("DeleterId") .HasColumnType("uniqueidentifier") .HasColumnName("DeleterId"); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.cs similarity index 99% rename from api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.cs rename to api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.cs index 2f524940..c0c0e1b7 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260817083025_Initial.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/20260818091734_Initial.cs @@ -1185,6 +1185,7 @@ namespace Sozsoft.Platform.Migrations Description = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), IsActive = table.Column(type: "bit", nullable: false), Dependencies = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + DataSources = table.Column(type: "nvarchar(max)", nullable: true), CreationTime = table.Column(type: "datetime2", nullable: false), CreatorId = table.Column(type: "uniqueidentifier", nullable: true), LastModificationTime = table.Column(type: "datetime2", nullable: true), diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs index 55fc7215..dbc11ecf 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Migrations/PlatformDbContextModelSnapshot.cs @@ -1918,6 +1918,9 @@ namespace Sozsoft.Platform.Migrations .HasColumnType("uniqueidentifier") .HasColumnName("CreatorId"); + b.Property("DataSources") + .HasColumnType("nvarchar(max)"); + b.Property("DeleterId") .HasColumnType("uniqueidentifier") .HasColumnName("DeleterId"); diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantData.json b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantData.json index 8665de1a..af14f329 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantData.json +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantData.json @@ -346,26 +346,6 @@ ] } ], - "CustomComponents": [ - { - "name": "DynamicEntityComponent", - "routePath": "/admin/dynamic-entity", - "code": "import React, { useEffect, useState } from \"react\";\nimport axios from \"axios\";\n\ninterface DynamicEntityComponentProps {\n title: string;\n}\n\nconst api = axios.create({\n baseURL: \"https://localhost:44344\",\n});\n\nconst DynamicEntityComponent: React.FC = ({ title }) => {\n const [data, setData] = useState>([]);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n const fetchData = async () => {\n setLoading(true);\n setError(null);\n\n try {\n const res = await api.get(`/api/app/crudendpoint/${title}`);\n const raw = Array.isArray(res.data) ? res.data : res.data?.items ?? [];\n\n const filtered = raw.map((item: any) => ({\n id: item.Id ?? item.id,\n name: item.Name ?? item.name,\n }));\n\n setData(filtered);\n } catch (err: any) {\n setError(err.message || \"Failed to fetch data\");\n } finally {\n setLoading(false);\n }\n };\n\n if (title) fetchData();\n }, [title]);\n\n if (loading) return
Loading...
;\n if (error) return
Error: {error}
;\n if (!data.length) return
No records found
;\n\n const headers = [\"id\", \"name\", \"actions\"];\n\n return (\n
\n \n \n \n {headers.map((key) => (\n \n {key === \"actions\" ? \"Actions\" : key}\n \n ))}\n \n \n \n {data.map((item, rowIndex) => (\n \n \n \n \n \n ))}\n \n
\n {item.id}\n \n {item.name}\n \n alert(item.name)}\n shape=\"round\"\n size=\"xs\"\n variant=\"solid\"\n id=\"c_mrix4c1a_qrybhk\"\n >\n Show Name\n \n
\n
\n );\n};\n\nexport default DynamicEntityComponent;", - "props": null, - "description": null, - "isActive": true, - "dependencies": [] - }, - { - "name": "RoleListComponent", - "routePath": "/admin/roles-list", - "code": "const RoleListComponent = ({\n title = \"AbpRoles\"\n}) => {\n return (\n \n );\n};\n\nexport default RoleListComponent;", - "props": null, - "description": null, - "isActive": true, - "dependencies": ["DynamicEntityComponent"] - } - ], "Abouts": [ { "stats": [ diff --git a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs index 00de524d..723aa8b4 100644 --- a/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs +++ b/api/src/Sozsoft.Platform.EntityFrameworkCore/Seeds/TenantDataSeeder.cs @@ -25,7 +25,6 @@ public class TenantSeederDto //Saas public List GlobalSearch { get; set; } public List CustomEndpoints { get; set; } - public List CustomComponents { get; set; } public List Sequences { get; set; } //Definitions @@ -340,17 +339,6 @@ public class InstallmentOptionSeedDto public decimal Commission { get; set; } } -public class CustomComponentSeedDto -{ - public string Name { get; set; } - public string RoutePath { get; set; } - public string Code { get; set; } - public string Props { get; set; } - public string Description { get; set; } - public bool IsActive { get; set; } - public List Dependencies { get; set; } = new(); -} - public class ReportTemplateSeedDto { public string Name { get; set; } @@ -479,7 +467,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency private readonly IRepository _branchRepository; private readonly IRepository _globalSearch; private readonly IRepository _customEndpointRepository; - private readonly IRepository _customComponentRepository; private readonly IRepository _reportCategoriesRepository; private readonly IRepository _reportTemplatesRepository; private readonly IRepository _homeRepository; @@ -535,7 +522,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency IRepository skillRepository, IRepository skillLevelRepository, IRepository customEndpointRepository, - IRepository customComponentRepository, IRepository reportCategoriesRepository, IRepository reportTemplatesRepository, IRepository homeRepository, @@ -589,7 +575,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency _productRepository = productRepository; _paymentMethodRepository = paymentMethodRepository; _installmentOptionRepository = installmentOptionRepository; - _customComponentRepository = customComponentRepository; _reportCategoriesRepository = reportCategoriesRepository; _reportTemplatesRepository = reportTemplatesRepository; _homeRepository = homeRepository; @@ -830,23 +815,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency } } - foreach (var item in items.CustomComponents) - { - var exists = await _customComponentRepository.AnyAsync(x => x.Name == item.Name); - - if (!exists) - { - await _customComponentRepository.InsertAsync(new CustomComponent( - item.Name, - item.RoutePath, - item.Code, - item.Props, - item.Description, - item.IsActive, - JsonSerializer.Serialize(item.Dependencies))); - } - } - foreach (var item in items.ReportCategories) { var exists = await _reportCategoriesRepository.AnyAsync(x => x.Name == item.Name); diff --git a/configs/seeds/host/custom/DynamicEntityComponent.json b/configs/seeds/host/custom/DynamicEntityComponent.json new file mode 100644 index 00000000..90ca2064 --- /dev/null +++ b/configs/seeds/host/custom/DynamicEntityComponent.json @@ -0,0 +1,15 @@ +{ + "GeneratedAt": "2026-08-18T08:40:41.9083616Z", + "CustomComponents": [ + { + "Name": "DynamicEntityComponent", + "RoutePath": "/admin/dynamic-entity", + "Code": "import React, { useEffect, useState } from \"react\";\nimport axios from \"axios\";\n\ninterface DynamicEntityComponentProps {\n title: string;\n}\n\nconst api = axios.create({\n baseURL: \"https://localhost:44344\",\n});\n\nconst DynamicEntityComponent: React.FC = ({ title }) => {\n const [data, setData] = useState>([]);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n const fetchData = async () => {\n setLoading(true);\n setError(null);\n\n try {\n const res = await api.get(`/api/app/crudendpoint/${title}`);\n const raw = Array.isArray(res.data) ? res.data : res.data?.items ?? [];\n\n const filtered = raw.map((item: any) => ({\n id: item.Id ?? item.id,\n name: item.Name ?? item.name,\n }));\n\n setData(filtered);\n } catch (err: any) {\n setError(err.message || \"Failed to fetch data\");\n } finally {\n setLoading(false);\n }\n };\n\n if (title) fetchData();\n }, [title]);\n\n if (loading) return
Loading...
;\n if (error) return
Error: {error}
;\n if (!data.length) return
No records found
;\n\n const headers = [\"id\", \"name\", \"actions\"];\n\n return (\n
\n \n \n \n {headers.map((key) => (\n \n {key === \"actions\" ? \"Actions\" : key}\n \n ))}\n \n \n \n {data.map((item, rowIndex) => (\n \n \n \n \n \n ))}\n \n
\n {item.id}\n \n {item.name}\n \n alert(item.name)}\n shape=\"round\"\n size=\"xs\"\n variant=\"solid\"\n id=\"c_mrix4c1a_qrybhk\"\n >\n Show Name\n \n
\n
\n );\n};\n\nexport default DynamicEntityComponent;", + "Props": null, + "Description": null, + "IsActive": true, + "Dependencies": [], + "DataSources": [] + } + ] +} \ No newline at end of file diff --git a/configs/seeds/host/custom/RoleComponent.json b/configs/seeds/host/custom/RoleComponent.json new file mode 100644 index 00000000..c67f438e --- /dev/null +++ b/configs/seeds/host/custom/RoleComponent.json @@ -0,0 +1,61 @@ +{ + "GeneratedAt": "2026-08-18T09:36:07.2745686Z", + "CustomComponents": [ + { + "Name": "RoleComponent", + "RoutePath": "/admin/roles", + "Code": "/*__SOZSOFT_VISUAL_DESIGNER__%7B%22version%22%3A1%2C%22sourceMode%22%3A%22visual%22%2C%22nodes%22%3A%5B%7B%22id%22%3A%22cmp_msyfke8z_96c0a%22%2C%22type%22%3A%22span%22%2C%22kind%22%3A%22html%22%2C%22props%22%3A%7B%22children%22%3A%22Rol%20List%22%7D%2C%22events%22%3A%7B%7D%2C%22bindings%22%3A%7B%7D%2C%22children%22%3A%5B%5D%2C%22ref%22%3A%22span1%22%7D%2C%7B%22id%22%3A%22cmp_msyfmegt_jkkn3%22%2C%22type%22%3A%22SqlDataSource%22%2C%22kind%22%3A%22layout%22%2C%22props%22%3A%7B%22selectEndpoint%22%3A%22source_5w_5ibpv%22%2C%22insertEndpoint%22%3A%22source_h3_484qa%22%2C%22updateEndpoint%22%3A%22source_gb_d9cvs%22%2C%22deleteEndpoint%22%3A%22source_0t_avok9%22%2C%22keyFieldName%22%3A%22id%22%2C%22collectionPath%22%3A%22%22%2C%22keySource%22%3A%22query%22%2C%22keyParamName%22%3A%22id%22%2C%22previewKeyValue%22%3A%22%22%2C%22autoLoad%22%3Atrue%2C%22showToolbar%22%3Atrue%2C%22gap%22%3A16%2C%22className%22%3A%22%22%7D%2C%22events%22%3A%7B%22onLoad%22%3A%22%22%2C%22onRecordChange%22%3A%22%22%2C%22onFieldChange%22%3A%22%22%2C%22onNewRecord%22%3A%22%22%2C%22onModeChange%22%3A%22%22%2C%22onBeforeSave%22%3A%22%22%2C%22onAfterSave%22%3A%22%22%2C%22onBeforeDelete%22%3A%22%22%2C%22onAfterDelete%22%3A%22%22%2C%22onError%22%3A%22%22%7D%2C%22bindings%22%3A%7B%7D%2C%22children%22%3A%5B%7B%22id%22%3A%22cmp_msyfpfia_ifhdq%22%2C%22type%22%3A%22Input%22%2C%22kind%22%3A%22ui%22%2C%22props%22%3A%7B%22asElement%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22invalid%22%3Afalse%2C%22rows%22%3A3%2C%22prefix%22%3A%22Name%20%3A%22%2C%22size%22%3A%22md%22%2C%22suffix%22%3A%22%22%2C%22textArea%22%3Afalse%2C%22type%22%3A%22text%22%2C%22unstyle%22%3Afalse%2C%22field%22%3A%22%22%2C%22form%22%3A%22%22%2C%22className%22%3A%22%22%2C%22children%22%3A%22%22%2C%22style%22%3A%7B%7D%2C%22placeholder%22%3A%22Metin%20girin...%22%2C%22value%22%3A%22%22%2C%22name%22%3A%22%22%2C%22autoComplete%22%3A%22off%22%2C%22autoFocus%22%3Afalse%2C%22readOnly%22%3Afalse%2C%22required%22%3Afalse%2C%22maxLength%22%3A%22%22%2C%22minLength%22%3A%22%22%2C%22pattern%22%3A%22%22%7D%2C%22events%22%3A%7B%22onChange%22%3A%22%22%7D%2C%22bindings%22%3A%7B%22value%22%3A%7B%22sourceId%22%3A%22cmp_msyfmegt_jkkn3%22%2C%22path%22%3A%22Name%22%7D%7D%2C%22children%22%3A%5B%5D%2C%22ref%22%3A%22input1%22%7D%5D%2C%22ref%22%3A%22sqlDataSource1%22%7D%5D%2C%22canvas%22%3A%7B%22width%22%3A%22responsive%22%7D%2C%22lifecycle%22%3A%7B%22onMount%22%3A%22%22%7D%2C%22dataSources%22%3A%5B%7B%22id%22%3A%22source_5w_5ibpv%22%2C%22name%22%3A%22AbpRoles%20%C2%B7%20GetList%22%2C%22method%22%3A%22GET%22%2C%22url%22%3A%22%2Fapi%2Fapp%2Fcrudendpoint%2FAbpRoles%22%2C%22responsePath%22%3A%22%22%7D%2C%7B%22id%22%3A%22source_i7_msox4%22%2C%22name%22%3A%22AbpRoles%20%C2%B7%20GetById%22%2C%22method%22%3A%22GET%22%2C%22url%22%3A%22%2Fapi%2Fapp%2Fcrudendpoint%2FAbpRoles%2F%7Bid%7D%22%2C%22responsePath%22%3A%22%22%7D%2C%7B%22id%22%3A%22source_0t_avok9%22%2C%22name%22%3A%22AbpRoles%20%C2%B7%20Delete%22%2C%22method%22%3A%22DELETE%22%2C%22url%22%3A%22%2Fapi%2Fapp%2Fcrudendpoint%2FAbpRoles%2F%7Bid%7D%22%2C%22responsePath%22%3A%22%22%7D%2C%7B%22id%22%3A%22source_h3_484qa%22%2C%22name%22%3A%22AbpRoles%20%C2%B7%20Create%22%2C%22method%22%3A%22POST%22%2C%22url%22%3A%22%2Fapi%2Fapp%2Fcrudendpoint%2FAbpRoles%22%2C%22responsePath%22%3A%22%22%7D%2C%7B%22id%22%3A%22source_gb_d9cvs%22%2C%22name%22%3A%22AbpRoles%20%C2%B7%20Update%22%2C%22method%22%3A%22PUT%22%2C%22url%22%3A%22%2Fapi%2Fapp%2Fcrudendpoint%2FAbpRoles%2F%7Bid%7D%22%2C%22responsePath%22%3A%22%22%7D%5D%7D__*/\nconst RoleList = () => {\n const getByPath = (value, path) => {\n if (!path) return value\n const readPath = (target, targetPath) => targetPath.split('.').filter(Boolean).reduce((current, key) => current?.[key], target)\n const directValue = readPath(value, path)\n if (directValue !== undefined) return directValue\n if (path.startsWith('result.')) return readPath(value, path.slice('result.'.length))\n return value?.result === undefined ? undefined : readPath(value.result, path)\n }\n\n const readSqlField = (record, field) => {\n if (!record || typeof record !== \"object\" || Array.isArray(record)) return undefined\n if (field in record) return record[field]\n const matched = Object.keys(record).find((key) => key.toLowerCase() === String(field).toLowerCase())\n return matched === undefined ? undefined : record[matched]\n }\n const toSqlRows = (value, collectionPath) => {\n const source = collectionPath ? getByPath(value, collectionPath) : value\n const rows = Array.isArray(source)\n ? source\n : source && typeof source === \"object\"\n ? (Array.isArray(source.items) ? source.items : [source])\n : []\n return rows.filter((item) => item && typeof item === \"object\" && !Array.isArray(item))\n }\n const setSqlField = (record, path, value) => {\n const keys = String(path || \"\").split(\".\").filter(Boolean)\n if (!keys.length) return record\n const next = { ...(record || {}) }\n let target = next\n for (let index = 0; index < keys.length - 1; index += 1) {\n const child = target[keys[index]]\n target[keys[index]] = child && typeof child === \"object\" && !Array.isArray(child) ? { ...child } : {}\n target = target[keys[index]]\n }\n target[keys[keys.length - 1]] = value\n return next\n }\n const bindSqlUrl = (url, record, keyField) => {\n let keyBound = false\n const boundUrl = String(url).replace(/\\{([^}]+)\\}|(?<=\\/):([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, colon) => {\n const name = (braced || colon || \"\").trim()\n if (!name) return match\n const value = readSqlField(record, name) ?? readSqlField(record, keyField)\n if (value === undefined || value === null || value === \"\") return match\n if (name.toLowerCase() === String(keyField).toLowerCase()) keyBound = true\n return encodeURIComponent(String(value))\n })\n return { url: boundUrl, keyBound }\n }\n const appendQueryParam = (url, name, value) =>\n url + (url.includes(\"?\") ? \"&\" : \"?\") + encodeURIComponent(name) + \"=\" + encodeURIComponent(value)\n const hasSqlUrlParams = (url) => /\\{[^}]+\\}|(?<=\\/):[A-Za-z_][A-Za-z0-9_]*/.test(String(url))\n const readUrlKey = (source, name) => {\n if (typeof window === \"undefined\") return \"\"\n if (source === \"query\") return new URLSearchParams(window.location.search).get(name) ?? \"\"\n const segments = window.location.pathname.split(\"/\").filter(Boolean)\n return segments.length ? decodeURIComponent(segments[segments.length - 1]) : \"\"\n }\n const filterSqlRowsByKey = (rows, keyField, keyValue) => {\n if (!keyValue) return rows\n const matched = rows.filter((row) => String(readSqlField(row, keyField) ?? \"\") === String(keyValue))\n // No match means the endpoint already filtered server side, or the key column\n // is named differently — keeping the rows beats showing an empty form.\n return matched.length ? matched : rows\n }\n // Update payload: only the columns the user actually edited, plus the key.\n const toSqlChanges = (original, current, keyField) => {\n const changes = {}\n const isSame = (left, right) => JSON.stringify(left ?? null) === JSON.stringify(right ?? null)\n Object.keys(current || {}).forEach((column) => {\n if (!isSame(original ? original[column] : undefined, current[column])) changes[column] = current[column]\n })\n const keyColumn = Object.keys(current || {}).find((column) => column.toLowerCase() === String(keyField).toLowerCase())\n if (keyColumn !== undefined && changes[keyColumn] === undefined) changes[keyColumn] = current[keyColumn]\n return changes\n }\n const callSqlEndpoint = (target, record, keyField, payload) => {\n // The URL is always bound from the full record: a placeholder may reference a\n // column that the update payload does not carry.\n const bound = bindSqlUrl(target.url, record, keyField)\n const request = { url: bound.url, method: target.method }\n if (target.method === \"POST\" || target.method === \"PUT\") request.data = payload === undefined ? record : payload\n const keyValue = readSqlField(record, keyField)\n const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== \"\"\n // The key still has to reach the endpoint when the URL carries no placeholder.\n if (!bound.keyBound && hasKey && target.method !== \"POST\") request.params = { [keyField]: keyValue }\n return apiService.fetchData(request)\n }\n const toSqlErrorMessage = (error) => error?.response?.data?.error?.message || error?.response?.data?.message || error?.message || \"İşlem tamamlanamadı.\"\n\n const [data_source_5w_5ibpv, setData_source_5w_5ibpv] = React.useState(null)\n\n const [data_source_i7_msox4, setData_source_i7_msox4] = React.useState(null)\n\n const [data_source_0t_avok9] = React.useState(null)\n\n const [data_source_h3_484qa] = React.useState(null)\n\n const [data_source_gb_d9cvs] = React.useState(null)\n\n const sqlselect_cmp_msyfmegt_jkkn3 = { url: \"/api/app/crudendpoint/AbpRoles\", method: \"GET\", responsePath: \"\" }\n const sqlinsert_cmp_msyfmegt_jkkn3 = { url: \"/api/app/crudendpoint/AbpRoles\", method: \"POST\", responsePath: \"\" }\n const sqlupdate_cmp_msyfmegt_jkkn3 = { url: \"/api/app/crudendpoint/AbpRoles/{id}\", method: \"PUT\", responsePath: \"\" }\n const sqldelete_cmp_msyfmegt_jkkn3 = { url: \"/api/app/crudendpoint/AbpRoles/{id}\", method: \"DELETE\", responsePath: \"\" }\n const sqlKey_cmp_msyfmegt_jkkn3 = \"id\"\n const sqlKeyParam_cmp_msyfmegt_jkkn3 = \"id\"\n const [data_cmp_msyfmegt_jkkn3, setData_cmp_msyfmegt_jkkn3] = React.useState({})\n const [sqlOriginal_cmp_msyfmegt_jkkn3, setSqlOriginal_cmp_msyfmegt_jkkn3] = React.useState({})\n const [sqlMode_cmp_msyfmegt_jkkn3, setSqlMode_cmp_msyfmegt_jkkn3] = React.useState(\"edit\")\n const [sqlIndex_cmp_msyfmegt_jkkn3, setSqlIndex_cmp_msyfmegt_jkkn3] = React.useState(0)\n const [sqlBusy_cmp_msyfmegt_jkkn3, setSqlBusy_cmp_msyfmegt_jkkn3] = React.useState(false)\n const [sqlError_cmp_msyfmegt_jkkn3, setSqlError_cmp_msyfmegt_jkkn3] = React.useState(\"\")\n // The Select key can come from the page URL, which is how a detail page reads\n // /api/app/orders/{id} or /api/app/orders?id=… for a single record.\n const sqlUrlKey_cmp_msyfmegt_jkkn3 = readUrlKey(\"query\", sqlKeyParam_cmp_msyfmegt_jkkn3)\n const sqlSelectUrl_cmp_msyfmegt_jkkn3 = React.useMemo(() => {\n if (!sqlselect_cmp_msyfmegt_jkkn3) return \"\"\n if (!sqlUrlKey_cmp_msyfmegt_jkkn3) return sqlselect_cmp_msyfmegt_jkkn3.url\n const bound = bindSqlUrl(sqlselect_cmp_msyfmegt_jkkn3.url, { [sqlKeyParam_cmp_msyfmegt_jkkn3]: sqlUrlKey_cmp_msyfmegt_jkkn3 }, sqlKeyParam_cmp_msyfmegt_jkkn3)\n return bound.keyBound ? bound.url : appendQueryParam(bound.url, sqlKeyParam_cmp_msyfmegt_jkkn3, sqlUrlKey_cmp_msyfmegt_jkkn3)\n }, [sqlUrlKey_cmp_msyfmegt_jkkn3])\n // A key in the page URL always narrows the result: a list endpoint ignores the\n // parameter server side, so the requested record is picked out here. Without a\n // key every row is kept and the navigation below takes over.\n const sqlRows_cmp_msyfmegt_jkkn3 = React.useMemo(\n () => filterSqlRowsByKey(toSqlRows(data_source_5w_5ibpv, \"\"), sqlKey_cmp_msyfmegt_jkkn3, sqlUrlKey_cmp_msyfmegt_jkkn3),\n [data_source_5w_5ibpv, sqlUrlKey_cmp_msyfmegt_jkkn3],\n )\n // A fresh result set resets the position; New mode is left untouched so an\n // unsaved draft is not overwritten by a re-render.\n React.useEffect(() => {\n setSqlIndex_cmp_msyfmegt_jkkn3((current) => (current < sqlRows_cmp_msyfmegt_jkkn3.length ? current : 0))\n }, [sqlRows_cmp_msyfmegt_jkkn3])\n React.useEffect(() => {\n const row = sqlRows_cmp_msyfmegt_jkkn3[sqlIndex_cmp_msyfmegt_jkkn3]\n if (!row) return\n setData_cmp_msyfmegt_jkkn3(row)\n setSqlOriginal_cmp_msyfmegt_jkkn3(row)\n setSqlMode_cmp_msyfmegt_jkkn3(\"edit\")\n }, [sqlRows_cmp_msyfmegt_jkkn3, sqlIndex_cmp_msyfmegt_jkkn3])\n const sqlHasKey_cmp_msyfmegt_jkkn3 = (() => {\n const value = readSqlField(data_cmp_msyfmegt_jkkn3, sqlKey_cmp_msyfmegt_jkkn3)\n return value !== undefined && value !== null && value !== \"\"\n })()\n const sqlSetField_cmp_msyfmegt_jkkn3 = React.useCallback((path, value) => setData_cmp_msyfmegt_jkkn3((current) => setSqlField(current, path, value)), [])\n const sqlReload_cmp_msyfmegt_jkkn3 = React.useCallback(async () => {\n if (!sqlSelectUrl_cmp_msyfmegt_jkkn3) return\n // Calling a URL that still holds a placeholder is a guaranteed 400, so the\n // missing key is reported in the component instead.\n if (hasSqlUrlParams(sqlSelectUrl_cmp_msyfmegt_jkkn3)) {\n throw new Error(\"Select endpointi \" + sqlKeyParam_cmp_msyfmegt_jkkn3 + \" parametresini bekliyor; sayfa adresinde bulunamadı.\")\n }\n const response = await apiService.fetchData({ url: sqlSelectUrl_cmp_msyfmegt_jkkn3, method: \"GET\" })\n setData_source_5w_5ibpv(getByPath(response.data, sqlselect_cmp_msyfmegt_jkkn3.responsePath))\n }, [sqlSelectUrl_cmp_msyfmegt_jkkn3])\n // Reload wrapped with the busy/error handling the toolbar and mount effect need,\n // so a failing Select is reported in the component instead of the console.\n const sqlRefresh_cmp_msyfmegt_jkkn3 = React.useCallback(async () => {\n setSqlBusy_cmp_msyfmegt_jkkn3(true)\n setSqlError_cmp_msyfmegt_jkkn3(\"\")\n try {\n await sqlReload_cmp_msyfmegt_jkkn3()\n } catch (error) {\n setSqlError_cmp_msyfmegt_jkkn3(toSqlErrorMessage(error))\n } finally {\n setSqlBusy_cmp_msyfmegt_jkkn3(false)\n }\n }, [sqlReload_cmp_msyfmegt_jkkn3])\n const sqlNew_cmp_msyfmegt_jkkn3 = React.useCallback(() => {\n setSqlError_cmp_msyfmegt_jkkn3(\"\")\n setData_cmp_msyfmegt_jkkn3({})\n setSqlOriginal_cmp_msyfmegt_jkkn3({})\n setSqlMode_cmp_msyfmegt_jkkn3(\"new\")\n }, [])\n const sqlPrev_cmp_msyfmegt_jkkn3 = React.useCallback(() => setSqlIndex_cmp_msyfmegt_jkkn3((current) => Math.max(0, current - 1)), [])\n const sqlNext_cmp_msyfmegt_jkkn3 = React.useCallback(() => setSqlIndex_cmp_msyfmegt_jkkn3((current) => Math.min(sqlRows_cmp_msyfmegt_jkkn3.length - 1, current + 1)), [sqlRows_cmp_msyfmegt_jkkn3])\n const sqlSave_cmp_msyfmegt_jkkn3 = async () => {\n const isNew = sqlMode_cmp_msyfmegt_jkkn3 === \"new\"\n const target = isNew ? sqlinsert_cmp_msyfmegt_jkkn3 : sqlupdate_cmp_msyfmegt_jkkn3\n if (!target) {\n setSqlError_cmp_msyfmegt_jkkn3(isNew ? \"Insert için POST endpointi tanımlı değil.\" : \"Update için PUT endpointi tanımlı değil.\")\n return\n }\n // Update carries only the edited columns plus the key; Insert sends the record.\n const payload = isNew ? data_cmp_msyfmegt_jkkn3 : toSqlChanges(sqlOriginal_cmp_msyfmegt_jkkn3, data_cmp_msyfmegt_jkkn3, sqlKey_cmp_msyfmegt_jkkn3)\n if (!isNew && Object.keys(payload).filter((column) => column.toLowerCase() !== String(sqlKey_cmp_msyfmegt_jkkn3).toLowerCase()).length === 0) {\n setSqlError_cmp_msyfmegt_jkkn3(\"Kaydedilecek bir değişiklik yok.\")\n return\n }\n setSqlBusy_cmp_msyfmegt_jkkn3(true)\n setSqlError_cmp_msyfmegt_jkkn3(\"\")\n try {\n const response = await callSqlEndpoint(target, data_cmp_msyfmegt_jkkn3, sqlKey_cmp_msyfmegt_jkkn3, payload)\n // With a Select endpoint the reload is the source of truth and its effect\n // repopulates the form; without one, the response row is all there is.\n if (sqlselect_cmp_msyfmegt_jkkn3) {\n await sqlReload_cmp_msyfmegt_jkkn3()\n } else {\n const saved = toSqlRows(response?.data, \"\")[0]\n if (saved) {\n setData_cmp_msyfmegt_jkkn3(saved)\n setSqlOriginal_cmp_msyfmegt_jkkn3(saved)\n }\n }\n setSqlMode_cmp_msyfmegt_jkkn3(\"edit\")\n } catch (error) {\n setSqlError_cmp_msyfmegt_jkkn3(toSqlErrorMessage(error))\n } finally {\n setSqlBusy_cmp_msyfmegt_jkkn3(false)\n }\n }\n const sqlDelete_cmp_msyfmegt_jkkn3 = async () => {\n if (!sqldelete_cmp_msyfmegt_jkkn3 || !sqlHasKey_cmp_msyfmegt_jkkn3 || sqlMode_cmp_msyfmegt_jkkn3 === \"new\") return\n // Deleting cannot be undone from the form, so it always asks first.\n const confirmMessage = sqlKey_cmp_msyfmegt_jkkn3 + \" = \" + readSqlField(data_cmp_msyfmegt_jkkn3, sqlKey_cmp_msyfmegt_jkkn3) + \" kaydı silinecek.\\n\\nOnaylıyor musunuz?\"\n if (typeof window !== \"undefined\" && !window.confirm(confirmMessage)) return\n setSqlBusy_cmp_msyfmegt_jkkn3(true)\n setSqlError_cmp_msyfmegt_jkkn3(\"\")\n try {\n await callSqlEndpoint(sqldelete_cmp_msyfmegt_jkkn3, data_cmp_msyfmegt_jkkn3, sqlKey_cmp_msyfmegt_jkkn3)\n setData_cmp_msyfmegt_jkkn3({})\n setSqlOriginal_cmp_msyfmegt_jkkn3({})\n setSqlMode_cmp_msyfmegt_jkkn3(\"new\")\n await sqlReload_cmp_msyfmegt_jkkn3()\n } catch (error) {\n setSqlError_cmp_msyfmegt_jkkn3(toSqlErrorMessage(error))\n } finally {\n setSqlBusy_cmp_msyfmegt_jkkn3(false)\n }\n }\n React.useEffect(() => { void sqlRefresh_cmp_msyfmegt_jkkn3() }, [sqlRefresh_cmp_msyfmegt_jkkn3])\n\n const handle_cmp_msyfpfia_ifhdq_onChange = (valueOrEvent, originalEvent) => {\n sqlSetField_cmp_msyfmegt_jkkn3(\"Name\", valueOrEvent?.target?.value ?? valueOrEvent)\n const event = valueOrEvent\n }\n\n return (\n <>\n \n {\"Rol List\"}\n \n
\n \n
\n {sqlRows_cmp_msyfmegt_jkkn3.length > 1 ? (\n <>\n \n {`${sqlIndex_cmp_msyfmegt_jkkn3 + 1} / ${sqlRows_cmp_msyfmegt_jkkn3.length}`}\n \n \n \n ) : null}\n \n \n \n \n {sqlMode_cmp_msyfmegt_jkkn3 === \"new\" ? \"Yeni kayıt\" : \"Düzenleme\"}\n
\n {sqlError_cmp_msyfmegt_jkkn3 ?
{sqlError_cmp_msyfmegt_jkkn3}
: null}\n
\n \n )\n}\n\nexport default RoleList", + "Props": "{\"visualDesigner\":{\"version\":1,\"sourceMode\":\"visual\",\"nodes\":[{\"id\":\"cmp_msyfke8z_96c0a\",\"type\":\"span\",\"kind\":\"html\",\"props\":{\"children\":\"Rol List\"},\"events\":{},\"bindings\":{},\"children\":[],\"ref\":\"span1\"},{\"id\":\"cmp_msyfmegt_jkkn3\",\"type\":\"SqlDataSource\",\"kind\":\"layout\",\"props\":{\"selectEndpoint\":\"source_5w_5ibpv\",\"insertEndpoint\":\"source_h3_484qa\",\"updateEndpoint\":\"source_gb_d9cvs\",\"deleteEndpoint\":\"source_0t_avok9\",\"keyFieldName\":\"id\",\"collectionPath\":\"\",\"keySource\":\"query\",\"keyParamName\":\"id\",\"previewKeyValue\":\"\",\"autoLoad\":true,\"showToolbar\":true,\"gap\":16,\"className\":\"\"},\"events\":{\"onLoad\":\"\",\"onRecordChange\":\"\",\"onFieldChange\":\"\",\"onNewRecord\":\"\",\"onModeChange\":\"\",\"onBeforeSave\":\"\",\"onAfterSave\":\"\",\"onBeforeDelete\":\"\",\"onAfterDelete\":\"\",\"onError\":\"\"},\"bindings\":{},\"children\":[{\"id\":\"cmp_msyfpfia_ifhdq\",\"type\":\"Input\",\"kind\":\"ui\",\"props\":{\"asElement\":\"\",\"disabled\":false,\"invalid\":false,\"rows\":3,\"prefix\":\"Name :\",\"size\":\"md\",\"suffix\":\"\",\"textArea\":false,\"type\":\"text\",\"unstyle\":false,\"field\":\"\",\"form\":\"\",\"className\":\"\",\"children\":\"\",\"style\":{},\"placeholder\":\"Metin girin...\",\"value\":\"\",\"name\":\"\",\"autoComplete\":\"off\",\"autoFocus\":false,\"readOnly\":false,\"required\":false,\"maxLength\":\"\",\"minLength\":\"\",\"pattern\":\"\"},\"events\":{\"onChange\":\"\"},\"bindings\":{\"value\":{\"sourceId\":\"cmp_msyfmegt_jkkn3\",\"path\":\"Name\"}},\"children\":[],\"ref\":\"input1\"}],\"ref\":\"sqlDataSource1\"}],\"canvas\":{\"width\":\"responsive\"},\"lifecycle\":{\"onMount\":\"\"},\"dataSources\":[{\"id\":\"source_5w_5ibpv\",\"name\":\"AbpRoles · GetList\",\"method\":\"GET\",\"url\":\"/api/app/crudendpoint/AbpRoles\",\"responsePath\":\"\"},{\"id\":\"source_i7_msox4\",\"name\":\"AbpRoles · GetById\",\"method\":\"GET\",\"url\":\"/api/app/crudendpoint/AbpRoles/{id}\",\"responsePath\":\"\"},{\"id\":\"source_0t_avok9\",\"name\":\"AbpRoles · Delete\",\"method\":\"DELETE\",\"url\":\"/api/app/crudendpoint/AbpRoles/{id}\",\"responsePath\":\"\"},{\"id\":\"source_h3_484qa\",\"name\":\"AbpRoles · Create\",\"method\":\"POST\",\"url\":\"/api/app/crudendpoint/AbpRoles\",\"responsePath\":\"\"},{\"id\":\"source_gb_d9cvs\",\"name\":\"AbpRoles · Update\",\"method\":\"PUT\",\"url\":\"/api/app/crudendpoint/AbpRoles/{id}\",\"responsePath\":\"\"}]}}", + "Description": "", + "IsActive": true, + "Dependencies": [], + "DataSources": [ + { + "Name": "AbpRoles · GetList", + "Method": "GET", + "Url": "/api/app/crudendpoint/AbpRoles", + "ResponsePath": null, + "EntityName": "AbpRoles", + "OperationType": "GetList", + "SeedFile": "crud/AbpRoles.json" + }, + { + "Name": "AbpRoles · GetById", + "Method": "GET", + "Url": "/api/app/crudendpoint/AbpRoles/{id}", + "ResponsePath": null, + "EntityName": "AbpRoles", + "OperationType": "GetById", + "SeedFile": "crud/AbpRoles.json" + }, + { + "Name": "AbpRoles · Delete", + "Method": "DELETE", + "Url": "/api/app/crudendpoint/AbpRoles/{id}", + "ResponsePath": null, + "EntityName": "AbpRoles", + "OperationType": "Delete", + "SeedFile": "crud/AbpRoles.json" + }, + { + "Name": "AbpRoles · Create", + "Method": "POST", + "Url": "/api/app/crudendpoint/AbpRoles", + "ResponsePath": null, + "EntityName": "AbpRoles", + "OperationType": "Create", + "SeedFile": "crud/AbpRoles.json" + }, + { + "Name": "AbpRoles · Update", + "Method": "PUT", + "Url": "/api/app/crudendpoint/AbpRoles/{id}", + "ResponsePath": null, + "EntityName": "AbpRoles", + "OperationType": "Update", + "SeedFile": "crud/AbpRoles.json" + } + ] + } + ] +} \ No newline at end of file diff --git a/configs/seeds/host/custom/RoleListComponent.json b/configs/seeds/host/custom/RoleListComponent.json new file mode 100644 index 00000000..190f0f43 --- /dev/null +++ b/configs/seeds/host/custom/RoleListComponent.json @@ -0,0 +1,17 @@ +{ + "GeneratedAt": "2026-08-18T00:00:00Z", + "CustomComponents": [ + { + "Name": "RoleListComponent", + "RoutePath": "/admin/roles-list", + "Code": "const RoleListComponent = ({\n title = \"AbpRoles\"\n}) => {\n return (\n \n );\n};\n\nexport default RoleListComponent;", + "Props": null, + "Description": null, + "IsActive": true, + "Dependencies": [ + "DynamicEntityComponent" + ], + "DataSources": [] + } + ] +} \ No newline at end of file diff --git a/ui/src/views/developerKit/ComponentEditor.tsx b/ui/src/views/developerKit/ComponentEditor.tsx index f8fc79da..465247b9 100644 --- a/ui/src/views/developerKit/ComponentEditor.tsx +++ b/ui/src/views/developerKit/ComponentEditor.tsx @@ -261,21 +261,23 @@ const ComponentEditor: React.FC = ({ - - - + +
+ + +
)