Custom Component json dosyasının oluşturulması

This commit is contained in:
Sedat ÖZTÜRK 2026-08-18 12:36:40 +03:00
parent 7870ebb7bc
commit 3152cd681b
19 changed files with 823 additions and 77 deletions

View file

@ -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}/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). | | `{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. | | `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, 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. fakat `SqlDataSeeder` bunları uyarı ile loglar — ilgili klasöre taşınmaları beklenir.

View file

@ -13,6 +13,12 @@ public class CustomComponentDto : FullAuditedEntityDto<Guid>
public string? Description { get; set; } public string? Description { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string? Dependencies { get; set; } // JSON string of component names public string? Dependencies { get; set; } // JSON string of component names
/// <summary>
/// Data sekmesinde kullanilan API'ler; JSON dizisi. Props icindeki designer dokumanindan
/// kaydetme sirasinda sunucu tarafinda uretilir, istemciden gonderilmez.
/// </summary>
public string? DataSources { get; set; }
} }
public class CreateUpdateCustomComponentDto public class CreateUpdateCustomComponentDto

View file

@ -1,7 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Sozsoft.Platform.Data.Seeds;
using Sozsoft.Platform.DeveloperKit; using Sozsoft.Platform.DeveloperKit;
using Sozsoft.Platform.Entities; using Sozsoft.Platform.Entities;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
@ -18,21 +25,72 @@ public class CustomComponentAppService : CrudAppService<
PagedAndSortedResultRequestDto, PagedAndSortedResultRequestDto,
CreateUpdateCustomComponentDto>, ICustomComponentAppService CreateUpdateCustomComponentDto>, ICustomComponentAppService
{ {
public CustomComponentAppService(IRepository<CustomComponent, Guid> 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<CrudEndpoint, Guid> _crudEndpointRepository;
public CustomComponentAppService(
IRepository<CustomComponent, Guid> repository,
IRepository<CrudEndpoint, Guid> crudEndpointRepository,
IConfiguration configuration) : base(repository)
{
_crudEndpointRepository = crudEndpointRepository;
_configuration = configuration;
}
public override async Task<CustomComponentDto> CreateAsync(CreateUpdateCustomComponentDto input)
{
var result = await base.CreateAsync(input);
await SyncDataSourcesAsync(result.Id);
await SyncSeedFileAsync(result.Id);
return result;
} }
public override async Task<CustomComponentDto> UpdateAsync( public override async Task<CustomComponentDto> UpdateAsync(
Guid id, Guid id,
CreateUpdateCustomComponentDto input) CreateUpdateCustomComponentDto input)
{ {
var existingComponent = await Repository.GetAsync(id);
var previousName = existingComponent.Name;
if (input.Props is null) if (input.Props is null)
{ {
var existingComponent = await Repository.GetAsync(id);
input.Props = existingComponent.Props; 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<List<CustomComponentDto>> GetActiveComponentsAsync() public async Task<List<CustomComponentDto>> GetActiveComponentsAsync()
@ -40,5 +98,203 @@ public class CustomComponentAppService : CrudAppService<
var components = await Repository.GetListAsync(x => x.IsActive); var components = await Repository.GetListAsync(x => x.IsActive);
return await MapToGetListOutputDtosAsync(components); return await MapToGetListOutputDtosAsync(components);
} }
/// <summary>
/// Seed dosyasini yazar; dosya islemi hatasi CRUD islemini engellemez, yalnizca loglanir.
/// </summary>
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<string> 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);
}
}
/// <summary>
/// Designer dokumanindaki (Props) Data sekmesi endpoint'lerini cozer, her birini CRUD
/// endpoint kataloguyla eslestirir ve sonucu entity'nin <c>DataSources</c> sutununa yazar.
/// Boylece component'in hangi <c>crud/{EntityName}.json</c> seed dosyalarina bagli oldugu
/// dokuman ayristirmadan sorgulanabilir. Hata CRUD islemini engellemez, yalnizca loglanir.
/// </summary>
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);
}
}
/// <summary>
/// Designer dokumanindaki endpoint'leri veritabanindaki CRUD endpoint kataloguyla eslestirir.
/// </summary>
private async Task<List<CustomComponentSeedDataSourceDto>> 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);
}
/// <summary>Entity'de JSON string olarak tutulan endpoint listesini cozer.</summary>
private static List<CustomComponentSeedDataSourceDto> ParseDataSources(string? dataSources)
{
if (string.IsNullOrWhiteSpace(dataSources))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<CustomComponentSeedDataSourceDto>>(dataSources, JsonReadOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>Designer dokumanindaki tek bir data source kaydi.</summary>
private sealed class DesignerDataSourceDto
{
public string? Name { get; set; }
public string? Method { get; set; }
public string? Url { get; set; }
public string? ResponsePath { get; set; }
}
/// <summary>Entity'de JSON string olarak tutulan bagimlilik listesini cozer.</summary>
private static List<string> ParseDependencies(string? dependencies)
{
if (string.IsNullOrWhiteSpace(dependencies))
{
return [];
}
try
{
return JsonSerializer.Deserialize<List<string>>(dependencies, JsonReadOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>
/// Custom component seed dosyalarinin yazilacagi klasor; kapsam aktif tenant'i izler
/// (host baglaminda <c>host/custom</c>, tenant baglaminda <c>tenants/{tenantId}/custom</c>).
/// </summary>
private string ResolveCustomSeedOutputPath()
{
return SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id, SeedPathResolver.CustomFolder);
}
/// <summary>Component adindan guvenli bir dosya adi uretir; path traversal denemelerini reddeder.</summary>
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;
}
}

View file

@ -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;
/// <summary>
/// Component Manager ile olusturulan custom component'leri
/// <c>{host|tenants/{tenantId}}/custom/*.json</c> dosyalarindan okuyarak veritabanina uygular
/// (kapsam, seed edilen veritabanini izler).
/// <para>
/// Dosyalar component kaydedildikce/silindikce otomatik yazildigi icin kaynak kabul edilir:
/// ayni adli component varsa dosyadaki icerikle guncellenir, yoksa olusturulur.
/// Dosyada olmayan component'ler silinmez.
/// </para>
/// </summary>
public class CustomComponentDataSeeder : IDataSeedContributor, ITransientDependency
{
private static readonly JsonSerializerOptions JsonReadOptions = new()
{
PropertyNameCaseInsensitive = true
};
private static readonly JsonSerializerOptions JsonWriteOptions = new()
{
WriteIndented = true
};
private readonly IRepository<CustomComponent, Guid> _repository;
private readonly IConfiguration _configuration;
private readonly ILogger<CustomComponentDataSeeder> _logger;
public CustomComponentDataSeeder(
IRepository<CustomComponent, Guid> repository,
IConfiguration configuration,
ILogger<CustomComponentDataSeeder> 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<CustomComponentSeedFileDto>(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);
}
/// <summary>
/// Kapsamdaki <c>crud/*.json</c> dosyalarini okuyarak endpoint katalogunu olusturur.
/// Veritabani yerine dosyalardan okunur; boylece seed sirasi CrudEndpointDataSeeder'a bagli kalmaz.
/// </summary>
private List<CustomComponentDataSourceResolver.CrudEndpointReference> ReadCrudEndpointCatalog(
Guid? tenantId,
string scopeFolderName)
{
var references = new List<CustomComponentDataSourceResolver.CrudEndpointReference>();
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<CrudEndpointSeedFileDto>(
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<CustomComponentDataSourceResolver.CrudEndpointReference> 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<string>());
// 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);
}
}

View file

@ -1,4 +1,4 @@
{ {
"Languages": [ "Languages": [
{ {
"cultureName": "ar", "cultureName": "ar",
@ -8802,6 +8802,30 @@
"en": "No dependencies", "en": "No dependencies",
"tr": "Bağımlılık yok" "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", "resourceName": "Platform",
"key": "App.ComponentAction.Edit", "key": "App.ComponentAction.Edit",

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace Sozsoft.Platform.Data.Seeds;
/// <summary>
/// Component'in designer dokumanindaki (Props) Data sekmesi endpoint'lerini cozup
/// CRUD endpoint katalogu ile eslestiren ortak mantik.
/// <para>
/// Ayni cozum hem kaydetme sirasinda (CustomComponentAppService, katalog veritabanindan gelir)
/// hem de seed sirasinda (CustomComponentDataSeeder, katalog <c>crud/*.json</c> dosyalarindan gelir)
/// kullanilir; boylece <c>DataSources</c> sutunu iki yolda da ayni sekilde uretilir.
/// </para>
/// </summary>
public static class CustomComponentDataSourceResolver
{
private static readonly JsonSerializerOptions JsonReadOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>Eslestirmede kullanilan CRUD endpoint tanimi.</summary>
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;
}
/// <summary>
/// Props icindeki <c>visualDesigner.dataSources</c> listesini okur ve her kaydi method + path
/// ile <paramref name="endpoints"/> icinde arar. Eslesmeyen (elle yazilmis ya da custom)
/// endpoint'ler de listede kalir; yalnizca entity/seed dosyasi alanlari bos gelir.
/// </summary>
public static List<CustomComponentSeedDataSourceDto> Resolve(
string? props,
IReadOnlyCollection<CrudEndpointReference> endpoints)
{
var documentSources = ParseDocumentDataSources(props);
if (documentSources.Count == 0)
{
return [];
}
var resolved = new List<CustomComponentSeedDataSourceDto>();
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;
}
/// <summary>Props JSON'undaki designer dokumanindan yalnizca dataSources dizisini ayiklar.</summary>
private static List<DesignerDataSourceDto> 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<List<DesignerDataSourceDto>>(dataSources.GetRawText(), JsonReadOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>Designer dokumanindaki tek bir data source kaydi.</summary>
private sealed class DesignerDataSourceDto
{
public string? Name { get; set; }
public string? Method { get; set; }
public string? Url { get; set; }
public string? ResponsePath { get; set; }
}
}

View file

@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
namespace Sozsoft.Platform.Data.Seeds;
/// <summary>
/// Bir custom component'in seed dosyasi.
/// Component Manager uzerinden kaydedildiginde/silindiginde
/// <c>{host|tenants/{tenantId}}/custom/{Name}.json</c> olarak yazilir; veritabani silinip
/// yeniden olusturuldugunda <c>CustomComponentDataSeeder</c> ayni dosyalari okuyarak
/// component'leri geri yukler.
/// <para>
/// Dosya duzeni bilincli olarak TenantData.json'daki <c>CustomComponents</c> bloguyla aynidir;
/// boylece eski seed icerigi dogrudan tasinabilir. Liste birden fazla component tasiyabilse de
/// senkronizasyon her component icin tek dosya yazar.
/// </para>
/// </summary>
public class CustomComponentSeedFileDto
{
/// <summary>Dosyanin en son yazildigi an (UTC) — yalnizca bilgi amaclidir.</summary>
public DateTime GeneratedAt { get; set; }
public List<CustomComponentSeedItemDto> CustomComponents { get; set; } = [];
}
/// <summary>Component'in seed edilebilir tam tanimi; dosya adi <see cref="Name"/> ile ayni olmasi beklenir.</summary>
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;
/// <summary>Bagimli olunan component adlari; veritabaninda JSON string olarak tutulur.</summary>
public List<string> Dependencies { get; set; } = [];
/// <summary>
/// Component'in Data sekmesinde kullandigi API'ler; veritabaninda JSON string olarak tutulur.
/// Kaydetme sirasinda designer dokumanindan otomatik uretilir.
/// </summary>
public List<CustomComponentSeedDataSourceDto> DataSources { get; set; } = [];
}
/// <summary>
/// Component'in kullandigi tek bir API. <see cref="EntityName"/> dolu ise endpoint
/// CRUD Endpoint Manager tarafindan uretilmistir ve tanimi <see cref="SeedFile"/>
/// (kapsam klasorune gore <c>crud/{EntityName}.json</c>) dosyasindadir.
/// </summary>
public class CustomComponentSeedDataSourceDto
{
/// <summary>Designer'daki gorunur ad.</summary>
public string Name { get; set; } = string.Empty;
public string Method { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
/// <summary>Yanit icindeki liste yolu; bos ise yanitin kendisi kullanilir.</summary>
public string? ResponsePath { get; set; }
/// <summary>Endpoint bir CRUD endpoint'ine denk geliyorsa entity adi, aksi halde null.</summary>
public string? EntityName { get; set; }
/// <summary>CRUD endpoint'inin islem turu (GetAll, GetById, Create, Update, Delete).</summary>
public string? OperationType { get; set; }
/// <summary>Endpoint tanimini tasiyan seed dosyasinin kapsam klasorune gore yolu.</summary>
public string? SeedFile { get; set; }
}

View file

@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
@ -72,7 +72,7 @@ public static class SeedPathResolver
/// <summary>Crud endpoint konfigurasyon dosyalari.</summary> /// <summary>Crud endpoint konfigurasyon dosyalari.</summary>
public const string CrudFolder = "crud"; public const string CrudFolder = "crud";
/// <summary>Custom endpoint konfigurasyon dosyalari.</summary> /// <summary>Custom component konfigurasyon dosyalari.</summary>
public const string CustomFolder = "custom"; public const string CustomFolder = "custom";
private const string RepositorySeedsFolder = "seeds"; private const string RepositorySeedsFolder = "seeds";

View file

@ -16,7 +16,15 @@ public class CustomComponent : FullAuditedEntity<Guid>, IMultiTenant
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string? Dependencies { get; set; } // JSON string of component names 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) /// <summary>
/// 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 <c>crud/{EntityName}.json</c> seed dosyasi) tasir.
/// Props icindeki designer dokumanindan turetilir; kaydetme sirasinda otomatik yazilir.
/// </summary>
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; Name = name;
RoutePath = routePath; RoutePath = routePath;
@ -25,6 +33,7 @@ public class CustomComponent : FullAuditedEntity<Guid>, IMultiTenant
Description = description; Description = description;
IsActive = isActive; IsActive = isActive;
Dependencies = dependencies; Dependencies = dependencies;
DataSources = dataSources;
} }
} }

View file

@ -670,6 +670,8 @@ public class PlatformDbContext :
b.Property(x => x.Props).HasColumnType("nvarchar(max)"); b.Property(x => x.Props).HasColumnType("nvarchar(max)");
b.Property(x => x.Description).HasMaxLength(512); b.Property(x => x.Description).HasMaxLength(512);
b.Property(x => x.Dependencies).HasMaxLength(2048); 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.Name }).IsUnique().HasFilter("[IsDeleted] = 0");
b.HasIndex(x => new { x.TenantId, x.RoutePath }).IsUnique().HasFilter("[IsDeleted] = 0"); b.HasIndex(x => new { x.TenantId, x.RoutePath }).IsUnique().HasFilter("[IsDeleted] = 0");

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations namespace Sozsoft.Platform.Migrations
{ {
[DbContext(typeof(PlatformDbContext))] [DbContext(typeof(PlatformDbContext))]
[Migration("20260817083025_Initial")] [Migration("20260818091734_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -1921,6 +1921,9 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("uniqueidentifier") .HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId"); .HasColumnName("CreatorId");
b.Property<string>("DataSources")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("DeleterId") b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier") .HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId"); .HasColumnName("DeleterId");

View file

@ -1185,6 +1185,7 @@ namespace Sozsoft.Platform.Migrations
Description = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true), Description = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false), IsActive = table.Column<bool>(type: "bit", nullable: false),
Dependencies = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true), Dependencies = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
DataSources = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false), CreationTime = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true), CreatorId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true), LastModificationTime = table.Column<DateTime>(type: "datetime2", nullable: true),

View file

@ -1918,6 +1918,9 @@ namespace Sozsoft.Platform.Migrations
.HasColumnType("uniqueidentifier") .HasColumnType("uniqueidentifier")
.HasColumnName("CreatorId"); .HasColumnName("CreatorId");
b.Property<string>("DataSources")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("DeleterId") b.Property<Guid?>("DeleterId")
.HasColumnType("uniqueidentifier") .HasColumnType("uniqueidentifier")
.HasColumnName("DeleterId"); .HasColumnName("DeleterId");

View file

@ -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<DynamicEntityComponentProps> = ({ title }) => {\n const [data, setData] = useState<Array<{ id: string; name: string }>>([]);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(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 <div>Loading...</div>;\n if (error) return <div className=\"text-red-600 dark:text-red-400\">Error: {error}</div>;\n if (!data.length) return <div>No records found</div>;\n\n const headers = [\"id\", \"name\", \"actions\"];\n\n return (\n <div className=\"overflow-auto\">\n <table className=\"min-w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 shadow-sm rounded-lg\">\n <thead className=\"bg-slate-100 dark:bg-slate-800\">\n <tr>\n {headers.map((key) => (\n <th\n key={key}\n className=\"text-left px-4 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-medium text-slate-700 dark:text-slate-200\"\n >\n {key === \"actions\" ? \"Actions\" : key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {data.map((item, rowIndex) => (\n <tr key={item.id ?? rowIndex} className=\"hover:bg-slate-50 dark:hover:bg-slate-800\">\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800 text-sm text-slate-800 dark:text-slate-100\">\n {item.id}\n </td>\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800 text-sm text-slate-800 dark:text-slate-100\">\n {item.name}\n </td>\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800\">\n <Button\n type=\"button\"\n color=\"blue-500\"\n onClick={() => alert(item.name)}\n shape=\"round\"\n size=\"xs\"\n variant=\"solid\"\n id=\"c_mrix4c1a_qrybhk\"\n >\n Show Name\n </Button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\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 <DynamicEntityComponent id=\"c_mdljvvmq_fno52v\" title={title} />\n );\n};\n\nexport default RoleListComponent;",
"props": null,
"description": null,
"isActive": true,
"dependencies": ["DynamicEntityComponent"]
}
],
"Abouts": [ "Abouts": [
{ {
"stats": [ "stats": [

View file

@ -25,7 +25,6 @@ public class TenantSeederDto
//Saas //Saas
public List<GlobalSearchSeedDto> GlobalSearch { get; set; } public List<GlobalSearchSeedDto> GlobalSearch { get; set; }
public List<CustomEndpointSeedDto> CustomEndpoints { get; set; } public List<CustomEndpointSeedDto> CustomEndpoints { get; set; }
public List<CustomComponentSeedDto> CustomComponents { get; set; }
public List<SequenceSeedDto> Sequences { get; set; } public List<SequenceSeedDto> Sequences { get; set; }
//Definitions //Definitions
@ -340,17 +339,6 @@ public class InstallmentOptionSeedDto
public decimal Commission { get; set; } 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<string> Dependencies { get; set; } = new();
}
public class ReportTemplateSeedDto public class ReportTemplateSeedDto
{ {
public string Name { get; set; } public string Name { get; set; }
@ -479,7 +467,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
private readonly IRepository<Branch, Guid> _branchRepository; private readonly IRepository<Branch, Guid> _branchRepository;
private readonly IRepository<GlobalSearch, int> _globalSearch; private readonly IRepository<GlobalSearch, int> _globalSearch;
private readonly IRepository<CustomEndpoint, Guid> _customEndpointRepository; private readonly IRepository<CustomEndpoint, Guid> _customEndpointRepository;
private readonly IRepository<CustomComponent, Guid> _customComponentRepository;
private readonly IRepository<ReportCategory, Guid> _reportCategoriesRepository; private readonly IRepository<ReportCategory, Guid> _reportCategoriesRepository;
private readonly IRepository<ReportTemplate, Guid> _reportTemplatesRepository; private readonly IRepository<ReportTemplate, Guid> _reportTemplatesRepository;
private readonly IRepository<Home, Guid> _homeRepository; private readonly IRepository<Home, Guid> _homeRepository;
@ -535,7 +522,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
IRepository<Skill, string> skillRepository, IRepository<Skill, string> skillRepository,
IRepository<SkillLevel, string> skillLevelRepository, IRepository<SkillLevel, string> skillLevelRepository,
IRepository<CustomEndpoint, Guid> customEndpointRepository, IRepository<CustomEndpoint, Guid> customEndpointRepository,
IRepository<CustomComponent, Guid> customComponentRepository,
IRepository<ReportCategory, Guid> reportCategoriesRepository, IRepository<ReportCategory, Guid> reportCategoriesRepository,
IRepository<ReportTemplate, Guid> reportTemplatesRepository, IRepository<ReportTemplate, Guid> reportTemplatesRepository,
IRepository<Home, Guid> homeRepository, IRepository<Home, Guid> homeRepository,
@ -589,7 +575,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
_productRepository = productRepository; _productRepository = productRepository;
_paymentMethodRepository = paymentMethodRepository; _paymentMethodRepository = paymentMethodRepository;
_installmentOptionRepository = installmentOptionRepository; _installmentOptionRepository = installmentOptionRepository;
_customComponentRepository = customComponentRepository;
_reportCategoriesRepository = reportCategoriesRepository; _reportCategoriesRepository = reportCategoriesRepository;
_reportTemplatesRepository = reportTemplatesRepository; _reportTemplatesRepository = reportTemplatesRepository;
_homeRepository = homeRepository; _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) foreach (var item in items.ReportCategories)
{ {
var exists = await _reportCategoriesRepository.AnyAsync(x => x.Name == item.Name); var exists = await _reportCategoriesRepository.AnyAsync(x => x.Name == item.Name);

View file

@ -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<DynamicEntityComponentProps> = ({ title }) => {\n const [data, setData] = useState<Array<{ id: string; name: string }>>([]);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(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 <div>Loading...</div>;\n if (error) return <div className=\"text-red-600 dark:text-red-400\">Error: {error}</div>;\n if (!data.length) return <div>No records found</div>;\n\n const headers = [\"id\", \"name\", \"actions\"];\n\n return (\n <div className=\"overflow-auto\">\n <table className=\"min-w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 shadow-sm rounded-lg\">\n <thead className=\"bg-slate-100 dark:bg-slate-800\">\n <tr>\n {headers.map((key) => (\n <th\n key={key}\n className=\"text-left px-4 py-2 border-b border-slate-200 dark:border-slate-700 text-sm font-medium text-slate-700 dark:text-slate-200\"\n >\n {key === \"actions\" ? \"Actions\" : key}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {data.map((item, rowIndex) => (\n <tr key={item.id ?? rowIndex} className=\"hover:bg-slate-50 dark:hover:bg-slate-800\">\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800 text-sm text-slate-800 dark:text-slate-100\">\n {item.id}\n </td>\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800 text-sm text-slate-800 dark:text-slate-100\">\n {item.name}\n </td>\n <td className=\"px-4 py-2 border-b border-slate-100 dark:border-slate-800\">\n <Button\n type=\"button\"\n color=\"blue-500\"\n onClick={() => alert(item.name)}\n shape=\"round\"\n size=\"xs\"\n variant=\"solid\"\n id=\"c_mrix4c1a_qrybhk\"\n >\n Show Name\n </Button>\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n};\n\nexport default DynamicEntityComponent;",
"Props": null,
"Description": null,
"IsActive": true,
"Dependencies": [],
"DataSources": []
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -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 <DynamicEntityComponent id=\"c_mdljvvmq_fno52v\" title={title} />\n );\n};\n\nexport default RoleListComponent;",
"Props": null,
"Description": null,
"IsActive": true,
"Dependencies": [
"DynamicEntityComponent"
],
"DataSources": []
}
]
}

View file

@ -261,8 +261,9 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
</div> </div>
</div> </div>
</Form> </Form>
<Dialog.Footer className="flex items-center justify-end gap-2 border-t border-slate-200 pt-3 dark:border-slate-700"> <Dialog.Footer className="flex flex-col items-stretch gap-2 border-t border-slate-200 pt-3 dark:border-slate-700">
<Button type="button" size="sm" onClick={onClose} disabled={isSubmitting}> <div className="flex items-center justify-end gap-2">
<Button type="button" size="sm" disabled={isSubmitting} onClick={onClose}>
{translate('::App.Platform.Cancel')} {translate('::App.Platform.Cancel')}
</Button> </Button>
<Button <Button
@ -276,6 +277,7 @@ const ComponentEditor: React.FC<ComponentEditorProps> = ({
<FaRegSave className="h-4 w-4" /> <FaRegSave className="h-4 w-4" />
{isSubmitting ? translate('::App.Platform.Saving') : translate('::App.Platform.Save')} {isSubmitting ? translate('::App.Platform.Saving') : translate('::App.Platform.Save')}
</Button> </Button>
</div>
</Dialog.Footer> </Dialog.Footer>
</> </>
) )