Custom Component ve Wizard Import/Export özelliği

This commit is contained in:
Sedat ÖZTÜRK 2026-09-05 21:20:18 +03:00
parent 54e3390cbf
commit 59ec8950a3
46 changed files with 1627 additions and 1770 deletions

View file

@ -294,8 +294,8 @@ the Wizard's `Custom` path.
per-command Form permissions (`selectPermission`, `insertPermission`, `updatePermission`,
`deletePermission`). In `Otomatik` mode the base is the read permission the Wizard created for
the component and the commands get `''` / `.Create` / `.Update` / `.Delete` suffixes.
- Manager screen permissions: `App.DeveloperKit.Components{,.Create,.Update,.Delete}`.
- Every save/delete writes `configs/seeds/{scope}/custom/{Name}.json`; the endpoints it uses are
- List screen permissions: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete}`.
- Every save/delete is mirrored into `configs/seeds/{scope}/data/App.DeveloperKit.CustomComponents.json`; the endpoints it uses are
referenced through `crud/{Entity}.json`. A component that is not seed-backed is not done.
### 10.2 SQL View Designer
@ -655,15 +655,16 @@ a deploy. AI must learn and teach implementation flow primarily from these asset
- PermissionsData.json
- HostData.json
- LanguagesData.json
- WizardDataSeeder.cs / CustomComponentDataSeeder.cs / CrudDataSeeder.cs — the seeders that read
- WizardDataSeeder.cs / CrudDataSeeder.cs — the seeders that read
surface B below
**B. Runtime seeds (written by the running application)** — `configs/seeds/`, resolved through
`SeedPathResolver`. The Wizard, Component Manager and CRUD Endpoint Manager write here; the
`SeedPathResolver`. The Wizard, the Custom Components screen / Visual Designer and the CRUD
Endpoint Manager write here; the
seeders above restore them when the database is recreated. Scope folders mirror the CDN layout:
```
configs/seeds/host/{wizard,custom,crud,sql/{object,execute},postgres/{object,execute}}
configs/seeds/host/{wizard,data,crud,sql/{object,execute},postgres/{object,execute}}
configs/seeds/tenants/{tenantId}/… same layout, tenant scope
configs/seeds/.imports/{importId}/… wizard import staging + backup (not scanned)
```
@ -891,7 +892,8 @@ Exporting a wizard produces a zip whose paths are relative to the scope folder
(`host` / `tenants/{tenantId}`) and which carries every dependency:
- `wizard/{file}.json`
- `custom/{component}.json` (with its dependencies) — `Custom` path
- `data/App.DeveloperKit.CustomComponents.json``Custom` path; the file is filtered down to the
exported component and its dependency chain, never exported whole
- `crud/{entity}.json` — endpoints those components use
- `{sql|postgres}/{object|execute}/{object}.sql` — the object behind `SelectCommand` on the
`List` path
@ -899,9 +901,29 @@ Exporting a wizard produces a zip whose paths are relative to the scope folder
Import is two-phase: `AnalyzeImport` (unzip to `.imports/{id}/staged`, classify each entry as
`New` / `Identical` / `Conflict`, conflicts resolved in a diff editor) then `ApplyImport`
(file-by-file write, previous content backed up so `RollbackImport` can undo the whole batch),
closed by `CompleteImport`. Limits: allowed root folders `wizard`, `crud`, `custom`, `sql`,
`postgres` (and only `object` / `execute` under the SQL provider folders); 5 MB per entry, 50 MB
per archive, 500 entries. Permissions: `App.Listforms.Wizard.Export` / `.Import`.
closed by `CompleteImport`.
A `data/*.json` entry is compared and merged **by row, not by file**: the diff shows only the
rows whose keys the archive carries (file-level `GeneratedAt`/`Order` are excluded), so unrelated
records never surface as a difference — no matching row means `New` (insert), a matching identical
row means `Identical` (nothing is transferred), a matching different row means `Conflict` (update).
The file itself is never overwritten: rows are merged into the target by key (incoming row wins,
target-only rows are kept). `CompleteImport` then applies exactly the
`data/` files it wrote to the database through `IListFormSeedDataApplier` with
`overwriteExisting: true` — a missing row is inserted, an existing one is updated from the file
(unlike migrate/seed, where a possibly stale file must not overwrite live data), and a row that was
soft deleted is revived by clearing the columns the screen's `DeleteCommand` sets
(`IsDeleted`/`DeletionTime`/`DeleterId`), otherwise the default filter would keep hiding it — and
reports the inserted/updated/skipped counts. Data files that were already identical to the target
are applied too: there was nothing to write, but the rows may be missing or soft deleted in the
database. A file the user chose to keep (different, not written) is left alone. This step is not
covered by `RollbackImport`, which only restores files and must be run before it. The other
folders (`wizard`, `crud`, `sql`) stay as files and reach the database through the seeders or a
wizard deploy.
Limits: allowed root folders `wizard`, `crud`, `data`, `sql`, `postgres` (and only `object` /
`execute` under the SQL provider folders); 5 MB per entry, 50 MB per archive, 500 entries.
Permissions: `App.Listforms.Wizard.Export` / `.Import`.
AI must not propose moving a screen between environments by copying a single json file.

View file

@ -8,7 +8,7 @@ This document summarizes the rules, standards, and step-by-step instructions for
> **Bu doküman depo içi seeder'larla (DbMigrator/Migrations) modül eklemeyi anlatır.**
> Çalışma zamanında Wizard / Component Manager / CRUD Endpoint Manager ile üretim yapacaksan
> Çalışma zamanında Wizard / Custom Components / CRUD Endpoint Manager ile üretim yapacaksan
> `lowcode.instructions.md` esastır; oradaki seed dosyaları `configs/seeds` altına düşer.
## General Principles

View file

@ -131,7 +131,7 @@ ve çoğu isteğin cevabı aslında oradadır:
| `CustomJsSourcesJson` · `CustomStyleSourcesJson` | Sayfaya enjekte edilen JS/CSS | §5.5 |
| `AdaptiveLayoutJson` | Dar ekranda öğe gizleme (grafik) | §8.4 |
| `CommonJson` · `DataSourceJson` | Grafik geneli ve grafik sorgusu | §8.2 · §8.3 |
| `Width` · `Height` · `FullHeight` | Ekran boyutu | — |
| `Width` · `Height` · `FullHeight` | Ekran boyutu. `Height > 0` sabit piksel yüksekliği verir. Grid/Tree/Gantt/Scheduler'da `FullHeight` bayrağı görünümü `calc(100vh - …)` ile sayfaya yayar; **TodoBoard** bayrağa bakmaz, `Height` verilmediyse panoyu her zaman sayfanın kalan yüksekliğine yayar (kolonlar kendi içinde kayar), alt form olarak açıldığında ise içeriği kadar yer kaplar. | — |
| `ListFormType` · `IsSubForm` · `SubFormsListFormType` · `ShowNote` | Ekranın türü ve alt form davranışı | §5.6 |
| `CultureName` · `UserId` · `RoleId` | Aynı ekranın dil/kullanıcı/rol varyantı | §5.7 |
@ -531,6 +531,9 @@ ListForm editöründeki tipli formdan düzenlenemez; kalıcı kullanılacaksa C#
`PriorityExpr`, `CompletedExpr`, `OrderExpr`, `TimesheetExpr`, `SubTaskExpr`,
`StatusOrder` (kolon sırası, virgüllü), `AllowDragging` (varsayılan `true`).
Panonun yüksekliği `TodoOptionJson` ile değil ekranın `Height` kolonuyla belirlenir; boş
bırakıldığında pano sayfanın kalan yüksekliğini kaplar ve her kolon kendi içinde kayar.
> `*` işaretli alan boşsa seeder o görünümün JSON'unu **yazmaz**; bayrağı açmak yetmez.
---
@ -1183,7 +1186,7 @@ soneklerini alır.
| **Developer Kit SQL Query Manager** | `App.SqlQueryManager` · `/admin/sqlQueryManager` | — | — | View/procedure/function yazma ve çalıştırma; karar sırasının 2. adımı buradan başlar. |
| **Developer Kit Custom Endpoints** | `App.DeveloperKit.CustomEndpoints` | `Sas_H_CustomEndpoint` | tenant | `Name`, `Url`, `Method`, `DataSourceCode`, `Sql`, `ParametersJson`, `PermissionsJson` — parametreli SQL'i yetkili bir HTTP ucuna çevirir. |
| **Developer Kit Dynamic Services** | `App.DeveloperKit.DynamicServices` · `/admin/list/App.DeveloperKit.DynamicServices` | `Sas_H_DynamicService` | host | Liste ListForm ekranıdır; kod, listenin toolbar/satır butonlarından açılan `DynamicServiceEditor` diyalogunda (Monaco) yazılıp derlenir ve yayınlanır. SQL'in yetmediği iş kuralı için 3. adım. |
| **Developer Kit Components** | `App.DeveloperKit.Components` · `/admin/developerkit/components` | `Sas_H_CustomComponent` | host | Custom component listesi ve **Visual Designer** girişi (§10). |
| **Developer Kit Components** | `App.DeveloperKit.CustomComponents` · `/admin/list/App.DeveloperKit.CustomComponents` | `Sas_H_CustomComponent` | host | Custom component listesi (ListForm) ve satırdaki **Design** butonuyla **Visual Designer** girişi (§10). Kayıtlar `data/App.DeveloperKit.CustomComponents.json` dosyasına senkronlanır. |
| **Developer Kit ListForm** | `App.Listforms.Listform` | `Sas_H_ListForm` · `Sas_H_ListFormField` · `Sas_H_ListFormWorkflow` · `Sas_H_ListFormCustomization` · `Sas_H_ListFormImport` | host | Ekran tanımlarının kendisi. Bu dosyada anlatılan bütün JSON kolonlarının canlı hâli; düzenleyicisi `/admin/listform/edit/{kod}`. |
| **Developer Kit Wizard Manager** | menü kodu `App.Listforms.WizardManager` · yetki `App.Listforms.Wizard` · `/admin/listform/wizardManager` | — | host | Wizard seed dosyalarının listesi, düzenlemesi, silinmesi, export/import'u. Sihirbazın kendisi ayrı bir rotadadır: `/admin/listform/wizard`. |
| **Forum Management** | `App.ForumManagement` (grup menüsü) | `Sas_T_ForumCategory` · `Sas_T_ForumTopic` · `Sas_T_ForumPost` | tenant | Altında Categories / Topics / Posts ListForm ekranları; yayın tarafı Administration'daki `/admin/forum`. |
@ -1272,7 +1275,7 @@ görünürlük `checkPermission` ile bileşen içinde kurulur (`lowcode` §7.5).
| `/admin/listform/wizardManager` | `listForm/wizard/WizardFileManager` | `App.Listforms.Wizard` |
| `/admin/listform/edit/:listFormCode` | `listForm/edit/FormEdit` | `[]` — yetki ListForm tarafında |
| `/admin/sqlQueryManager` | `developerKit/SqlQueryManager` | `App.SqlQueryManager` |
| `/admin/developerkit/components{,/edit/:id}` | `ComponentManagerPage` · `ComponentCodeLayout` | `App.DeveloperKit.Components` |
| `/admin/developerkit/components/design/:id` | `ComponentCodeLayout` (liste tarafı ListForm ekranıdır) | `App.DeveloperKit.CustomComponents` |
| `/admin/reports/:report/{view,design}/:id?/:listFormCode?` | `DevexpressReportViewer` · `DevexpressReportDesigner` | — |
| `/admin/public/{home,about,services,contact}/designer` | ilgili public sayfa (tasarım modu) | `App.Home` · `App.About` · `App.Services` · `App.Contact` |
| `/admin/settings` | `settings/Settings` | `App.Setting` |

View file

@ -32,7 +32,7 @@ Bir istek geldiğinde çekirdek koda dokunmak değil, `configs/seeds/` altına u
## 0. Üretim protokolü (her prompt için zorunlu)
Kullanıcı "şöyle bir ekran/form/süreç istiyorum" dediğinde **uygulamayı kullanıyormuş gibi**
davran: Wizard'ın, Component Manager'ın ve SQL Query Manager'ın diske yazacağı dosyaların
davran: Wizard'ın, Custom Components ekranının ve SQL Query Manager'ın diske yazacağı dosyaların
aynılarını `configs/seeds/host/` altına sen yaz. Ekranlara tıklamanın yerine geçen şey budur.
Talep iki biçimde gelir ve ikisi de aynı hatta girer:
@ -52,9 +52,10 @@ configs/seeds/host/
├── sql/execute/{Ad}.sql ← bir kez çalışacak script (veri dolumu, migrasyon)
├── postgres/object|execute/… ← aynısının PostgreSQL diyalekti (hedef PG ise)
├── crud/{EntityName}.json ← tablonun REST uçları
├── custom/{ComponentName}.json ← Custom Component (Visual Designer dokümanı ya da kod)
├── wizard/{WizardName}.json ← ekran + menü + yetki + dil anahtarları
└── data/{ListFormCode}.json ← ekrandan girilen liste verisinin aynası (opsiyonel)
Custom Component'ler de burada yaşar:
data/App.DeveloperKit.CustomComponents.json
```
Tenant kapsamı isteniyorsa `host/` yerine `tenants/{tenantId}/`; iç düzen aynıdır.
@ -65,7 +66,7 @@ Tenant kapsamı isteniyorsa `host/` yerine `tenants/{tenantId}/`; iç düzen ayn
| --- | --- | --- | --- |
| 1 | Tablo / view | `sql/object/…` | Ekranın bağlanacağı nesne önce var olmalı |
| 2 | CRUD uçları (gerekiyorsa) | `crud/{Entity}.json` | Custom Component'in data source'ları buna bakar |
| 3 | Custom Component (gerekiyorsa) | `custom/{Name}.json` | Wizard `Custom` yolunda bileşen adını arar |
| 3 | Custom Component (gerekiyorsa) | `data/App.DeveloperKit.CustomComponents.json` | Wizard `Custom` yolunda bileşen adını arar |
| 4 | Wizard | `wizard/{Ad}.json` | Menü, yetki, dil anahtarı ve ListForm'u üretir |
| 5 | Alt ekranlar (varsa) | ek `wizard/…` dosyaları | Ana wizard'dan **önceki** `SeededAt` damgasını alır |
| 6 | Liste verisi (isteniyorsa) | `data/{ListFormCode}.json` | Ekran ve tablo hazır olmalı; `ListForm.SeedFilePath` bu yolu gösterir |
@ -105,7 +106,7 @@ Uydurma. Şu üçünü mutlaka kontrol et:
| JSON alan adları | **PascalCase** (`Wizard`, `ListFormCode`, `Groups`) — seeder `PropertyNameCaseInsensitive` okur ama dosyalar PascalCase üretilir |
| Custom component dosyası | Kök alanlar PascalCase; `Props` **içindeki** designer dokümanı camelCase (`visualDesigner`, `nodes`, `sourceMode`) |
| Enum'lar | Sayı olarak yazılır (§3) |
| `GeneratedAt` | ISO-8601 UTC (`2026-08-26T10:15:00Z`) — yalnızca bilgi amaçlı. **Yalnızca `custom/` ve `crud/` dosyalarında vardır**; wizard seed dosyasında karşılığı `Wizard.SeededAt` alanıdır |
| `GeneratedAt` | ISO-8601 UTC (`2026-08-26T10:15:00Z`) — yalnızca bilgi amaçlı. **Yalnızca `crud/` ve `data/` dosyalarında vardır**; wizard seed dosyasında karşılığı `Wizard.SeededAt` alanıdır |
| İç içe JSON | `EditorOptions`, `EditorScript`, `LookupQuery` → JSON **string** olarak kaçışlanır |
| Satır sonu | LF |
@ -1994,20 +1995,26 @@ ListForm ile ifade edilemeyen ekranın yolu **fiziksel React sayfası değil**,
### 7.1 Seed dosyası
`configs/seeds/{kapsam}/custom/{Name}.json`
`configs/seeds/{kapsam}/data/App.DeveloperKit.CustomComponents.json` — Custom Components
ekranının veri dosyası. Bileşenin **kendi dosyası yoktur**; her bileşen bu dosyada bir satırdır
ve dosya `data/{ListFormCode}.json` sözleşmesini (§0.1) izler.
```jsonc
{
"ListFormCode": "App.DeveloperKit.CustomComponents",
"KeyFieldName": "Id",
"GeneratedAt": "2026-08-20T11:23:45Z",
"CustomComponents": [
"Order": 0,
"Rows": [
{
"Id": "8389ccbc-3e7b-738d-35aa-3a237eea9b9d",
"Name": "OrderBoard",
"RoutePath": "/admin/order-board",
"Code": "/*__SOZSOFT_VISUAL_DESIGNER__<url-encoded designer doc>__*/\nconst OrderBoard = () => { … }\n\nexport default OrderBoard",
"Props": "{\"visualDesigner\":{ … }}",
"Description": "",
"IsActive": true,
"Dependencies": ["OrderCard"],
"Code": "/*__SOZSOFT_VISUAL_DESIGNER__<url-encoded designer doc>__*/\nconst OrderBoard = () => { … }\n\nexport default OrderBoard",
"Props": "{\"visualDesigner\":{ … }}",
"DataSources": [
{
"Name": "Mrp_T_Order — GetList",
@ -2024,6 +2031,9 @@ ListForm ile ifade edilemeyen ekranın yolu **fiziksel React sayfası değil**,
}
```
- `Id` satırın anahtarıdır (`KeyFieldName`). Yeni bir bileşen yazarken üret; import ve seed
eşleşmesi bu değer üzerinden yapılır.
- Yeni bir bileşen dosyaya **satır olarak eklenir**; var olan satırlar korunur.
- `DataSources` elle yazılmaz: kaydetme sırasında `CustomComponentDataSourceResolver`
tasarımcı dokümanındaki `dataSources` listesini `method + path` ile CRUD endpoint kataloğunda
arayarak üretir. Eşleşenler `EntityName` + `SeedFile` taşır; elle yazılmış endpoint'ler listede
@ -2346,7 +2356,7 @@ Form düğümünün `id`'si) ve Save/Delete üzerinden geri yazar. Olaylar: `onL
- `Özel` mod: yetki adı elle yazılır.
- Boş bırakılan komut serbesttir.
Manager ekranı yetkileri: `App.DeveloperKit.Components{,.Create,.Update,.Delete}`.
Liste ekranı yetkileri: `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete}`.
### 7.6 Bileşeni menüye bağlama
@ -2397,7 +2407,7 @@ Custom yolunda `ListForm` **üretilmez**; `Title`/`Desc` dil anahtarları da ür
Bu çift, custom component'in menüye nasıl bağlandığının çalışan örneğidir. İncelenecek dosyalar:
```
configs/seeds/host/custom/RoleComponent.json
configs/seeds/host/data/App.DeveloperKit.CustomComponents.json ← RoleComponent satırı
configs/seeds/host/crud/AbpRoles.json · AbpUsers.json · AbpUserRoles.json
configs/seeds/host/wizard/RoleList.json
```
@ -2438,14 +2448,22 @@ butonu zip üretir:
```
wizard/{dosya}.json
custom/{component}.json (bağımlılıklarıyla)
data/App.DeveloperKit.CustomComponents.json (component + bağımlılıklarına süzülmüş kopya)
crud/{entity}.json (component'lerin kullandığı endpoint'ler)
{sql|postgres}/{object|execute}/{nesne}.sql (List yolunda SelectCommand'ın nesnesi)
```
Import iki adımlıdır: analiz (`New`/`Identical`/`Conflict`, çakışmalar diff editörde çözülür) →
uygulama (dosya dosya yazım, önceki hâl yedeklenir, `RollbackImport` ile toplu geri alma).
Sınırlar: yalnızca `wizard`, `crud`, `custom`, `sql`, `postgres` kök klasörleri (SQL sağlayıcı
Import üç adımlıdır: analiz (`New`/`Identical`/`Conflict`, çakışmalar diff editörde çözülür;
`data/*.json` satır bazlı karşılaştırılır — yalnızca zip'in taşıdığı anahtarlar, dolayısıyla
ilgisiz component'ler fark olarak görünmez) →
uygulama (dosya dosya yazım, önceki hâl yedeklenir, `RollbackImport` ile toplu geri alma) →
kapanış (`CompleteImport`: yazılan `data/*.json` dosyaları `ListFormSeedDataApplier` ile
veritabanına uygulanır — tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle
güncellenir, soft delete edilmiş satırın `IsDeleted`/`DeletionTime`/`DeleterId` izleri temizlenir. Bu adım rollback kapsamında değildir; `wizard`/`crud`/`sql` dosyaları seeder ya da
deploy bekler).
`data/*.json` dosyaları kapsamdaki tüm satırları taşıdığı için üzerine yazılmaz: satırlar anahtar
alan üzerinden birleştirilir (gelen satır varsa güncellenir, yoksa eklenir, hedefteki diğer satırlar
korunur). Sınırlar: yalnızca `wizard`, `crud`, `data`, `sql`, `postgres` kök klasörleri (SQL sağlayıcı
klasörlerinin altında yalnızca `object`/`execute`); 5 MB/dosya, 50 MB/arşiv, 500 girdi.
Yetkiler: `App.Listforms.Wizard.Export` / `.Import`.
@ -2460,7 +2478,7 @@ ref'iyle üretildiyse geri okuma da onunla yapılır.
Script'ler `Props.visualDesigner` içinde saklanır ve kod üreticisi tarafından tek argümanlı
(`event`) handler gövdesine gömülür. Metin gömüldüğü için **her artefakt gibi seed dosyasına da
yazılır**; `custom/{Name}.json` güncellenmeden bırakılan bir script veritabanı sıfırlandığında
yazılır**; Custom Components ekranının seed verisi güncellenmeden bırakılan bir script veritabanı sıfırlandığında
geri gelmez.
#### Olaylar
@ -3029,7 +3047,7 @@ bağlıdır; hiçbiri "kod yaz" adımının yerine geçmez, hepsi ondan öncedir
| **SQL Query Manager** | `/admin/sqlQueryManager` | `App.SqlQueryManager` | SQL nesneleri, CRUD endpoint'ler | 9.5 |
| **Custom Endpoints** | `/admin/list/App.DeveloperKit.CustomEndpoints` | `App.DeveloperKit.CustomEndpoints` | Elle yazılmış SQL'den REST endpoint | 9.2 |
| **Dynamic Services** | `/admin/list/App.DeveloperKit.DynamicServices` | `App.DeveloperKit.DynamicServices` | Runtime derlenen C# AppService | 9.3 |
| **Components** | `/admin/developerkit/components` | `App.DeveloperKit.Components` | Custom Component + Visual Designer | 7 |
| **Components** | `/admin/list/App.DeveloperKit.CustomComponents` | `App.DeveloperKit.CustomComponents` | Custom Component + Visual Designer | 7 |
| **ListForm** | `/admin/list/App.Listforms.Listform` | `App.Listforms.Listform` | Var olan ekranların ham tanımı | 9.6 |
| **Wizard Manager** | `/admin/listform/wizardManager` | `App.Listforms.Wizard` | Wizard seed dosyalarının yönetimi, export/import | 4, 7.7 |
@ -3502,13 +3520,13 @@ Kurallar:
### 10.3 "Serbest yerleşimli sayfa / dashboard"
1. Gerekli endpoint'ler yoksa CRUD Endpoint üret.
2. Component Manager → yeni bileşen → Visual Designer:
2. Custom Components ekranı → yeni satır → **Design** → Visual Designer:
`PageContainer``FlexRow` → içine `platform` düğümleri (`listFormCode` ile) ve/veya `Form`.
3. Veri kaynaklarını Data sekmesinden bağla; filtreleri `route`/`query`/`record` kaynaklarıyla
kur, `required` bayrağını bilinçli seç.
4. Görünürlük ve komut yetkilerini ver (`designerPermission`, `*Permission`).
5. Wizard `ComponentKind: 1` ile menüye bağla.
6. Doğrula: `custom/{Name}.json` ve ilgili `crud/*.json` dosyaları oluştu mu.
6. Doğrula: `data/App.DeveloperKit.CustomComponents.json` içindeki satır ve ilgili `crud/*.json` dosyaları oluştu mu.
### 10.4 "Kanban panosu"
@ -3554,13 +3572,13 @@ Bu tür isteklerin tamamı aynı iskeleti kullanır. Kullanıcı "avans talep fo
configs/seeds/host/
├── sql/object/Hr_T_AdvanceRequest.sql ← tablo (+ PostgreSQL hedefse postgres/object/…)
├── crud/Hr_T_AdvanceRequest.json ← yalnızca Custom Component yolunda gerekir
├── custom/AdvanceRequestForm.json ← yalnızca serbest yerleşim isteniyorsa
├── data/App.DeveloperKit.CustomComponents.json ← yalnızca serbest yerleşim isteniyorsa
├── wizard/AdvanceRequestApprovals.json ← (varsa) menüsüz alt ekran, küçük SeededAt
└── wizard/AdvanceRequests.json ← ana ekran + menü + yetki + dil + onay akışı
```
**Karar:** Talep formu **standart bir kayıt listesi + düzenleme formu + onay akışı** ise
`ComponentKind: 0` (List) yeter ve `custom/` + `crud/` dosyalarına hiç gerek yoktur. Custom
`ComponentKind: 0` (List) yeter ve component seed verisi + `crud/` dosyalarına hiç gerek yoktur. Custom
Component yoluna yalnızca serbest yerleşim (özet kartlar, çok adımlı sihirbaz, gömülü panolar)
isteniyorsa geçilir.

View file

@ -245,17 +245,17 @@ runtime'da düzenlenebilen seed'ler `configs/seeds/` altındadır — bkz. `App:
`configs/seeds` altında en üst seviye kapsamdır (CDN klasörleriyle aynı mantık): host
veritabanı için `host/`, her tenant için `tenants/{tenantId}/`. Kapsamın altında içerik
türüne göre `crud/`, `custom/`, `wizard/`, `data/`, `sql/`, `postgres/` klasörleri bulunur:
türüne göre `crud/`, `wizard/`, `data/`, `sql/`, `postgres/` klasörleri bulunur:
```
configs/seeds/
host/
crud/ custom/ wizard/ data/
crud/ wizard/ data/
sql/ { object/ execute/ }
postgres/ { object/ execute/ }
tenants/
{tenantId}/
crud/ custom/ wizard/ data/
crud/ wizard/ data/
sql/ { object/ execute/ }
postgres/ { object/ execute/ }
```
@ -269,8 +269,7 @@ Aktif veritabanı sağlayıcısına göre `sql/` (SQL Server) ya da `postgres/`
| `{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` | 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. |
| `data` | Ekranlardan girilen listelerin veri aynası (`{ListFormCode}.json`). `ListForm.SeedFilePath` dolu olan ekranda `SeedSyncInsert/Update/Delete` bayraklarından işaretli olan işlemler dosyaya yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında bütün contributor'lar bittikten sonra `ListFormSeedDataApplier` dosyayı geri uygular: anahtarı veritabanında **olmayan** satırları ekler, var olanlara dokunmaz. |
| `data` | Ekranlardan girilen listelerin veri aynası (`{ListFormCode}.json`). `ListForm.SeedFilePath` dolu olan ekranda `SeedSyncInsert/Update/Delete` bayraklarından işaretli olan işlemler dosyaya yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında bütün contributor'lar bittikten sonra `ListFormSeedDataApplier` dosyayı geri uygular: anahtarı veritabanında **olmayan** satırları ekler, var olanlara dokunmaz. Aynı uygulayıcı wizard import'unun kapanışında da (`CompleteImport`) yalnızca o import'ta yazılan dosyalar için, `overwriteExisting`ık olarak çalışır — orada var olan satır dosyadaki değerlerle güncellenir. |
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.
@ -554,8 +553,10 @@ Yetkiler, Alt Form, Pivot, Tree, Gantt, Scheduler, Todo, Widget, Workflow, Durum
**Wizard File Manager** (`/admin/listform/wizard` liste ekranı) üretilmiş seed dosyalarını yönetir:
liste/kart görünümü arasında geçiş (tercih diğer listelerle aynı yerde, `admin.lists.states`
altında saklanır), arama, düzenleme (dosyanın yerine geçen yeni bir çalıştırma), silme, veritabanı
migrate tetikleme, dışa aktarma ve içe aktarma. Butonlar `App.Listforms.Wizard.*` yetkileriyle
gizlenir; asıl kontrol `ListFormWizardAppService` üzerindedir.
migrate tetikleme, dışa aktarma ve içe aktarma. Liste, wizard'ın ilk deploy damgasına
(`Wizard.SeededAt`) göre **tersine** sıralıdır — en son üretilen ekran en üstte durur; damgası
okunamayan dosyalar sona düşer. Butonlar `App.Listforms.Wizard.*` yetkileriyle gizlenir; asıl
kontrol `ListFormWizardAppService` üzerindedir.
### 7.7. İçe/dışa aktarma
@ -575,21 +576,46 @@ aynı klasör düzenine doğrudan açılabilir.
| Yol | İçerik |
| --- | --- |
| `wizard/{dosya}.json` | Wizard tanımının kendisi |
| `custom/{component}.json` | Custom yolunda bağlı component ve bağımlılıkları |
| `data/App.DeveloperKit.CustomComponents.json` | Custom yolunda bağlı component ve bağımlılıkları — dosyanın yalnızca bu satırlara süzülmüş kopyası |
| `crud/{entity}.json` | Component'lerin/nesnenin kullandığı CRUD endpoint tanımları |
| `{sql\|postgres}/{object\|execute}/{nesne}.sql` | List yolunda `SelectCommand`'a karşılık gelen SQL nesnesi |
**İçe aktarma iki adımlıdır** (`WizardImportDialog`):
**İçe aktarma üç adımlıdır** (`WizardImportDialog`):
1. **Analiz** (`AnalyzeImport`) — zip, seed kökü altındaki `.imports/{importId}/staged` klasörüne
ılır (nokta ile başladığı için seeder taramalarına girmez) ve her dosya hedefteki karşılığıyla
karşılaştırılır: `New` (hedefte yok), `Identical` (aynı, sorulmaz), `Conflict` (farklı).
Çakışan dosyalar Monaco `DiffEditor` ile karşılaştırılıp birleştirilir.
`data/*.json` girdilerinde karşılaştırma **satır bazlıdır**: hedef dosyadan yalnızca gelen
satırların anahtarları süzülür, dosya düzeyindeki `GeneratedAt`/`Order` karşılaştırmaya girmez.
Böylece aktarılan kaydın dışındaki satırlar fark olarak görünmez; gelen satırların hiçbiri
hedefte yoksa dosya var olsa bile durum `New` (ekleme), varsa ve içerik aynıysa `Identical`
(hiç aktarılmaz), farklıysa `Conflict` (güncelleme) olur.
2. **Uygulama** (`ApplyImport`) — dosyalar tek tek yazılır, ilerleme satır satır gösterilir. Yazılan
her dosyanın önceki hâli `.imports/{importId}/backup` altına alındığı için hata durumunda
`RollbackImport` ile toplu işlem geri alınabilir; `CompleteImport` oturumu kapatır.
`RollbackImport` ile toplu işlem geri alınabilir.
3. **Kapanış** (`CompleteImport`) — oturum kapatılmadan önce zip'in getirdiği `data/*.json` dosyaları
`IListFormSeedDataApplier.ApplyFilesAsync(..., overwriteExisting: true)` ile veritabanına
uygulanır: tabloda olmayan satır eklenir, var olan satır dosyadaki değerlerle **güncellenir**
(migrate/seed akışından farkı budur — orada dosya bayat olabileceği için canlı kayda
dokunulmaz). Kayıt daha önce **soft delete** edilmişse silinme izleri de temizlenir
(`IsDeleted`, `DeletionTime`, `DeleterId`); aksi halde satır tabloda durur ama ekranın
varsayılan süzgeci (`"IsDeleted" = 'false'`) onu gizlemeye devam ederdi. Hangi kolonların
sıfırlanacağı ekranın `DeleteCommand`'ından okunur, hard delete yapan ekranda hiçbir şey
eklenmez. Hedefle **zaten aynı** olan (`Identical`) veri dosyaları da uygulanır: yazacak bir şey
yoktur ama satırlar veritabanında eksik ya da silinmiş olabilir. Kullanıcının "mevcudu koru"
dediği (farklı olup yazılmayan) dosyaya dokunulmaz. Anahtarı olmayan satır güncellenemez,
atlanır; eklenen/güncellenen/atlanan satır
sayısı dialogda raporlanır. Bu adım `RollbackImport`
kapsamında **değildir** — geri alma bu adımdan önce yapılır. Diğer klasörler (`wizard`, `crud`,
`sql`) dosya olarak kalır; veritabanına seeder ya da wizard deploy ile geçer.
Güvenlik sınırları: yalnızca `wizard`, `crud`, `custom`, `sql`, `postgres` kök klasörleri
`data/*.json` dosyaları kapsamdaki bütün satırları taşıdığı için üzerine yazılmaz: satırlar anahtar
alan üzerinden birleştirilir (gelen satır varsa günceller, yoksa eklenir, hedefteki diğer satırlar
korunur). Diff ekranında düzenlenen içerik de aynı sözleşmeyi taşıdığı için elle birleştirme bu
davranışı bozmaz.
Güvenlik sınırları: yalnızca `wizard`, `crud`, `data`, `sql`, `postgres` kök klasörleri
(SQL sağlayıcı klasörlerinin altında yalnızca `object` ve `execute`) kabul edilir; dosya başına
5 MB, arşiv başına 50 MB, en çok 500 girdi. Kabul edilmeyen girdiler analiz sonucunda uyarı
olarak listelenir. Yetkiler: `App.Listforms.Wizard.Export` / `App.Listforms.Wizard.Import`.
@ -606,7 +632,7 @@ Teknik kullanıcılar için `/admin/developerkit` altında toplanan araçlar:
| **Custom Endpoint** | `/admin/list/App.DeveloperKit.CustomEndpoints` | SQL veya servis tanımından REST endpoint üretimi; GET/POST/PUT/DELETE için ayrı yetki kapıları (`App.DeveloperKit.CustomEndpoints.*`) ve kullanıcı/rol/global erişim kuralları. |
| **CRUD Endpoint** | SQL Query Manager içinde | Tablo tanımından tam CRUD endpoint kümesi üretimi. Ayrı ekranı yoktur; bkz. aşağıdaki not. |
| **Dynamic Service** | `/admin/list/App.DeveloperKit.DynamicServices` | Liste bir ListForm ekranıdır (KPI şeridi, Swagger butonu, kart/ızgara görünümü); kod yazma listeden açılan `DynamicServiceEditor` diyalogundadır: Monaco editörü, Roslyn ile derleme (`TestCompile`), yayınlama (`Publish`) ve çalışan uygulamaya controller olarak kaydetme. Yetkiler: Create/Edit/Delete/Manage/TestCompile/Publish/ViewCode. |
| **Custom Component** | `/admin/developerkit/components` | React bileşenini veritabanında saklama; `@babel/standalone` ile tarayıcıda derleyip route'a bağlama. Liste/kart görünümü, arama; yetkiler `App.DeveloperKit.Components{,.Create,.Update,.Delete}`. |
| **Custom Component** | `/admin/list/App.DeveloperKit.CustomComponents` | React bileşenini veritabanında saklama; `@babel/standalone` ile tarayıcıda derleyip route'a bağlama. Liste bir ListForm ekranıdır (KPI şeridi, ızgara/kart görünümü, grid'in kendi ekleme/düzenleme formu); satırdaki **Design** butonu `/admin/developerkit/components/design/{id}` tasarımcısını, **Open URL** butonu bileşenin `RoutePath`'ini açar. Kayıtlar `configs/seeds/host/data/App.DeveloperKit.CustomComponents.json` dosyasına senkronlanır. Yetkiler `App.DeveloperKit.CustomComponents{,.Create,.Update,.Delete}`. |
| **Visual Designer** | Component editörü içinde | Sürükle-bırak kanvas ile bileşen üretimi ve kod üretimi (`visualDesigner/codeGenerator.ts`). |
| **ListForm** | `/admin/list/App.Listforms.Listform` | Var olan ekranların ham tanım listesi; buradan `/admin/listform/edit/{kod}` sekmeli editörüne geçilir. |
| **Wizard Manager** | `/admin/listform/wizardManager` | Üretilmiş wizard seed dosyalarının listesi/kartı, düzenleme, silme, DB migrate, export/import. Yetki `App.Listforms.Wizard{,.Create,.Update,.Delete,.Export,.Import}`. |
@ -665,16 +691,16 @@ satırındaki `/*__SOZSOFT_VISUAL_DESIGNER__…__*/` yorumunda. Böylece yalnız
tasarımcı dokümanı geri okunabilir. `sourceMode` alanı `visual` ya da `code` olur; kod moduna
geçilen bir bileşen kanvasa geri dönmez.
**Seed senkronizasyonu.** Component Manager üzerinden kaydedilen/silinen her bileşen
`configs/seeds/{host|tenants/{tenantId}}/custom/{Name}.json` olarak yazılır;
`CustomComponentDataSeeder` veritabanı silinip yeniden oluşturulduğunda aynı dosyaları okuyup geri
yükler. Dosya düzeni bilinçli olarak `TenantData.json` içindeki `CustomComponents` bloğuyla
aynıdır. `DataSources` sütunu tasarımcı dokümanından türetilir
(`CustomComponentDataSourceResolver`): Data sekmesindeki her endpoint `method + path` ile CRUD
endpoint kataloğunda aranır, eşleşenler `EntityName` + `crud/{EntityName}.json` referansını taşır,
elle yazılmış olanlar listede kalır ama bu alanları boş gelir. Aynı çözüm hem kaydetmede (katalog
veritabanından) hem seed'de (katalog `crud/*.json` dosyalarından) kullanıldığı için sütun iki
yolda da aynı üretilir.
**Seed senkronizasyonu.** Bileşenlerin seed kaynağı Custom Components ekranının veri dosyasıdır:
`configs/seeds/{host|tenants/{tenantId}}/data/App.DeveloperKit.CustomComponents.json`. Bileşene
yazan **iki yol** da bu dosyaya işlenir (`ListFormSeedDataSynchronizer`): listeden yapılan
ekleme/güncelleme/silme ve Visual Designer'ın kaydı (`CustomComponentAppService`). Veritabanı
sıfırlandığında `ListFormSeedDataApplier` satırları geri yazar; `Code`, `Props` ve `DataSources`
kolonları bu yüzden ekranda gizli alan olarak tanımlıdır. `DataSources` sütunu tasarımcı
dokümanından türetilir (`CustomComponentDataSourceResolver`): Data sekmesindeki her endpoint
`method + path` ile CRUD endpoint kataloğunda aranır, eşleşenler `EntityName` +
`crud/{EntityName}.json` referansını taşır, elle yazılmış olanlar listede kalır ama bu alanları
boş gelir. Wizard export'u bu referansları izleyerek bağlı `crud/*.json` dosyalarını zip'e ekler.
**Toolbox.** Kanvasa bırakılabilecekler beş aileye ayrılır:
@ -715,7 +741,8 @@ parametresi (`?RoleId=…`), diğerleri son ek taşır (`?Name.contains=…`). O
**Diğer notlar.** `PlatformIcon` tasarımcı dokümanındaki ikon adını çözer; `selectComponents.ts`
`Select.componentAs` için saklanan adı (`ReactSelect`, `CreatableSelect`, `AsyncSelect`) gerçek
bileşene çevirir — kanvas ve üretilen bileşenin aynı adı aynı şekilde yorumlamasını sağlayan tek
nokta budur. Yeni bileşen `configs/seeds/host/custom/NewComponent.json` şablonundan başlar.
nokta budur. Yeni bileşen `configs/seeds/host/data/App.DeveloperKit.CustomComponents.json`
içindeki bir satır olarak başlar.
**Dinamik servis yaşam döngüsü:** `DynamicServiceCompiler` kodu derler →
`DynamicAssemblyRegistrationService` assembly'yi tenant bağlamıyla kaydeder →
@ -756,7 +783,7 @@ içindedir; oradaki kontroller yalnızca butonları gizler, asıl kontrol AppSer
| Grup | Alt yetkiler |
| --- | --- |
| `App.Listforms.Wizard` | `.Create`, `.Update`, `.Delete`, `.Export`, `.Import` |
| `App.DeveloperKit.Components` | `.Create`, `.Update`, `.Delete` |
| `App.DeveloperKit.CustomComponents` | `.Create`, `.Update`, `.Delete` |
| `App.DeveloperKit.CustomEndpoints` | `.Get`, `.Post`, `.Put`, `.Remove` (dispatcher üzerinden çağrı kapısı; endpoint bazlı User/Role/Global kuralları bunun üstünde denetlenir) |
| `App.DeveloperKit.DynamicServices` | `.Create`, `.Edit`, `.Delete`, `.Manage`, `.TestCompile`, `.Publish`, `.ViewCode` |
| `App.SqlQueryManager` | `.CrudEndpoints` |
@ -1168,18 +1195,17 @@ derlenip yayınlanır. Yeni bir modülün nasıl kurulacağını öğrenmek içi
- `PermissionsData.json` — yetki grupları ve tanımları
- `HostData.json` — ayar/entegrasyon değerleri
- `LanguagesData.json` — dil anahtarları (EN/TR)
- `WizardDataSeeder.cs`, `CustomComponentDataSeeder.cs`, `CrudDataSeeder.cs` — aşağıdaki
- `WizardDataSeeder.cs`, `CrudDataSeeder.cs` — aşağıdaki
runtime seed dosyalarını okuyup uygulayan seeder'lar
**2. Runtime'da üretilen seed'ler** — `configs/seeds/`. Wizard, Component Manager ve CRUD Endpoint
Manager çalışma zamanında burayı yazar; veritabanı silinip yeniden oluşturulduğunda aynı dosyalar
**2. Runtime'da üretilen seed'ler** — `configs/seeds/`. Wizard, Custom Components ekranı/Visual
Designer ve CRUD Endpoint Manager çalışma zamanında burayı yazar; veritabanı silinip yeniden oluşturulduğunda aynı dosyalar
geri yüklenir. Kapsam klasörü CDN düzeniyle aynıdır ve `SeedPathResolver` üzerinden çözülür:
```
configs/seeds/
├── host/ # host kapsamı
│ ├── wizard/ # {Ad}.json → WizardDataSeeder
│ ├── custom/ # {ComponentName}.json → CustomComponentDataSeeder
│ ├── crud/ # {EntityName}.json → CrudDataSeeder
│ ├── sql/{object,execute}/ # .sql (SQL Server)
│ └── postgres/{object,execute}/

View file

@ -1,6 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
@ -12,7 +10,4 @@ public interface ICustomComponentAppService : ICrudAppService<
PagedAndSortedResultRequestDto,
CreateUpdateCustomComponentDto>
{
Task<List<CustomComponentDto>> GetActiveComponentsAsync();
}

View file

@ -27,7 +27,7 @@ public interface IListFormWizardAppService
Task<WizardImportApplyResultDto> ApplyImport(WizardImportApplyInput input);
/// <summary>Import oturumunu basariyla kapatir ve staging/yedek klasorunu siler.</summary>
Task CompleteImport(string importId);
Task<WizardImportCompleteResultDto> CompleteImport(string importId);
/// <summary>O ana kadar yazilan dosyalari geri alir ve staging klasorunu siler.</summary>
Task RollbackImport(string importId);

View file

@ -9,10 +9,10 @@ namespace Sozsoft.Platform.ListForms;
/// </summary>
public class WizardImportEntryDto
{
/// <summary>Kapsam klasorune goreli yol, ornegin <c>custom/Orders.json</c>.</summary>
/// <summary>Kapsam klasorune goreli yol, ornegin <c>crud/Orders.json</c>.</summary>
public string Path { get; set; }
/// <summary>Yolun ilk segmenti; listede gruplama icin kullanilir (wizard, crud, custom, sql, postgres).</summary>
/// <summary>Yolun ilk segmenti; listede gruplama icin kullanilir (wizard, crud, data, sql, postgres).</summary>
public string Folder { get; set; }
public string FileName { get; set; }
@ -70,3 +70,31 @@ public class WizardImportApplyResultDto
public long Size { get; set; }
}
/// <summary>
/// Import kapanisinin sonucu. Yazilan <c>data/*.json</c> dosyalari ayni islemde veritabanina
/// da uygulanir; her dosyanin sonucu burada raporlanir.
/// </summary>
public class WizardImportCompleteResultDto
{
public List<WizardImportSeedApplyResultDto> AppliedData { get; set; } = [];
}
/// <summary>Tek bir veri seed dosyasinin veritabanina uygulanma sonucu.</summary>
public class WizardImportSeedApplyResultDto
{
/// <summary>Kapsam klasorune goreli yol.</summary>
public string Path { get; set; }
/// <summary>Veritabanina eklenen satir sayisi.</summary>
public int InsertedRowCount { get; set; }
/// <summary>Zaten var oldugu icin dosyadaki degerlerle guncellenen satir sayisi.</summary>
public int UpdatedRowCount { get; set; }
/// <summary>Anahtari olmadigi icin guncellenemeyen, dokunulmayan satir sayisi.</summary>
public int SkippedRowCount { get; set; }
/// <summary>Dosya uygulanamadiysa nedeni; basarili durumda bostur.</summary>
public string Error { get; set; }
}

View file

@ -1,16 +1,15 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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 Sozsoft.Platform.ListForms;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
@ -21,9 +20,13 @@ namespace Platform.Api.Application;
/// <summary>
/// Custom component yonetimi. Sinif seviyesindeki <see cref="AuthorizeAttribute"/> yalnizca
/// kimlik dogrulamasi ister; ekrani gorme ve CRUD haklari asagida ABP policy adlarina baglanir.
/// <c>App.DeveloperKit.Components</c> okuma, <c>.Create</c>/<c>.Update</c>/<c>.Delete</c> aksiyon
/// yetkileridir. <see cref="GetActiveComponentsAsync"/> calisma zamani okuma yoludur ve yalnizca
/// oturum acmis olmayi ister.
/// <c>App.DeveloperKit.CustomComponents</c> okuma, <c>.Create</c>/<c>.Update</c>/<c>.Delete</c> aksiyon
/// yetkileridir.
/// <para>
/// Component'ler <c>App.DeveloperKit.CustomComponents</c> ekraninin verisidir; ekran uzerinden
/// yapilan kayitlar seed dosyasina ListForm altyapisi tarafindan yansitilir. Designer bu servisi
/// kullandigi icin ayni yansitma burada da tetiklenir, aksi halde dosya bayat kalir.
/// </para>
/// </summary>
[Authorize]
public class CustomComponentAppService : CrudAppService<
@ -40,21 +43,19 @@ public class CustomComponentAppService : CrudAppService<
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonSerializerOptions JsonReadOptions = new()
{
PropertyNameCaseInsensitive = true
};
/// <summary>Component'lerin verisini tasiyan ekran; seed dosyasi bu kod uzerinden bulunur.</summary>
private const string ListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents;
private readonly IConfiguration _configuration;
private readonly IRepository<CrudEndpoint, Guid> _crudEndpointRepository;
private readonly IListFormSeedDataSynchronizer _seedDataSynchronizer;
public CustomComponentAppService(
IRepository<CustomComponent, Guid> repository,
IRepository<CrudEndpoint, Guid> crudEndpointRepository,
IConfiguration configuration) : base(repository)
IListFormSeedDataSynchronizer seedDataSynchronizer) : base(repository)
{
_crudEndpointRepository = crudEndpointRepository;
_configuration = configuration;
_seedDataSynchronizer = seedDataSynchronizer;
GetPolicyName = AppCodes.DeveloperKits.Components.Component;
GetListPolicyName = AppCodes.DeveloperKits.Components.Component;
@ -67,7 +68,7 @@ public class CustomComponentAppService : CrudAppService<
{
var result = await base.CreateAsync(input);
await SyncDataSourcesAsync(result.Id);
await SyncSeedFileAsync(result.Id);
await _seedDataSynchronizer.SyncRowAsync(ListFormCode, result.Id, SeedSyncOperation.Insert);
return result;
}
@ -76,7 +77,6 @@ public class CustomComponentAppService : CrudAppService<
CreateUpdateCustomComponentDto input)
{
var existingComponent = await Repository.GetAsync(id);
var previousName = existingComponent.Name;
if (input.Props is null)
{
@ -85,109 +85,15 @@ public class CustomComponentAppService : CrudAppService<
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);
await _seedDataSynchronizer.SyncRowAsync(ListFormCode, id, SeedSyncOperation.Update);
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()
{
var components = await Repository.GetListAsync(x => x.IsActive);
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);
}
await _seedDataSynchronizer.RemoveRowsAsync(ListFormCode, [id]);
}
/// <summary>
@ -241,74 +147,4 @@ public class CustomComponentAppService : CrudAppService<
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

@ -53,6 +53,7 @@ public class ListFormWizardAppService(
IConfiguration configuration,
LanguageTextAppService languageTextAppService,
IDynamicDataManager dynamicDataManager,
IListFormSeedDataApplier seedDataApplier,
ILogger<ListFormWizardAppService> logger
) : PlatformAppService(), IListFormWizardAppService
{
@ -70,6 +71,7 @@ public class ListFormWizardAppService(
private readonly IConfiguration _configuration = configuration;
private readonly LanguageTextAppService _languageTextAppService = languageTextAppService;
private readonly IDynamicDataManager _dynamicDataManager = dynamicDataManager;
private readonly IListFormSeedDataApplier _seedDataApplier = seedDataApplier;
private readonly ILogger<ListFormWizardAppService> logger = logger;
private readonly string cultureNameDefault = PlatformConsts.DefaultLanguage;
@ -534,6 +536,11 @@ public class ListFormWizardAppService(
})];
}
/// <summary>
/// Kapsamdaki wizard seed dosyalarini listeler. Sira ilk deploy damgasina (<c>SeededAt</c>)
/// gore tersinedir — en son uretilen ekran listenin basinda durur; dosya adina gore siralamak
/// yeni eklenen ekrani listenin ortasinda birakiyordu. Damgasi okunamayan dosyalar sona duser.
/// </summary>
public async Task<List<WizardFileInfoDto>> GetFiles()
{
var outputPath = ResolveWizardSeedOutputPath();
@ -575,7 +582,10 @@ public class ListFormWizardAppService(
}
}
return result;
// CreatedAt sortable ("s") bicimindedir; metin karsilastirmasi da kronolojiktir.
return [.. result
.OrderByDescending(x => x.CreatedAt, StringComparer.Ordinal)
.ThenBy(x => x.FileName, StringComparer.OrdinalIgnoreCase)];
}
public async Task<WizardSeedFileDto> GetFile(string fileName)
@ -804,8 +814,9 @@ public class ListFormWizardAppService(
/// Zip icindeki yollar seed kapsam klasoru (host / tenants/{tenantId}) koku baz alinarak yazilir;
/// dolayisiyla arsiv baska bir ortamda ayni klasor duzenine dogrudan acilabilir.
/// <para>
/// Custom yolunda: <c>wizard/{dosya}.json</c>, <c>custom/{component}.json</c> (bagimliliklari ile birlikte)
/// ve bu component'lerin data source'larinin isaret ettigi <c>crud/{entity}.json</c> dosyalari.
/// Custom yolunda: <c>wizard/{dosya}.json</c>, secili component ve bagimliliklariyla suzulmus
/// <c>data/App.DeveloperKit.CustomComponents.json</c> ve bu component'lerin data source'larinin
/// isaret ettigi <c>crud/{entity}.json</c> dosyalari.
/// List yolunda: <c>wizard/{dosya}.json</c>, SelectCommand'a karsilik gelen
/// <c>{sql|postgres}/{object|execute}/{nesne}.sql</c> ve varsa <c>crud/{nesne}.json</c>.
/// </para>
@ -828,8 +839,12 @@ public class ListFormWizardAppService(
[$"{SeedPathResolver.WizardFolder}/{Path.GetFileName(filePath)}"] = filePath
};
// Paylasilan bir seed dosyasindan yalnizca ilgili satirlar suzuldugunde zip'e diskteki
// hali degil, uretilen icerik girer.
var generatedEntries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (seed.Wizard?.ComponentKind == WizardComponentKindEnum.Custom)
await CollectCustomComponentEntriesAsync(scopeRoot, seed.Wizard.CustomComponentName, entries);
await CollectCustomComponentEntriesAsync(scopeRoot, seed.Wizard.CustomComponentName, entries, generatedEntries);
else
CollectListComponentEntries(scopeRoot, seed.Wizard, entries);
@ -843,6 +858,13 @@ public class ListFormWizardAppService(
await using var target = zipEntry.Open();
await source.CopyToAsync(target);
}
foreach (var entry in generatedEntries.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase))
{
var zipEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal);
await using var target = zipEntry.Open();
await target.WriteAsync(ImportEncoding.GetBytes(entry.Value));
}
}
memory.Position = 0;
@ -869,6 +891,12 @@ public class ListFormWizardAppService(
private const string ImportBackupFolder = "backup";
private const string ImportAppliedLogFile = "applied.log";
/// <summary>Custom component'lerin seed kaynagi olan ListForm ekrani.</summary>
private const string CustomComponentsListFormCode = SeedConsts.AppCodes.DeveloperKits.CustomComponents;
/// <summary>Custom component satirlarinin anahtar alani; bagimliliklar bu adla eslesir.</summary>
private const string CustomComponentNameField = "Name";
private const long ImportMaxEntryBytes = 5 * 1024 * 1024;
private const long ImportMaxArchiveBytes = 50 * 1024 * 1024;
private const int ImportMaxEntryCount = 500;
@ -878,7 +906,7 @@ public class ListFormWizardAppService(
[
SeedPathResolver.WizardFolder,
SeedPathResolver.CrudFolder,
SeedPathResolver.CustomFolder,
SeedPathResolver.DataFolder,
SeedPathResolver.SqlFolder,
SeedPathResolver.PostgresFolder,
];
@ -1003,6 +1031,24 @@ public class ListFormWizardAppService(
var incomingText = NormalizeImportText(incoming);
var existingText = NormalizeImportText(existing);
// Veri seed dosyasinda karsilastirma dosya degil satir bazlidir.
var scope = IsSeedDataPath(relativePath) ? BuildSeedDataScope(incoming, existing) : null;
if (scope != null)
{
incomingText = scope.Incoming;
existingText = scope.Existing;
entry.IncomingSize = ImportEncoding.GetByteCount(scope.Incoming);
entry.ExistingSize = ImportEncoding.GetByteCount(scope.Existing);
// Gelen satirlarin hicbiri hedefte yoksa dosya var olsa da islem eklemedir.
if (!scope.HasExistingRows)
{
entry.Status = WizardImportEntryStatusEnum.New;
result.Entries.Add(entry);
continue;
}
}
if (string.Equals(incomingText, existingText, StringComparison.Ordinal))
{
entry.Status = WizardImportEntryStatusEnum.Identical;
@ -1058,6 +1104,10 @@ public class ListFormWizardAppService(
: ImportEncoding.GetBytes(input.Content);
var overwritten = File.Exists(targetPath);
if (overwritten && relativePath.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase))
content = await MergeSeedDataRowsAsync(targetPath, content);
if (overwritten)
{
var backupPath = Path.Combine(sessionPath, ImportBackupFolder, systemPath);
@ -1087,12 +1137,106 @@ public class ListFormWizardAppService(
};
}
/// <summary>
/// Import oturumunu kapatir. Kapanistan once zip'in getirdigi <c>data/*.json</c> dosyalari
/// veritabanina uygulanir: tabloda olmayan satirlar eklenir, var olanlar dosyadaki degerlerle
/// guncellenir ve soft delete edilmis satirlarin silinme izleri temizlenir — aksi halde kayit
/// tabloda durur ama ekranin varsayilan suzgeci onu gizlemeye devam ederdi. Hedefle zaten ayni
/// olan dosyalar da uygulanir: yazacak bir sey yoktur ama satirlar veritabaninda eksik ya da
/// silinmis olabilir. Migrate/seed akisindan farki budur — orada dosya bayat olabilir ve canli kayit
/// ezilmez; import ise kullanicinin bilincli getirdigi icerigi tasir. Diger klasorler
/// (wizard, crud, sql) dosya olarak kalir; onlar seeder ya da wizard deploy ile veritabanina gecer.
/// <para>
/// Uygulama basarisiz olsa bile oturum kapatilir; dosyalar hedefe yazilmis durumdadir ve
/// hata sonuca tasinir. Bu adim geri alinamaz, cunku dosya yedegi veritabani kaydini
/// kapsamaz — geri alma <c>RollbackImport</c> ile bu adimdan once yapilir.
/// </para>
/// </summary>
[Authorize(AppCodes.Listforms.Wizards.Import)]
[HttpPost("api/app/list-form-wizard/complete-import")]
public Task CompleteImport(string importId)
public async Task<WizardImportCompleteResultDto> CompleteImport(string importId)
{
DeleteImportSession(GetExistingImportSessionPath(importId));
return Task.CompletedTask;
var sessionPath = GetExistingImportSessionPath(importId);
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(_configuration, CurrentTenant.Id));
var result = new WizardImportCompleteResultDto();
var dataFiles = await ReadAppliedDataFilesAsync(sessionPath, scopeRoot);
if (dataFiles.Count > 0)
{
foreach (var applied in await _seedDataApplier.ApplyFilesAsync(CurrentTenant.Id, dataFiles, overwriteExisting: true))
{
result.AppliedData.Add(new WizardImportSeedApplyResultDto
{
Path = applied.Path,
InsertedRowCount = applied.Inserted,
UpdatedRowCount = applied.Updated,
SkippedRowCount = applied.Skipped,
Error = applied.Error,
});
}
}
DeleteImportSession(sessionPath);
return result;
}
/// <summary>
/// Veritabanina uygulanacak <c>data/</c> girdilerini toplar.
/// <para>
/// Once oturumda gercekten yazilmis dosyalar (kayit dosyasindan) alinir. Ardindan yazilmamis
/// staging girdilerinden <b>hedefle ayni olanlar</b> eklenir: dosya ayni oldugu icin yazacak
/// bir sey yoktu, ama satirlar veritabaninda eksik ya da soft delete edilmis olabilir. Hedeften
/// farkli olup yazilmamis dosya kullanicinin "mevcudu koru" karari demektir; ona dokunulmaz.
/// </para>
/// </summary>
private async Task<List<string>> ReadAppliedDataFilesAsync(string sessionPath, string scopeRoot)
{
var prefix = SeedPathResolver.DataFolder + "/";
var paths = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var logPath = Path.Combine(sessionPath, ImportAppliedLogFile);
if (File.Exists(logPath))
{
foreach (var line in await File.ReadAllLinesAsync(logPath))
{
var parts = line.Split('|', 2);
if (parts.Length != 2 || !parts[1].StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
if (seen.Add(parts[1]))
paths.Add(parts[1]);
}
}
var stagedDataPath = Path.Combine(sessionPath, ImportStagedFolder, SeedPathResolver.DataFolder);
if (!Directory.Exists(stagedDataPath))
return paths;
foreach (var stagedFile in Directory.GetFiles(stagedDataPath, "*.json"))
{
var relativePath = prefix + Path.GetFileName(stagedFile);
if (seen.Contains(relativePath))
continue;
var targetPath = ResolveScopeFilePath(scopeRoot, relativePath);
if (targetPath == null)
continue;
var staged = await File.ReadAllBytesAsync(stagedFile);
var existing = await File.ReadAllBytesAsync(targetPath);
// Karsilastirma analiz adimiyla ayni olmali: veri dosyalarinda satir bazlidir.
var scope = BuildSeedDataScope(staged, existing);
var isIdentical = scope != null
? string.Equals(scope.Incoming, scope.Existing, StringComparison.Ordinal)
: string.Equals(NormalizeImportText(staged), NormalizeImportText(existing), StringComparison.Ordinal);
if (isIdentical && seen.Add(relativePath))
paths.Add(relativePath);
}
return paths;
}
[Authorize(AppCodes.Listforms.Wizards.Import)]
@ -1143,6 +1287,156 @@ public class ListFormWizardAppService(
DeleteImportSession(sessionPath);
}
/// <summary>Kapsam-goreli yolun bir veri seed dosyasini gosterip gostermedigi.</summary>
private static bool IsSeedDataPath(string relativePath)
{
return relativePath.StartsWith(SeedPathResolver.DataFolder + "/", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Veri seed dosyasinin karsilastirilacak kisimlari. <see cref="Existing"/> hedef dosyadan
/// yalnizca gelen satirlarin anahtarlariyla suzulur; <see cref="HasExistingRows"/> false ise
/// gelen satirlarin hicbiri hedefte yoktur.
/// </summary>
private sealed record SeedDataScope(string Incoming, string Existing, bool HasExistingRows);
/// <summary>
/// Diff'te gosterilen suzulmus veri seed icerigi. Dosya duzeyindeki <c>GeneratedAt</c> ve
/// <c>Order</c> disarida birakilir; birlestirme bu alanlari hedeften korudugu icin
/// karsilastirmaya girmeleri sahte fark uretirdi.
/// </summary>
private sealed class ListFormSeedDataScopeDto
{
public string ListFormCode { get; set; } = string.Empty;
public string KeyFieldName { get; set; } = string.Empty;
public List<Dictionary<string, object>> Rows { get; set; } = [];
}
/// <summary>
/// Bir veri seed dosyasi kapsamdaki butun satirlari tasir, zip ise yalnizca aktarilan ekranin
/// satirlarini. Diff'te dosyanin tamami karsilastirilirsa kullanici ilgisiz kayitlarin farkini
/// gorur; bu yuzden iki taraf da gelen satirlarin anahtarlarina indirgenir. Dosya duzeyindeki
/// alanlar (<c>GeneratedAt</c>, <c>Order</c>) karsilastirmaya girmez — birlestirme onlari zaten
/// hedeften korur. Dosyalardan biri cozulemiyorsa null doner ve dosyanin tamami karsilastirilir.
/// </summary>
private static SeedDataScope? BuildSeedDataScope(byte[] incoming, byte[] existing)
{
try
{
var incomingFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(incoming, SeedJsonReadOptions);
var existingFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(existing, SeedJsonReadOptions);
var keyFieldName = incomingFile?.KeyFieldName ?? existingFile?.KeyFieldName;
if (incomingFile == null || existingFile == null || string.IsNullOrWhiteSpace(keyFieldName))
return null;
var existingByKey = new Dictionary<string, Dictionary<string, object>>(StringComparer.OrdinalIgnoreCase);
foreach (var row in existingFile.Rows)
{
var key = GetRowText(row, keyFieldName);
if (!string.IsNullOrWhiteSpace(key))
existingByKey[key] = row;
}
// Hedef satirlar gelen sirayla dizilir; sira farki diff'te sahte fark uretmesin.
var matched = new List<Dictionary<string, object>>();
foreach (var row in incomingFile.Rows)
{
var key = GetRowText(row, keyFieldName);
if (!string.IsNullOrWhiteSpace(key) && existingByKey.TryGetValue(key, out var match))
matched.Add(match);
}
return new SeedDataScope(
SerializeSeedDataScope(incomingFile, keyFieldName, incomingFile.Rows),
SerializeSeedDataScope(existingFile, keyFieldName, matched),
matched.Count > 0);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Suzulmus satirlari, birlestirmenin okudugu alanlarla (ekran kodu, anahtar alan, satirlar)
/// yeniden yazar. Kullanici diff ekraninda bu icerigi duzenleyip gonderdiginde de
/// <see cref="MergeSeedDataRowsAsync"/> ayni sozlesmeyi bulur.
/// </summary>
private static string SerializeSeedDataScope(
ListFormSeedDataFileDto source,
string keyFieldName,
List<Dictionary<string, object>> rows)
{
return JsonSerializer.Serialize(
new ListFormSeedDataScopeDto
{
ListFormCode = source.ListFormCode,
KeyFieldName = keyFieldName,
Rows = rows,
},
ListFormSeedDataFileDto.JsonOptions);
}
/// <summary>
/// Bir veri seed dosyasi kapsamdaki butun satirlari tasir, zip ise yalnizca aktarilan ekranin
/// satirlarini getirir; bu yuzden hedef dosyanin ustune yazilmaz, satirlar anahtar alan
/// uzerinden birlestirilir: gelen satir hedefte varsa guncellenir, yoksa sona eklenir,
/// hedefteki diger satirlar korunur. Iki dosyadan biri cozulemiyorsa ya da anahtar alan
/// bilinmiyorsa gelen icerik oldugu gibi yazilir.
/// </summary>
private async Task<byte[]> MergeSeedDataRowsAsync(string targetPath, byte[] incoming)
{
try
{
var incomingFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(incoming, SeedJsonReadOptions);
var existingFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
await File.ReadAllBytesAsync(targetPath), SeedJsonReadOptions);
var keyFieldName = incomingFile?.KeyFieldName ?? existingFile?.KeyFieldName;
if (incomingFile == null || existingFile == null || string.IsNullOrWhiteSpace(keyFieldName))
return incoming;
var merged = new List<Dictionary<string, object>>(existingFile.Rows);
var indexByKey = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < merged.Count; i++)
{
var key = GetRowText(merged[i], keyFieldName);
if (!string.IsNullOrWhiteSpace(key))
indexByKey[key] = i;
}
foreach (var row in incomingFile.Rows)
{
var key = GetRowText(row, keyFieldName);
if (string.IsNullOrWhiteSpace(key))
continue;
if (indexByKey.TryGetValue(key, out var index))
{
merged[index] = row;
}
else
{
indexByKey[key] = merged.Count;
merged.Add(row);
}
}
existingFile.Rows = merged;
existingFile.GeneratedAt = Clock.Now.ToUniversalTime();
return ImportEncoding.GetBytes(JsonSerializer.Serialize(existingFile, ListFormSeedDataFileDto.JsonOptions));
}
catch (Exception ex)
{
logger.LogWarning(ex, "Seed data file could not be merged, incoming content is written as is: {TargetPath}", targetPath);
return incoming;
}
}
/// <summary>
/// Zip girdisini kapsam klasorune goreli yola cevirir. Zip, kapsam klasorunun icinden
/// (<c>wizard/x.json</c>) ya da depo kokunden (<c>configs/seeds/host/wizard/x.json</c>)
@ -1255,63 +1549,120 @@ public class ListFormWizardAppService(
}
/// <summary>
/// Custom component seed dosyasini, bagimli oldugu component'leri ve bunlarin data source'larinin
/// isaret ettigi crud seed dosyalarini toplar. Bagimlilik zinciri dongu icerse bile
/// ziyaret edilen adlar takip edildigi icin sonsuz dongu olusmaz.
/// Custom component'lerin seed kaynagi <c>data/App.DeveloperKit.CustomComponents.json</c>
/// dosyasidir. Dosya kapsamdaki butun component'leri tasidigi icin zip'e oldugu gibi degil,
/// secili component ile bagimlilik zincirine suzulmus bir kopyasi konur; boylece arsiv ilgisiz
/// component'leri disari tasimaz. Ayni zincirdeki component'lerin data source'larinin isaret
/// ettigi crud seed dosyalari da toplanir. Bagimlilik zinciri dongu icerse bile ziyaret edilen
/// adlar takip edildigi icin sonsuz dongu olusmaz.
/// </summary>
private async Task CollectCustomComponentEntriesAsync(string scopeRoot, string? componentName, Dictionary<string, string> entries)
private async Task CollectCustomComponentEntriesAsync(
string scopeRoot,
string? componentName,
Dictionary<string, string> entries,
Dictionary<string, string> generatedEntries)
{
if (string.IsNullOrWhiteSpace(componentName))
return;
var relativePath = SeedPathResolver.GetDataFilePath(CustomComponentsListFormCode);
var sourcePath = ResolveScopeFilePath(scopeRoot, relativePath);
if (sourcePath == null)
{
logger.LogWarning("Custom component seed data file not found for export: {RelativePath}", relativePath);
return;
}
ListFormSeedDataFileDto? seedFile;
try
{
seedFile = JsonSerializer.Deserialize<ListFormSeedDataFileDto>(
await File.ReadAllTextAsync(sourcePath), SeedJsonReadOptions);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Custom component seed data file could not be parsed for export: {RelativePath}", relativePath);
return;
}
if (seedFile == null)
return;
var rowsByName = new Dictionary<string, Dictionary<string, object>>(StringComparer.OrdinalIgnoreCase);
foreach (var row in seedFile.Rows)
{
var name = GetRowText(row, CustomComponentNameField);
if (!string.IsNullOrWhiteSpace(name))
rowsByName[name] = row;
}
var pending = new Queue<string>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(componentName))
pending.Enqueue(componentName.Trim());
var exported = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
pending.Enqueue(componentName.Trim());
while (pending.Count > 0)
{
var name = pending.Dequeue();
if (!visited.Add(name))
if (!visited.Add(name) || !rowsByName.TryGetValue(name, out var row))
continue;
var relativePath = $"{SeedPathResolver.CustomFolder}/{name}.json";
if (!TryAddSeedEntry(scopeRoot, relativePath, entries))
exported.Add(name);
foreach (var dependency in ParseSeedRowList<string>(GetRowText(row, "Dependencies")))
{
logger.LogWarning("Custom component seed file not found for export: {RelativePath}", relativePath);
continue;
if (!string.IsNullOrWhiteSpace(dependency))
pending.Enqueue(dependency.Trim());
}
CustomComponentSeedFileDto? customSeed;
try
foreach (var dataSource in ParseSeedRowList<CustomComponentSeedDataSourceDto>(GetRowText(row, "DataSources")))
{
var customJson = await File.ReadAllTextAsync(entries[relativePath]);
customSeed = JsonSerializer.Deserialize<CustomComponentSeedFileDto>(customJson, SeedJsonReadOptions);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Custom component seed file could not be parsed for export: {RelativePath}", relativePath);
continue;
}
// SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur;
// elle yazilmis bir URL'in seed karsiligi yoktur.
if (string.IsNullOrWhiteSpace(dataSource.SeedFile))
continue;
foreach (var component in customSeed?.CustomComponents ?? [])
{
foreach (var dependency in component.Dependencies)
{
if (!string.IsNullOrWhiteSpace(dependency))
pending.Enqueue(dependency.Trim());
}
foreach (var dataSource in component.DataSources)
{
// SeedFile yalnizca CRUD Endpoint Manager'in urettigi endpointlerde doludur;
// elle yazilmis bir URL'in seed karsiligi yoktur.
if (string.IsNullOrWhiteSpace(dataSource.SeedFile))
continue;
if (!TryAddSeedEntry(scopeRoot, dataSource.SeedFile, entries))
logger.LogWarning("CRUD seed file not found for export: {RelativePath}", dataSource.SeedFile);
}
if (!TryAddSeedEntry(scopeRoot, dataSource.SeedFile, entries))
logger.LogWarning("CRUD seed file not found for export: {RelativePath}", dataSource.SeedFile);
}
}
if (exported.Count == 0)
{
logger.LogWarning("Custom component not found in the seed data file for export: {ComponentName}", componentName);
return;
}
// Dosyadaki satir sirasi korunur; hedefte satirlar bu sirayla birlestirilir.
seedFile.Rows = [.. seedFile.Rows.Where(x => exported.Contains(GetRowText(x, CustomComponentNameField) ?? string.Empty))];
generatedEntries[relativePath] = JsonSerializer.Serialize(seedFile, ListFormSeedDataFileDto.JsonOptions);
}
/// <summary>Seed satirindaki bir alani metin olarak okur; JsonElement ve duz deger destekler.</summary>
private static string? GetRowText(Dictionary<string, object> row, string fieldName)
{
if (!row.TryGetValue(fieldName, out var value) || value is null)
return null;
return value is JsonElement element
? (element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString())
: value.ToString();
}
/// <summary>Seed satirinda JSON dizisi olarak tutulan alanlari cozer; bozuk deger bos liste doner.</summary>
private static List<T> ParseSeedRowList<T>(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return [];
try
{
return JsonSerializer.Deserialize<List<T>>(json, SeedJsonReadOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
/// <summary>
@ -1369,20 +1720,30 @@ public class ListFormWizardAppService(
/// sessizce reddedilir. Dosya yoksa false doner.
/// </summary>
private static bool TryAddSeedEntry(string scopeRoot, string relativePath, Dictionary<string, string> entries)
{
var fullPath = ResolveScopeFilePath(scopeRoot, relativePath);
if (fullPath == null)
return false;
entries[relativePath.Replace('\\', '/').Trim('/')] = fullPath;
return true;
}
/// <summary>
/// Kapsam klasorune goreli yolu var olan bir dosyanin tam yoluna cevirir; kapsam disina cikan
/// ya da bulunmayan bir yol icin null doner.
/// </summary>
private static string? ResolveScopeFilePath(string scopeRoot, string relativePath)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
if (normalized.Length == 0 || normalized.Contains("..", StringComparison.Ordinal))
return false;
return null;
var fullPath = Path.GetFullPath(Path.Combine(scopeRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
if (!fullPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
return false;
return null;
if (!File.Exists(fullPath))
return false;
entries[normalized] = fullPath;
return true;
return File.Exists(fullPath) ? fullPath : null;
}
private async Task<HashSet<string>> GetTableColumnNamesAsync(string dataSourceCode, SelectCommandTypeEnum commandType, string selectCommand)

View file

@ -1,206 +0,0 @@
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

@ -732,12 +732,6 @@
"en": "Crud Endpoints",
"tr": "Crud Uç Noktaları"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKit.Components",
"en": "Custom Components",
"tr": "Özel Bileşenler"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKit.DynamicServices",
@ -9026,15 +9020,21 @@
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.ComponentDescription",
"en": "Manage your custom React components",
"tr": "Özel React bileşenlerinizi yönetin"
"key": "App.Listform.ListformField.DataSources",
"en": "Data Sources",
"tr": "Veri Kaynakları"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.New",
"en": "New Component",
"tr": "Yeni Bileşen"
"key": "App.DeveloperKit.CustomComponents",
"en": "Custom Components",
"tr": "Özel Bileşenler"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKit.CustomComponents.Description",
"en": "Custom components built with the Visual Designer",
"tr": "Visual Designer ile üretilen özel bileşenler"
},
{
"resourceName": "Platform",
@ -9072,24 +9072,12 @@
"en": "Total number of inactive custom React components",
"tr": "Pasif özel React bileşenlerinin toplam sayısı"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.SearchPlaceholder",
"en": "Search components...",
"tr": "Bileşenlerde ara..."
},
{
"resourceName": "Platform",
"key": "App.ComponentFilter.All",
"en": "All Components",
"tr": "Tüm Bileşenler"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.NoDependencies",
"en": "No dependencies",
"tr": "Bağımlılık yok"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.SaveToSeed",
@ -9132,12 +9120,6 @@
"en": "Delete Component",
"tr": "Bileşeni Sil"
},
{
"resourceName": "Platform",
"key": "App.DeveloperKitComponent.ConfirmDelete",
"en": "Are you sure you want to delete the component?",
"tr": "Bileşeni silmek istediğinizden emin misiniz?"
},
{
"resourceName": "Platform",
"key": "App.EmptyFiltered.FilteredTitle",
@ -16455,8 +16437,8 @@
{
"resourceName": "Platform",
"key": "App.ListForm.WizardFileExport",
"en": "Export as zip (wizard, custom/crud and sql seed files)",
"tr": "Zip olarak dışa aktar (wizard, custom/crud ve sql seed dosyaları)"
"en": "Export as zip (wizard, data/crud and sql seed files)",
"tr": "Zip olarak dışa aktar (wizard, data/crud ve sql seed dosyaları)"
},
{
"resourceName": "Platform",
@ -27231,8 +27213,26 @@
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportRunMigrateHint",
"en": "Run DB Migrate to load the imported seed files into the database.",
"tr": "İçe aktarılan seed dosyalarının veritabanına işlenmesi için DB Migrate çalıştırın."
"en": "Data rows were written to the database. Run DB Migrate for the remaining seed files (wizard, crud, sql).",
"tr": "Veri satırları veritabanına yazıldı. Kalan seed dosyaları (wizard, crud, sql) için DB Migrate çalıştırın."
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportDataApplied",
"en": "Rows written to the database",
"tr": "Veritabanına yazılan satırlar"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportDataRowSummary",
"en": "{inserted} inserted, {updated} updated, {skipped} unchanged",
"tr": "{inserted} eklendi, {updated} güncellendi, {skipped} değişmedi"
},
{
"resourceName": "Platform",
"key": "App.ListForm.WizardImportDataApplyFailed",
"en": "Files were written but some rows could not be applied to the database.",
"tr": "Dosyalar yazıldı ancak bazı satırlar veritabanına uygulanamadı."
},
{
"resourceName": "Platform",

View file

@ -6897,9 +6897,9 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = TableNameResolver.GetFullTableName(nameof(TableNameEnum.CustomEndpoint)),
SeedFilePath = SeedPathResolver.GetDataFilePath(listFormName),
SeedSyncInsert = false,
SeedSyncUpdate = false,
SeedSyncDelete = false,
SeedSyncInsert = true,
SeedSyncUpdate = true,
SeedSyncDelete = true,
KeyFieldName = "Id",
KeyFieldDbSourceType = DbType.Guid,
DefaultFilter = DefaultFilterJson,
@ -7179,9 +7179,9 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = dynamicServiceTable,
SeedFilePath = SeedPathResolver.GetDataFilePath(listFormName),
SeedSyncInsert = false,
SeedSyncUpdate = false,
SeedSyncDelete = false,
SeedSyncInsert = true,
SeedSyncUpdate = true,
SeedSyncDelete = true,
KeyFieldName = "Id",
KeyFieldDbSourceType = DbType.Guid,
DefaultFilter = DefaultFilterJson,
@ -7527,6 +7527,322 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency
}
#endregion
#region Custom Components
listFormName = AppCodes.DeveloperKits.CustomComponents;
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName))
{
// Ekran bilesenin ust bilgilerini yonetir; kod ve tasarim Visual Designer sayfasindadir.
var customComponentTable = TableNameResolver.GetFullTableName(nameof(TableNameEnum.CustomComponent));
var listForm = await _listFormRepository.InsertAsync(
new ListForm()
{
ListFormType = ListFormTypeEnum.List,
PageSize = 10,
ExportJson = DefaultExportJson,
IsSubForm = false,
ShowNote = false,
LayoutJson = JsonSerializer.Serialize(new LayoutDto
{
Grid = true,
Card = true,
Pivot = false,
Chart = false,
Tree = false,
Gantt = false,
Scheduler = false,
Todo = false,
DefaultLayout = "grid",
}),
CultureName = LanguageCodes.En,
ListFormCode = listFormName,
Name = listFormName,
Title = listFormName,
DataSourceCode = SeedConsts.DataSources.DefaultCode,
IsTenant = false,
IsBranch = false,
IsOrganizationUnit = false,
Description = $"{listFormName}.Description",
SelectCommandType = SelectCommandTypeEnum.Table,
SelectCommand = customComponentTable,
SeedFilePath = SeedPathResolver.GetDataFilePath(listFormName),
SeedSyncInsert = true,
SeedSyncUpdate = true,
SeedSyncDelete = true,
KeyFieldName = "Id",
KeyFieldDbSourceType = DbType.Guid,
DefaultFilter = DefaultFilterJson,
SortMode = GridOptions.SortModeSingle,
FilterRowJson = DefaultFilterRowJson(),
HeaderFilterJson = DefaultHeaderFilterJson(),
SearchPanelJson = DefaultSearchPanelJson(),
GroupPanelJson = DefaultGroupPanelJson(),
SelectionJson = DefaultSelectionSingleJson,
ColumnOptionJson = DefaultColumnOptionJson(),
PermissionJson = DefaultPermissionJson(listFormName),
PagerOptionJson = DefaultPagerOptionJson,
// Ekleme/duzenleme grid'in kendi popup formunda; kod tarafi Design butonundan acilir.
EditingOptionJson = DefaultEditingOptionJson(listFormName, 700, 520, true, true, true, true, false),
EditingFormJson = JsonSerializer.Serialize(new List<EditingFormDto>()
{
new() { Order = 1, ColCount = 2, ColSpan = 1, ItemType = "group" },
}),
InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
new() { FieldName = "CreationTime", FieldDbType = DbType.DateTimeOffset, Value = "@NOW", CustomValueType = FieldCustomValueTypeEnum.CustomKey },
new() { FieldName = "CreatorId", FieldDbType = DbType.Guid, Value = "@USERID", CustomValueType = FieldCustomValueTypeEnum.CustomKey },
new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@NEWID", CustomValueType = FieldCustomValueTypeEnum.CustomKey },
// Kod ve tasarim dokumani tasarimcida uretilir; kolon NOT NULL oldugu icin bos baslar.
new() { FieldName = "Code", FieldDbType = DbType.String, Value = "", CustomValueType = FieldCustomValueTypeEnum.Value },
}),
FormFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] {
new() { FieldName = "IsActive", FieldDbType = DbType.Boolean, Value = "true", CustomValueType = FieldCustomValueTypeEnum.Value },
}),
DeleteCommand = DefaultDeleteCommand(nameof(TableNameEnum.CustomComponent)),
DeleteFieldsDefaultValueJson = DefaultDeleteFieldsDefaultValueJson(),
CommandColumnJson = JsonSerializer.Serialize(new CommandColumnDto[] {
new() {
ButtonPosition = UiCommandButtonPositionTypeEnum.CommandColumn,
Hint = "App.Platform.Design",
Text = "App.Platform.Design",
AuthName = listFormName + ".Update",
Url = "/admin/developerkit/components/design/@Id",
UrlTarget = "_blank",
IsVisible = true,
},
new() {
ButtonPosition = UiCommandButtonPositionTypeEnum.CommandColumn,
Hint = "App.Platform.OpenUrl",
Text = "App.Platform.OpenUrl",
AuthName = listFormName,
Url = "@RoutePath",
UrlTarget = "_blank",
// Pasif ya da rotasi olmayan bilesenin onizlemesi acilmaz.
VisibleExpression = "(e) => Boolean(e.row.data.IsActive) && Boolean(e.row.data.RoutePath)",
IsVisible = true,
},
}),
WidgetsJson = JsonSerializer.Serialize(new List<WidgetEditDto>
{
new()
{
ColGap = 3,
ColSpan = 4,
SqlQuery = $@"
SELECT
'App.DeveloperKitComponent.Total' AS ""Title"",
COUNT(*) AS ""Value"",
'purple' AS ""Color"",
'App.DeveloperKitComponent.TotalDescription' AS ""SubTitle"",
'FaPuzzlePiece' AS ""Icon""
FROM ""{customComponentTable}""
WHERE ""IsDeleted"" = 'false'
UNION ALL
SELECT
'App.DeveloperKitComponent.Active' AS ""Title"",
COUNT(*) AS ""Value"",
'green' AS ""Color"",
'App.DeveloperKitComponent.ActiveDescription' AS ""SubTitle"",
'FaCheckCircle' AS ""Icon""
FROM ""{customComponentTable}""
WHERE ""IsDeleted"" = 'false' AND ""IsActive"" = 'true'
UNION ALL
SELECT
'App.DeveloperKitComponent.Inactive' AS ""Title"",
COUNT(*) AS ""Value"",
'gray' AS ""Color"",
'App.DeveloperKitComponent.InactiveDescription' AS ""SubTitle"",
'FaTimesCircle' AS ""Icon""
FROM ""{customComponentTable}""
WHERE ""IsDeleted"" = 'false' AND ""IsActive"" = 'false'
",
Title = "Title",
Value = "Value",
ValueClassName = "text-3xl",
Color = "Color",
Icon = "Icon",
SubTitle = "SubTitle",
ClassName = "mb-3",
IsActive = true
}
}),
}
);
#region Custom Components Fields
await _listFormFieldRepository.InsertManyAsync([
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Guid,
FieldName = "Id",
CaptionName = "App.Listform.ListformField.Id",
Width = 0,
ListOrderNo = 1,
Visible = false,
IsActive = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Name",
EditGroupOrderNo = 1,
EditOrderNo = 1,
EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1,
CaptionName = "App.Listform.ListformField.Name",
Width = 0,
ListOrderNo = 2,
Visible = true,
IsActive = true,
AllowSearch = true,
ValidationRuleJson = DefaultValidationRuleRequiredJson,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "RoutePath",
EditGroupOrderNo = 1,
EditOrderNo = 2,
EditorType2 = EditorTypes.dxTextBox,
ColSpan = 1,
CaptionName = "App.Platform.RoutePath",
Width = 0,
ListOrderNo = 3,
Visible = true,
IsActive = true,
AllowSearch = true,
// Rota "/" ile baslamalidir; sunucu tarafinda da ayni kural dogrulanir.
ValidationRuleJson = JsonSerializer.Serialize(new ValidationRuleDto[] {
new() { Type = Enum.GetName(UiColumnValidationRuleTypeEnum.required) },
new() { Type = Enum.GetName(UiColumnValidationRuleTypeEnum.pattern), Pattern = "^/.*", Message = "Route path must start with /" },
}),
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Description",
EditGroupOrderNo = 1,
EditOrderNo = 3,
EditorType2 = EditorTypes.dxTextArea,
ColSpan = 2,
EditorOptions = EditorOptions.Multiline(60),
CaptionName = "App.Listform.ListformField.Description",
Width = 0,
ListOrderNo = 4,
Visible = true,
IsActive = true,
AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.Boolean,
FieldName = "IsActive",
EditGroupOrderNo = 1,
EditOrderNo = 4,
EditorType2 = EditorTypes.dxCheckBox,
ColSpan = 1,
CaptionName = "App.Listform.ListformField.IsActive",
Width = 0,
ListOrderNo = 5,
Visible = true,
IsActive = true,
AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Dependencies",
EditorType2 = EditorTypes.dxTextBox,
CaptionName = "App.Platform.Dependencies",
Width = 0,
ListOrderNo = 6,
Visible = true,
IsActive = true,
AllowSearch = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Code",
CaptionName = "App.Listform.ListformField.Code",
Width = 0,
ListOrderNo = 7,
Visible = false,
IsActive = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "Props",
CaptionName = "App.Listform.ListformField.Props",
Width = 0,
ListOrderNo = 8,
Visible = false,
IsActive = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
new() {
ListFormCode = listForm.ListFormCode,
CultureName = LanguageCodes.En,
SourceDbType = DbType.String,
FieldName = "DataSources",
CaptionName = "App.Listform.ListformField.DataSources",
Width = 0,
ListOrderNo = 9,
Visible = false,
IsActive = true,
ColumnCustomizationJson = DefaultColumnCustomizationJson,
PermissionJson = DefaultFieldPermissionJson(listForm.Name),
PivotSettingsJson = DefaultPivotSettingsJson
},
]);
#endregion
}
#endregion
#region Products
listFormName = AppCodes.Orders.Products;
if (!await _listFormRepository.AnyAsync(a => a.ListFormCode == listFormName))

View file

@ -329,20 +329,12 @@
"authority": ["App.SqlQueryManager"]
},
{
"key": "admin.developerkit.components",
"path": "/admin/developerkit/components",
"componentType": "normal",
"componentPath": "@/views/developerKit/ComponentManagerPage",
"routeType": "protected",
"authority": ["App.DeveloperKit.Components"]
},
{
"key": "admin.developerkit.components.edit",
"path": "/admin/developerkit/components/edit/:id",
"key": "admin.developerkit.components.design",
"path": "/admin/developerkit/components/design/:id",
"componentType": "normal",
"componentPath": "@/views/developerKit/ComponentCodeLayout",
"routeType": "protected",
"authority": ["App.DeveloperKit.Components"]
"authority": ["App.DeveloperKit.CustomComponents"]
},
{
"key": "admin.intranet.documents",
@ -997,12 +989,12 @@
},
{
"ParentCode": "App.DeveloperKit",
"Code": "App.DeveloperKit.Components",
"DisplayName": "App.DeveloperKit.Components",
"Code": "App.DeveloperKit.CustomComponents",
"DisplayName": "App.DeveloperKit.CustomComponents",
"Order": 5,
"Url": "/admin/developerkit/components",
"Url": "/admin/list/App.DeveloperKit.CustomComponents",
"Icon": "FcBiohazard",
"RequiredPermissionName": "App.DeveloperKit.Components",
"RequiredPermissionName": "App.DeveloperKit.CustomComponents",
"IsDisabled": false,
"ModuleId": "Sas"
},

View file

@ -2374,7 +2374,7 @@
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.CustomEndpoints.Put",
"ParentName": "App.DeveloperKit.CustomEndpoints",
"DisplayName": "Put",
"DisplayName": "App.StaticLookup.Put",
"IsEnabled": true,
"MultiTenancySide": 2,
"Modules": "Sas|Adm"
@ -2471,17 +2471,17 @@
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components",
"Name": "App.DeveloperKit.CustomComponents",
"ParentName": "App.DeveloperKit",
"DisplayName": "App.DeveloperKit.Components",
"DisplayName": "App.DeveloperKit.CustomComponents",
"IsEnabled": true,
"MultiTenancySide": 2,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Create",
"ParentName": "App.DeveloperKit.Components",
"Name": "App.DeveloperKit.CustomComponents.Create",
"ParentName": "App.DeveloperKit.CustomComponents",
"DisplayName": "App.Platform.Create",
"IsEnabled": true,
"MultiTenancySide": 2,
@ -2489,8 +2489,8 @@
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Update",
"ParentName": "App.DeveloperKit.Components",
"Name": "App.DeveloperKit.CustomComponents.Update",
"ParentName": "App.DeveloperKit.CustomComponents",
"DisplayName": "App.Platform.Update",
"IsEnabled": true,
"MultiTenancySide": 2,
@ -2498,13 +2498,31 @@
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.Components.Delete",
"ParentName": "App.DeveloperKit.Components",
"Name": "App.DeveloperKit.CustomComponents.Delete",
"ParentName": "App.DeveloperKit.CustomComponents",
"DisplayName": "App.Platform.Delete",
"IsEnabled": true,
"MultiTenancySide": 2,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.CustomComponents.Export",
"ParentName": "App.DeveloperKit.CustomComponents",
"DisplayName": "Export",
"IsEnabled": true,
"MultiTenancySide": 2,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.CustomComponents.Import",
"ParentName": "App.DeveloperKit.CustomComponents",
"DisplayName": "Import",
"IsEnabled": true,
"MultiTenancySide": 2,
"Modules": "Sas|Adm"
},
{
"GroupName": "App.Saas",
"Name": "App.DeveloperKit.IntranetWidgets",

View file

@ -494,7 +494,7 @@ public static class PlatformConsts
/// </summary>
public static class Components
{
public const string Component = Default + ".Components";
public const string Component = Default + ".CustomComponents";
public const string Create = Component + ".Create";
public const string Update = Component + ".Update";

View file

@ -9,9 +9,8 @@ namespace Sozsoft.Platform.Data.Seeds;
/// 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.
/// Cozum kaydetme sirasinda (CustomComponentAppService) katalogu veritabanindan okur;
/// boylece <c>DataSources</c> sutunu tasarimci dokumanindan tutarli sekilde uretilir.
/// </para>
/// </summary>
public static class CustomComponentDataSourceResolver

View file

@ -0,0 +1,28 @@
namespace Sozsoft.Platform.Data.Seeds;
/// <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,71 +0,0 @@
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

@ -28,6 +28,40 @@ public interface IListFormSeedDataApplier
{
/// <summary>Verilen kapsamdaki tum veri seed dosyalarini uygular.</summary>
Task ApplyAsync(Guid? tenantId);
/// <summary>
/// Kapsam klasorune goreli belirli veri seed dosyalarini uygular; import gibi dosya bazli
/// akislar butun klasoru taramak yerine yalnizca yazilan dosyalari uygular. Dosyalar
/// <c>ApplyAsync</c> ile ayni kurala gore siralanir.
/// </summary>
/// <param name="overwriteExisting">
/// true ise anahtari veritabaninda bulunan satir dosyadaki degerlerle guncellenir. Migrate/seed
/// akisi bunu kullanmaz — orada dosya eski olabilir ve canli kayit ezilmemelidir; import ise
/// kullanicinin bilincli olarak getirdigi icerik oldugu icin gunceller.
/// </param>
Task<IReadOnlyList<ListFormSeedDataApplyResult>> ApplyFilesAsync(
Guid? tenantId,
IReadOnlyCollection<string> relativePaths,
bool overwriteExisting = false);
}
/// <summary>
/// Tek bir veri seed dosyasinin uygulama sonucu. <see cref="Error"/> dolu ise dosya islenemedi;
/// diger dosyalarin uygulanmasi bundan etkilenmez.
/// </summary>
public record ListFormSeedDataApplyResult(string Path, int Inserted, int Updated, int Skipped, string? Error = null);
/// <summary>Bir satirin veritabaninda ne olduguna dair sonuc.</summary>
public enum SeedRowOutcome
{
/// <summary>Kayit yoktu, eklendi.</summary>
Inserted,
/// <summary>Kayit vardi, dosyadaki degerlerle guncellendi.</summary>
Updated,
/// <summary>Kayit vardi, dokunulmadi.</summary>
Skipped,
}
/// <summary>
@ -60,6 +94,50 @@ public class ListFormSeedDataApplier(
/// <summary>Cozulen kullanici anahtarlari; ayni kullanici her satirda yeniden sorgulanmaz.</summary>
private readonly Dictionary<string, Guid?> userIdCache = [];
public async Task<IReadOnlyList<ListFormSeedDataApplyResult>> ApplyFilesAsync(
Guid? tenantId,
IReadOnlyCollection<string> relativePaths,
bool overwriteExisting = false)
{
if (relativePaths == null || relativePaths.Count == 0)
return [];
var scopeRoot = Path.GetFullPath(SeedPathResolver.GetScopePath(configuration, tenantId));
var results = new List<ListFormSeedDataApplyResult>();
var filePaths = new List<string>();
foreach (var relativePath in relativePaths)
{
var normalized = relativePath.Replace('\\', '/').Trim('/');
var fullPath = normalized.Contains("..", StringComparison.Ordinal)
? null
: Path.GetFullPath(Path.Combine(scopeRoot, normalized.Replace('/', Path.DirectorySeparatorChar)));
if (fullPath == null
|| !fullPath.StartsWith(scopeRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| !File.Exists(fullPath))
{
results.Add(new ListFormSeedDataApplyResult(normalized, 0, 0, 0, "Seed data file not found."));
continue;
}
filePaths.Add(fullPath);
}
using (currentTenant.Change(tenantId))
{
foreach (var filePath in OrderFiles([.. filePaths]))
{
// Her dosya kendi unit of work'unde islenir; ApplyAsync ile ayni kural.
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
results.Add(await ApplyFileAsync(filePath, overwriteExisting));
await uow.CompleteAsync();
}
}
return results;
}
public async Task ApplyAsync(Guid? tenantId)
{
var scopeFolderName = SeedPathResolver.GetScopeFolderName(tenantId);
@ -87,7 +165,7 @@ public class ListFormSeedDataApplier(
// Seed contributor'larin unit of work'u kapandigi icin her dosya kendi
// unit of work'unde islenir; aksi halde DbContext disposed gelir.
using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
await ApplyFileAsync(filePath);
await ApplyFileAsync(filePath, overwriteExisting: false);
await uow.CompleteAsync();
}
}
@ -126,7 +204,11 @@ public class ListFormSeedDataApplier(
}
}
private async Task ApplyFileAsync(string filePath)
/// <summary>
/// Dosyayi okur ve satirlari ilgili ListForm'un tablosuna uygular. Tek bir dosyanin hatasi
/// diger dosyalari etkilemez; hata sonuca tasinir ve loglanir.
/// </summary>
private async Task<ListFormSeedDataApplyResult> ApplyFileAsync(string filePath, bool overwriteExisting)
{
var fileName = Path.GetFileName(filePath);
try
@ -142,14 +224,14 @@ public class ListFormSeedDataApplier(
if (seedFile?.Rows == null || seedFile.Rows.Count == 0)
{
logger.LogWarning("[{File}] No row found, skipped.", fileName);
return;
return new ListFormSeedDataApplyResult(fileName, 0, 0, 0);
}
var listForm = await listFormRepository.FirstOrDefaultAsync(a => a.ListFormCode == listFormCode);
if (listForm == null)
{
logger.LogWarning("[{File}] ListForm '{ListFormCode}' not found, skipped.", fileName, listFormCode);
return;
return new ListFormSeedDataApplyResult(fileName, 0, 0, 0, $"ListForm '{listFormCode}' not found.");
}
var fields = await listFormFieldRepository.GetListAsync(a => a.ListFormCode == listFormCode);
@ -160,38 +242,53 @@ public class ListFormSeedDataApplier(
var (repository, connectionString, dataSourceType) = await dynamicDataManager.GetAsync(listForm.IsTenant, listForm.DataSourceCode);
var inserted = 0;
var updated = 0;
var skipped = 0;
foreach (var row in seedFile.Rows)
{
if (await ApplyRowAsync(listForm, fields, keyFieldName, row, repository, connectionString, dataSourceType))
inserted++;
else
skipped++;
switch (await ApplyRowAsync(listForm, fields, keyFieldName, row, repository, connectionString, dataSourceType, overwriteExisting))
{
case SeedRowOutcome.Inserted:
inserted++;
break;
case SeedRowOutcome.Updated:
updated++;
break;
default:
skipped++;
break;
}
}
logger.LogInformation("[{File}] {Inserted} row(s) inserted, {Skipped} existing row(s) skipped.", fileName, inserted, skipped);
logger.LogInformation(
"[{File}] {Inserted} row(s) inserted, {Updated} row(s) updated, {Skipped} existing row(s) skipped.",
fileName, inserted, updated, skipped);
return new ListFormSeedDataApplyResult(fileName, inserted, updated, skipped);
}
catch (Exception ex)
{
// Tek bir dosyanin hatasi diger listelerin seed edilmesini engellemez.
logger.LogError(ex, "Failed to process list form data seed file: {FileName}", fileName);
return new ListFormSeedDataApplyResult(fileName, 0, 0, 0, ex.Message);
}
}
/// <summary>
/// Satiri ekler; kayit eklendiyse <c>true</c>, ayni kayit zaten varsa <c>false</c> doner.
/// Satiri veritabanina yazar. Kayit yoksa eklenir; varsa <paramref name="overwriteExisting"/>
/// true ise dosyadaki degerlerle guncellenir ve soft delete izleri temizlenir, degilse dokunulmaz.
/// Dosyada anahtar yoksa (identity kolonlu tablolar) satir diger alanlarinin tamami uzerinden
/// eslesir ve anahtar veritabanina biraktirilir.
/// eslesir, anahtar veritabanina biraktirilir ve guncelleme yapilamaz.
/// </summary>
private async Task<bool> ApplyRowAsync(
private async Task<SeedRowOutcome> ApplyRowAsync(
ListForm listForm,
List<ListFormField> fields,
string keyFieldName,
Dictionary<string, object> row,
IDynamicDataRepository repository,
string connectionString,
DataSourceTypeEnum dataSourceType)
DataSourceTypeEnum dataSourceType,
bool overwriteExisting)
{
var keyField = fields.FirstOrDefault(a => a.FieldName == keyFieldName);
var keyValue = QueryHelper.GetFormattedValue(
@ -201,9 +298,9 @@ public class ListFormSeedDataApplier(
object[] keys = [keyValue];
var hasKey = keyValue != null;
var insertParameters = BuildParameters(fields, await ResolveTokensAsync(row), keyFieldName, includeKey: hasKey);
var resolvedRow = await ResolveTokensAsync(row);
var insertParameters = BuildParameters(fields, resolvedRow, keyFieldName, includeKey: hasKey);
// Var olan kayda dokunulmaz: veritabanindaki guncel deger, dosyadaki eski degerle ezilmemeli.
var match = hasKey
? new Dictionary<string, object> { [keyFieldName] = keyValue }
: new Dictionary<string, object>(insertParameters);
@ -215,7 +312,31 @@ public class ListFormSeedDataApplier(
if (await ExistsAsync(listForm, match, repository, connectionString))
{
return false;
// Anahtarsiz satirin hangi kayda karsilik geldigi bilinmedigi icin guncellenemez.
if (!overwriteExisting || !hasKey)
return SeedRowOutcome.Skipped;
// Anahtar SET listesine girmez; GenerateQuery onu WHERE icin parametrelere ekler.
var updateParameters = BuildParameters(fields, resolvedRow, keyFieldName, includeKey: false);
// Kayit daha once silinmis olabilir: satir tabloda durur ama ekranin varsayilan
// suzgeci ("IsDeleted" = 'false') onu gizler. Dosyadan gelen icerik kaydin var olmasi
// gerektigini soyledigi icin silinme izleri temizlenir, aksi halde import sessizce
// gorunmeyen bir kayit gunceller.
foreach (var reset in BuildSoftDeleteResetParameters(listForm))
{
if (!updateParameters.ContainsKey(reset.Key))
updateParameters[reset.Key] = reset.Value;
}
if (updateParameters.Count == 0)
return SeedRowOutcome.Skipped;
await MergeDefaultsAsync(listForm, fields, OperationEnum.Update, keys, updateParameters);
var updateSql = queryManager.GenerateQuery(listForm, fields, updateParameters, OperationEnum.Update, dataSourceType, keys);
await repository.ExecuteAsync(updateSql, connectionString, updateParameters);
return SeedRowOutcome.Updated;
}
await MergeDefaultsAsync(listForm, fields, OperationEnum.Insert, keys, insertParameters);
@ -235,7 +356,32 @@ public class ListFormSeedDataApplier(
var insertSql = queryManager.GenerateQuery(listForm, fields, insertParameters, OperationEnum.Insert, dataSourceType, keys);
await repository.ExecuteAsync(insertSql, connectionString, insertParameters);
return true;
return SeedRowOutcome.Inserted;
}
/// <summary>
/// Ekranin silme komutunun yazdigi soft delete kolonlarini bulup onlari sifirlayan
/// parametreleri dondurur. Kolon listesi komuttan okunur; boylece yalnizca gercekten var olan
/// kolonlara yazilir ve donanimsal (hard) silme yapan bir ekranda hicbir sey eklenmez.
/// </summary>
private static Dictionary<string, object> BuildSoftDeleteResetParameters(ListForm listForm)
{
var command = listForm.DeleteCommand;
var parameters = new Dictionary<string, object>();
if (string.IsNullOrWhiteSpace(command))
return parameters;
if (command.Contains("IsDeleted", StringComparison.OrdinalIgnoreCase))
parameters["IsDeleted"] = false;
if (command.Contains("DeletionTime", StringComparison.OrdinalIgnoreCase))
parameters["DeletionTime"] = null;
if (command.Contains("DeleterId", StringComparison.OrdinalIgnoreCase))
parameters["DeleterId"] = null;
return parameters;
}
/// <summary>Verilen alan degerleriyle eslesen bir kayit hedef tabloda var mi.</summary>

View file

@ -476,6 +476,7 @@ public static class SeedConsts
public const string IntranetWidgets = Default + ".IntranetWidgets";
public const string DynamicServices = Default + ".DynamicServices";
public const string CustomComponents = Default + ".CustomComponents";
}
public const string Forum = Prefix.App + ".Forum";

View file

@ -23,7 +23,6 @@ namespace Sozsoft.Platform.Data.Seeds;
/// seeds/
/// host/ → host (varsayilan) veritabani kapsami
/// crud/
/// custom/
/// wizard/
/// data/
/// sql/ → SQL Server saglayicisi
@ -34,7 +33,7 @@ namespace Sozsoft.Platform.Data.Seeds;
/// execute/
/// tenants/
/// {tenantId}/ → her tenant kendi klasorunde
/// crud/ custom/ wizard/ data/ sql/{object,execute}/ postgres/{object,execute}/
/// crud/ wizard/ data/ sql/{object,execute}/ postgres/{object,execute}/
/// </code>
/// </para>
/// <para>
@ -72,9 +71,6 @@ public static class SeedPathResolver
/// <summary>Crud endpoint konfigurasyon dosyalari.</summary>
public const string CrudFolder = "crud";
/// <summary>Custom component konfigurasyon dosyalari.</summary>
public const string CustomFolder = "custom";
/// <summary>Liste verisinin aynalandigi seed dosyalari (<c>data/{ListFormCode}.json</c>).</summary>
public const string DataFolder = "data";

View file

@ -184,15 +184,21 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` →
`permission.constant.ts` + `LanguagesData.json`.
- **Yeni bir dil anahtarı**`LanguagesData.json`; eklemeden önce anahtarın zaten var olup
olmadığını kontrol et.
- **Custom component kaydı/silinmesi**`custom/{Name}.json`; kullandığı endpoint'ler
`crud/{Entity}.json`.
- **Wizard dosyası** → tek başına taşınmaz; export zip'i `wizard/`, `custom/`, `crud/` ve
`{sql|postgres}/{object|execute}/` bağımlılıklarını da içerir.
- **Custom component kaydı/silinmesi**`data/App.DeveloperKit.CustomComponents.json`
(Custom Components ekranının seed verisi); bileşenin kendi dosyası yoktur, her bileşen bu
dosyada bir satırdır. Hem listeden hem Visual Designer'dan (`CustomComponentAppService`) yapılan
kayıt aynı dosyaya yansır; kullandığı endpoint'ler `crud/{Entity}.json`.
- **Wizard dosyası** → tek başına taşınmaz; export zip'i `wizard/`, `data/`, `crud/` ve
`{sql|postgres}/{object|execute}/` bağımlılıklarını da içerir. `Custom` yolunda `data/` girdisi
yalnızca ilgili bileşen ve bağımlılık zincirine süzülür; import'ta hedef dosyanın üstüne
yazılmaz, satırlar anahtar üzerinden birleştirilir.
- **`ListForm.SeedFilePath` dolu bir ekran** → `SeedSyncInsert/SeedSyncUpdate/SeedSyncDelete`
bayraklarından işaretli olan işlemler kapsam klasöründeki `data/{ListFormCode}.json` dosyasına
yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında `ListFormSeedDataApplier` dosyayı
geri uygular (kayıt yoksa ekler, varsa dokunmaz). Yeni bir kayıt yolu (yeni endpoint, toplu işlem)
eklenirse senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
yansır (`ListFormSeedDataSynchronizer`); migrate/seed sırasında ve wizard import'unun kapanışında
(`CompleteImport`) `ListFormSeedDataApplier` dosyayı geri uygular. Migrate/seed'de kayıt yoksa
eklenir, varsa dokunulmaz; import'ta `overwriteExisting`ıktır: var olan kayıt dosyadaki
değerlerle güncellenir ve soft delete izleri (`IsDeleted`/`DeletionTime`/`DeleterId`) temizlenir. Yeni bir kayıt yolu (yeni endpoint, toplu işlem, tipli AppService) eklenirse
senkronizasyon çağrısı da eklenir; yoksa dosya bayatlar.
- **`MenusData.json`** → `Routes` bölümüne dokunma; yalnızca `Modules` ve `Menus`.
- **Çok değerli alan (`dxTagBox` / çoklu `dxGridBox`)** → tek metin kolonunda `|` ile saklanır.
Ekran varsayılan `list-form-data/*` ucundan kaydediyorsa birleştirmeyi `QueryHelper` yapar;

View file

@ -1,15 +0,0 @@
{
"GeneratedAt": "2026-08-20T11:16:38.2313189Z",
"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": []
}
]
}

View file

@ -1,15 +0,0 @@
{
"GeneratedAt": "2026-08-20T11:23:45.7859641Z",
"CustomComponents": [
{
"Name": "NewComponent",
"RoutePath": "/admin/new-component",
"Code": "/*__SOZSOFT_VISUAL_DESIGNER__%7B%22version%22%3A1%2C%22sourceMode%22%3A%22visual%22%2C%22nodes%22%3A%5B%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%5D%7D__*/\nconst NewComponent = () => {\n\n\n return (\n <>\n\n </>\n )\n}\n\nexport default NewComponent",
"Props": "{\"visualDesigner\":{\"version\":1,\"sourceMode\":\"visual\",\"nodes\":[],\"canvas\":{\"width\":\"responsive\"},\"lifecycle\":{\"onMount\":\"\"},\"dataSources\":[]}}",
"Description": "",
"IsActive": true,
"Dependencies": [],
"DataSources": []
}
]
}

View file

@ -1,17 +0,0 @@
{
"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

@ -0,0 +1,159 @@
{
"Wizard": {
"ComponentKind": 1,
"WizardName": "RoleList",
"WizardCode": "App.Wizard.RoleList",
"SeededAt": "2026-09-05T19:53:59.1764998\u002B03:00",
"CustomComponentName": "RoleComponent",
"PermissionGroupName": "App.Wizard.Sales",
"ModuleId": "Sal",
"Menu": {
"MenuParentCode": "App.Wizard.Sales",
"MenuParentIcon": "FcAssistant",
"MenuCode": "App.Wizard.RoleList",
"MenuOrder": 2,
"MenuUrl": "/admin/roles",
"MenuIcon": "FcBullish"
},
"Language": {
"MenuParentEn": "Sales",
"MenuParentTr": "Sat\u0131\u015F",
"PermissionGroupEn": "Sales",
"PermissionGroupTr": "Sat\u0131\u015F",
"MenuEn": "Role List",
"MenuTr": "Role List",
"TitleEn": "Role List",
"TitleTr": "Role List",
"DescEn": "Role List",
"DescTr": "Role List"
},
"Groups": [],
"IsTenant": false,
"IsBranch": false,
"IsOrganizationUnit": false,
"AllowAdding": true,
"AllowUpdating": true,
"AllowDeleting": true,
"AllowDetail": false,
"ConfirmDelete": true,
"DefaultLayout": "grid",
"Grid": true,
"Card": true,
"Pivot": true,
"Tree": false,
"Chart": true,
"Gantt": false,
"Scheduler": false,
"Todo": false,
"DataSourceCode": "Default",
"SelectCommandType": 1,
"SelectCommand": "",
"KeyFieldName": "",
"KeyFieldDbSourceType": 11,
"TreeOptionDto": {
"KeyExpr": "",
"ParentIdExpr": "",
"HasItemsExpr": "",
"RootValue": "",
"ExpandedRowKeys": [],
"AutoExpandAll": false,
"RecursiveSelection": false,
"TitleExpr": null,
"StartExpr": null,
"EndExpr": null,
"ProgressExpr": null
},
"GanttOptionDto": {
"KeyExpr": "",
"ParentIdExpr": "",
"HasItemsExpr": "",
"RootValue": "",
"ExpandedRowKeys": [],
"AutoExpandAll": false,
"RecursiveSelection": false,
"TitleExpr": "",
"ScaleType": "weeks",
"StartExpr": "",
"EndExpr": "",
"ProgressExpr": "",
"EditingEnabled": false,
"AllowTaskAdding": false,
"AllowTaskUpdating": false,
"AllowTaskResourceUpdating": false,
"AllowTaskDeleting": false,
"AllowDependencyAdding": false,
"AllowDependencyDeleting": false,
"AllowResourceAdding": false,
"AllowResourceUpdating": false,
"AllowResourceDeleting": false
},
"SchedulerOptionDto": {
"TextExpr": "",
"UserNameExpr": null,
"DescriptionExpr": null,
"StartDateExpr": "",
"EndDateExpr": "",
"AllDayExpr": "",
"RecurrenceRuleExpr": "",
"RecurrenceExceptionExpr": "",
"StartDayHour": 8,
"EndDayHour": 20,
"DefaultView": "week",
"ShowAllDayPanel": true,
"CellDuration": 30,
"FirstDayOfWeek": 1,
"CrossScrollingEnabled": false,
"AllowResizing": false,
"AllowDragging": false,
"AllowDeleting": false,
"AllowEditing": false,
"AllowAdding": false
},
"TodoOptionDto": {
"TitleExpr": "",
"StatusExpr": "",
"DescriptionExpr": "",
"DueDateExpr": "",
"TagExpr": "",
"AssigneeExpr": "",
"PriorityExpr": "",
"CompletedExpr": "",
"OrderExpr": "",
"TimesheetExpr": null,
"SubTaskExpr": null,
"StatusOrder": "Backlog, Ready to Start, In Progress, Code Review, Testing, Staging",
"AllowDragging": true
},
"WorkflowDto": {
"ApprovalUserFieldName": "",
"ApprovalDateFieldName": "",
"ApprovalStatusFieldName": "",
"ApprovalDescriptionFieldName": "",
"ApprovalIsFilterUserName": false,
"ApprovalIsResetWorkflow": false,
"Criteria": []
},
"SubForms": [],
"Widgets": []
},
"ListForm": null,
"Fields": [],
"IsDeletedField": false,
"IsCreatedField": false,
"InsertedRecords": {
"LanguageKeys": [
"App.Wizard.RoleList"
],
"PermissionGroupNames": [],
"PermissionNames": [
"App.Wizard.RoleList",
"App.Wizard.RoleList.Create",
"App.Wizard.RoleList.Update",
"App.Wizard.RoleList.Delete"
],
"MenuCodes": [
"App.Wizard.RoleList"
],
"DataSourceCodes": []
}
}

View file

@ -1,14 +0,0 @@
import type { PropsWithChildren } from 'react'
import { Container } from '../shared'
const DeveloperLayout = ({ children }: PropsWithChildren) => {
return (
<Container>
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1">{children}</div>
</div>
</Container>
)
}
export default DeveloperLayout

View file

@ -31,19 +31,6 @@ export const WIZARD_PERMISSION = {
IMPORT: 'App.Listforms.Wizard.Import',
} as const
/**
* Custom component yöneticisi yetkileri. `PermissionsData.json` içinde tanımlıdır ve seeder ile
* yönetilir; buradaki kontroller yalnızca butonları gizler, asıl kontrol
* `CustomComponentAppService` üzerindeki ABP policy adlarındadır.
*/
export const COMPONENT_PERMISSION = {
/** Ekranı görme (okuma) yetkisi; alt yetkilerin tamamının üstüdür. */
DEFAULT: 'App.DeveloperKit.Components',
CREATE: 'App.DeveloperKit.Components.Create',
UPDATE: 'App.DeveloperKit.Components.Update',
DELETE: 'App.DeveloperKit.Components.Delete',
} as const
/**
* ListForm alan (Fields) yoneticisi yetkileri. `PermissionsData.json` icinde tanimlidir ve
* seeder ile yonetilir; buradaki kontroller yalnizca butonlari kapatir, asil kontrol

View file

@ -23,7 +23,6 @@ import {
buildRuntimeScope,
compileComponentBundle,
getCompilationSignature,
toErrorMessage,
type BabelLike,
type RuntimeComponent,
} from './componentRuntime'
@ -34,19 +33,12 @@ interface ComponentProps {
interface ComponentContextType {
components: CustomComponent[]
loading: boolean
error: string | null
/** True while the runtime bundle is being (re)compiled. */
compiling: boolean
/** Compilation/initialization errors keyed by component name. */
componentErrors: Record<string, string>
addComponent: (component: CreateUpdateCustomComponentDto) => Promise<void>
updateComponent: (id: string, component: CreateUpdateCustomComponentDto) => Promise<void>
deleteComponent: (id: string) => Promise<void>
getComponent: (id: string) => CustomComponent | undefined
getComponentByName: (name: string) => CustomComponent | undefined
refreshComponents: () => Promise<void>
refreshComponent: (id: string) => Promise<CustomComponent | undefined>
/** Natively implemented components exposed to dynamic routes by name. */
registeredComponents: Record<string, React.ComponentType<unknown>>
registerComponent: (name: string, component: React.ComponentType<unknown>) => void
@ -103,8 +95,6 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const extraProperties = applicationConfig?.extraProperties
const [components, setComponents] = useState<CustomComponent[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [compiling, setCompiling] = useState(false)
const [registeredComponents, setRegisteredComponents] =
useState<Record<string, React.ComponentType<unknown>>>(EMPTY_NATIVE_REGISTRY)
@ -145,41 +135,16 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
if (!applicationConfig) return
try {
setError(null)
const result = await developerKitService.getCustomComponents()
const nextComponents = result.items || []
setComponents((current) =>
haveSameComponentRevision(current, nextComponents) ? current : nextComponents,
)
} catch (err) {
setError(toErrorMessage(err))
console.error('Failed to fetch components:', err)
}
}, [applicationConfig])
const refreshComponent = useCallback(
async (id: string): Promise<CustomComponent | undefined> => {
if (!applicationConfig) return undefined
try {
setError(null)
const freshComponent = await developerKitService.getCustomComponent(id)
setComponents((current) => {
const exists = current.some((component) => component.id === id)
return exists
? current.map((component) => (component.id === id ? freshComponent : component))
: [...current, freshComponent]
})
return freshComponent
} catch (err) {
setError(toErrorMessage(err))
console.error('Failed to fetch component:', err)
return undefined
}
},
[applicationConfig],
)
// Components delivered with the application configuration are the initial set.
useEffect(() => {
const initialComponents =
@ -197,60 +162,17 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
return () => componentsChangedChannel?.removeEventListener('message', handleComponentsChanged)
}, [refreshComponents])
const addComponent = useCallback(async (componentData: CreateUpdateCustomComponentDto) => {
try {
setLoading(true)
setError(null)
const newComponent = await developerKitService.createCustomComponent(componentData)
setComponents((prev) => [...prev, newComponent])
notifyComponentsChanged()
} catch (err) {
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
}
}, [])
const updateComponent = useCallback(
async (id: string, componentData: CreateUpdateCustomComponentDto) => {
try {
setLoading(true)
setError(null)
const updatedComponent = await developerKitService.updateCustomComponent(id, componentData)
setComponents((prev) =>
prev.map((component) => (component.id === id ? updatedComponent : component)),
)
notifyComponentsChanged()
} catch (err) {
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
}
const updatedComponent = await developerKitService.updateCustomComponent(id, componentData)
setComponents((prev) =>
prev.map((component) => (component.id === id ? updatedComponent : component)),
)
notifyComponentsChanged()
},
[],
)
const deleteComponent = useCallback(async (id: string) => {
try {
setLoading(true)
setError(null)
await developerKitService.deleteCustomComponent(id)
setComponents((prev) => prev.filter((component) => component.id !== id))
notifyComponentsChanged()
} catch (err) {
setError(toErrorMessage(err))
throw err
} finally {
setLoading(false)
}
}, [])
const componentsById = useMemo(
() => new Map(components.map((component) => [component.id, component])),
[components],
)
const componentsByName = useMemo(
() => new Map(components.map((component) => [component.name, component])),
[components],
@ -271,8 +193,6 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
[compilableComponents],
)
const getComponent = useCallback((id: string) => componentsById.get(id), [componentsById])
const getComponentByName = useCallback(
(name: string) => componentsByName.get(name),
[componentsByName],
@ -397,17 +317,10 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const value = useMemo<ComponentContextType>(
() => ({
components,
loading,
error,
compiling,
componentErrors,
addComponent,
updateComponent,
deleteComponent,
getComponent,
getComponentByName,
refreshComponents,
refreshComponent,
registeredComponents,
registerComponent,
renderComponent,
@ -415,17 +328,10 @@ export const ComponentProvider: React.FC<{ children: React.ReactNode }> = ({ chi
}),
[
components,
loading,
error,
compiling,
componentErrors,
addComponent,
updateComponent,
deleteComponent,
getComponent,
getComponentByName,
refreshComponents,
refreshComponent,
registeredComponents,
registerComponent,
renderComponent,

Binary file not shown.

View file

@ -192,7 +192,7 @@ export enum WizardImportEntryStatusEnum {
}
export interface WizardImportEntryDto {
/** Kapsam klasorune goreli yol, ornegin `custom/Orders.json`. */
/** Kapsam klasorune goreli yol, ornegin `crud/Orders.json`. */
path: string
folder: string
fileName: string
@ -213,6 +213,28 @@ export interface WizardImportAnalysisDto {
warnings: string[]
}
/**
* Import kapanisinin sonucu. Yazilan `data/*.json` dosyalari ayni islemde veritabanina da
* uygulanir; her dosyanin sonucu burada raporlanir.
*/
export interface WizardImportCompleteResultDto {
appliedData: WizardImportSeedApplyResultDto[]
}
/** Tek bir veri seed dosyasinin veritabanina uygulanma sonucu. */
export interface WizardImportSeedApplyResultDto {
/** Kapsam klasorune goreli yol. */
path: string
/** Veritabanina eklenen satir sayisi. */
insertedRowCount: number
/** Zaten var oldugu icin dosyadaki degerlerle guncellenen satir sayisi. */
updatedRowCount: number
/** Anahtari olmadigi icin guncellenemeyen, dokunulmayan satir sayisi. */
skippedRowCount: number
/** Dosya uygulanamadiysa nedeni; basarili durumda bostur. */
error?: string
}
export interface WizardImportApplyResultDto {
path: string
overwritten: boolean

View file

@ -39,8 +39,7 @@
entities: '/admin/developerkit/entities',
entitiesNew: '/admin/developerkit/entities/new',
entitiesEdit: '/admin/developerkit/entities/edit/:id',
components: '/admin/developerkit/components',
componentsEdit: '/admin/developerkit/components/edit/:id',
components: '/admin/list/App.DeveloperKit.CustomComponents',
},
reports: {
view: '/admin/reports/:report/view/:id?/:listFormCode?',

View file

@ -18,14 +18,6 @@ class DeveloperKitService {
return response.data
}
async getActiveCustomComponents(): Promise<PagedResultDto<CustomComponentDto>> {
const response = await apiService.fetchData<PagedResultDto<CustomComponentDto>>({
url: '/api/app/custom-component/active-components',
method: 'GET',
})
return response.data
}
async getCustomComponent(id: string): Promise<CustomComponent> {
const response = await apiService.fetchData<CustomComponent>({
url: `/api/app/custom-component/${id}`,
@ -34,15 +26,6 @@ class DeveloperKitService {
return response.data
}
async createCustomComponent(component: CreateUpdateCustomComponentDto): Promise<CustomComponent> {
const response = await apiService.fetchData<CustomComponent>({
url: '/api/app/custom-component',
method: 'POST',
data: component as any,
})
return response.data
}
async updateCustomComponent(
id: string,
component: CreateUpdateCustomComponentDto,
@ -59,13 +42,6 @@ class DeveloperKitService {
return response.data
}
async deleteCustomComponent(id: string): Promise<void> {
await apiService.fetchData<void>({
url: `/api/app/custom-component/${id}`,
method: 'DELETE',
})
}
// Generated Endpoint endpoints
async toggleGeneratedEndpoint(id: string): Promise<CrudEndpoint> {
const response = await apiService.fetchData<CrudEndpoint>({

View file

@ -3,6 +3,7 @@ import {
WizardFileInfoDto,
WizardImportAnalysisDto,
WizardImportApplyResultDto,
WizardImportCompleteResultDto,
WizardPermissionGroupDto,
WizardSeedFileDto,
} from '@/proxy/admin/wizard/models'
@ -43,7 +44,7 @@ export const deleteWizardFile = (fileName: string) =>
})
/**
* Wizard dosyasini ve bagli seed dosyalarini (custom/crud/sql) tek bir zip olarak indirir.
* Wizard dosyasini ve bagli seed dosyalarini (data/crud/sql) tek bir zip olarak indirir.
* Zip icindeki klasor duzeni seed kapsam klasoru ile aynidir; baska bir ortamda dogrudan acilabilir.
*/
export const exportWizardFile = (fileName: string) =>
@ -93,9 +94,12 @@ export const applyWizardImport = (importId: string, path: string, content?: stri
data: { importId, path, content: content ?? null },
})
/** Import oturumunu kapatir; staging ve yedek klasorleri silinir. */
/**
* Import oturumunu kapatir; staging ve yedek klasorleri silinir. Kapanistan once yazilan
* `data/*.json` dosyalari veritabanina uygulanir ve sonucu doner.
*/
export const completeWizardImport = (importId: string) =>
apiService.fetchData({
apiService.fetchData<WizardImportCompleteResultDto>({
method: 'POST',
url: `/api/app/list-form-wizard/complete-import`,
params: { importId },

View file

@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom'
import classNames from 'classnames'
import { Button, Dialog, Input, Notification, toast } from '@/components/ui'
import Container from '@/components/shared/Container'
import Loading from '@/components/shared/Loading'
import PageTitle from '@/components/shared/PageTitle'
import {
FaTrash,
FaSync,
@ -234,6 +236,8 @@ const WizardFileManager = () => {
return (
<Container>
<PageTitle title={translate('::App.Listforms.WizardManager')} />
{/* ── Header ─────────────────────────────────────────────── */}
<div
className={classNames(
@ -268,6 +272,7 @@ const WizardFileManager = () => {
>
<FaSync />
</Button>
<DbMigrateButton hideLabelOnMobile />
{canImport && (
<>
<Button
@ -293,7 +298,6 @@ const WizardFileManager = () => {
/>
</>
)}
<DbMigrateButton hideLabelOnMobile />
{canCreate && (
<Button
size="sm"
@ -328,7 +332,11 @@ const WizardFileManager = () => {
</div>
</div>
<div className="mt-4">
<Loading
type="cover"
loading={loading}
className={classNames('mt-4', loading && 'min-h-[240px]')}
>
{filteredFiles.length === 0 && !loading && (
<div className="text-center py-12">
<FaCalendarAlt size={48} className="mx-auto text-gray-400 mb-4" />
@ -338,12 +346,6 @@ const WizardFileManager = () => {
</div>
)}
{loading && (
<p className="text-xs text-gray-400 text-center py-4 animate-pulse">
{translate('::App.Platform.LoadingWithThreeDot')}
</p>
)}
{viewMode === 'card' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filteredFiles.map((f) => {
@ -511,7 +513,7 @@ const WizardFileManager = () => {
</table>
</div>
)}
</div>
</Loading>
{importFile && (
<WizardImportDialog

View file

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import classNames from 'classnames'
import { DiffEditor } from '@monaco-editor/react'
import { Button, Dialog, Progress } from '@/components/ui'
@ -23,10 +24,12 @@ import {
import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store'
import Input from '@/components/ui/Input'
import { useDialogContext } from '@/components/ui/Dialog/Dialog'
import {
WizardImportAnalysisDto,
WizardImportEntryDto,
WizardImportEntryStatusEnum,
WizardImportSeedApplyResultDto,
} from '@/proxy/admin/wizard/models'
interface WizardImportDialogProps {
@ -69,6 +72,29 @@ const formatSize = (size: number) => {
return `${(size / 1024).toFixed(1)} KB`
}
/**
* Dialog gövdesi. Normalde yüksekliği viewport'a göre sınırlanır; büyütülmüş (maximize) modda
* dialog zaten `100vh` olduğu için gövde esner ve footer en altta, görünür kalır sabit yükseklik
* bırakılırsa footer ekranın dışına taşar. `useDialogContext` yalnızca `Dialog` ağacının içinde
* çalıştığından bu ayrı bir bileşendir.
*/
const ImportDialogBody = ({ children }: { children: ReactNode }) => {
const { isMaximized } = useDialogContext()
return (
<Dialog.Body
className={classNames(
'mt-5 flex min-h-0 flex-col gap-3 !overflow-hidden',
isMaximized
? 'flex-1'
: 'h-[calc(100vh-14rem)] max-h-[calc(90vh-7rem)] min-h-[360px] !flex-none',
)}
>
{children}
</Dialog.Body>
)
}
/**
* Wizard export zip'ini kapsam klasorune aktarir.
* Akis: analiz catisma cozumu (diff) toplu yazma. Yazma sirasinda bir hata olursa
@ -84,6 +110,8 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
const [decisions, setDecisions] = useState<Record<string, EntryDecision>>({})
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [applied, setApplied] = useState<AppliedRow[]>([])
// Yazilan data/*.json dosyalarinin veritabanina uygulanma sonucu; oturum kapanisinda doner.
const [seedApplied, setSeedApplied] = useState<WizardImportSeedApplyResultDto[]>([])
const [currentPath, setCurrentPath] = useState<string | null>(null)
const [showIdentical, setShowIdentical] = useState(false)
const [editorEpoch, setEditorEpoch] = useState(0)
@ -228,6 +256,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
setPhase('running')
setApplied([])
setSeedApplied([])
setError(null)
const rows: AppliedRow[] = []
@ -267,9 +296,11 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
setCurrentPath(null)
try {
await completeWizardImport(analysis.importId)
} catch {
// Oturum klasoru zaten gecici; temizlenemezse import basarili sayilir.
const res = await completeWizardImport(analysis.importId)
setSeedApplied(res.data.appliedData ?? [])
} catch (err: any) {
// Dosyalar hedefe yazildi; yalnizca veritabanina uygulama ya da temizlik basarisiz oldu.
setError(err?.response?.data?.error?.message ?? err?.message ?? null)
}
setPhase('finished')
onImported()
@ -565,7 +596,37 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
<FaCheckCircle />
{translate('::App.ListForm.WizardImportSuccess')}
</div>
{seedApplied.length > 0 && (
<div className="mt-2">
<div className="font-medium">
{translate('::App.ListForm.WizardImportDataApplied')}
</div>
<ul className="mt-0.5 space-y-0.5">
{seedApplied.map((row) => (
<li key={row.path} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono">{row.path}</span>
<span className="shrink-0">
{row.error ??
translate('::App.ListForm.WizardImportDataRowSummary', {
inserted: row.insertedRowCount,
updated: row.updatedRowCount,
skipped: row.skippedRowCount,
})}
</span>
</li>
))}
</ul>
</div>
)}
<div className="mt-1">{translate('::App.ListForm.WizardImportRunMigrateHint')}</div>
{error && (
<div className="mt-2 flex items-start gap-1 text-amber-700 dark:text-amber-300">
<FaExclamationTriangle className="mt-0.5 shrink-0" />
<span className="break-all">
{translate('::App.ListForm.WizardImportDataApplyFailed')} {error}
</span>
</div>
)}
</div>
)}
</div>
@ -581,7 +642,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
onClose={handleClose}
onRequestClose={handleClose}
>
<Dialog.Body className="mt-5 flex h-[calc(100vh-14rem)] max-h-[calc(90vh-7rem)] min-h-[360px] !flex-none flex-col gap-3 !overflow-hidden">
<ImportDialogBody>
<div className="flex flex-wrap items-center gap-2 border-b border-gray-200 pb-3 dark:border-gray-700">
<FaFileImport className="text-sky-500" />
<h5 className="mr-1">{translate('::App.ListForm.WizardImportTitle')}</h5>
@ -622,7 +683,7 @@ const WizardImportDialog = ({ file, onClose, onImported }: WizardImportDialogPro
{(phase === 'running' || phase === 'finished' || phase === 'rolledBack') &&
renderProgress()}
</Dialog.Body>
</ImportDialogBody>
<Dialog.Footer className="mt-1 flex items-center justify-end gap-2 border-t border-gray-200 pt-3 dark:border-gray-700">
{phase === 'review' && !error && unresolvedCount > 0 && (

View file

@ -1,297 +0,0 @@
import React, { useState, useEffect } from 'react'
import { useComponents } from '../../contexts/ComponentContext'
import { FaRegSave, FaSync, FaCog } from 'react-icons/fa'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Formik, Form, Field } from 'formik'
import * as Yup from 'yup'
import { Button, Checkbox, FormContainer, FormItem, Input } from '@/components/ui'
import { usePermission } from '@/utils/hooks/usePermission'
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
import Dialog from '@/components/ui/Dialog'
import { generateDesignerCode } from '@/components/visualDesigner/codeGenerator'
import {
createDesignerId,
createEmptyDesignerDocument,
type DesignerDocument,
} from '@/components/visualDesigner/types'
// Validation schema
const validationSchema = Yup.object({
name: Yup.string().required(),
routePath: Yup.string().required().matches(/^\//, 'Route path must start with /'),
description: Yup.string(),
dependencies: Yup.array().of(Yup.string()),
isActive: Yup.boolean(),
})
interface ComponentEditorProps {
componentId?: string | null
onClose?: () => void
onSaved?: () => void | Promise<void>
}
const ComponentEditor: React.FC<ComponentEditorProps> = ({
componentId,
onClose,
onSaved,
}) => {
const id = componentId || undefined
const { translate } = useLocalization()
const { getComponent, addComponent, updateComponent, refreshComponent } = useComponents()
const [isLoaded, setIsLoaded] = useState(false)
const isEditing = !!id
// Kaydetme hakki moda gore degisir: mevcut kayit guncelleme, yeni kayit olusturma yetkisi ister.
const { checkPermission } = usePermission()
const canSave = checkPermission(
isEditing ? COMPONENT_PERMISSION.UPDATE : COMPONENT_PERMISSION.CREATE,
)
// Initial values for Formik
const [initialValues, setInitialValues] = useState({
name: '',
routePath: '',
description: '',
dependencies: [] as string[],
isActive: true,
})
// Load existing component data - sadece edit modunda
useEffect(() => {
if (isEditing && id && !isLoaded) {
let cancelled = false
void refreshComponent(id).then((freshComponent) => {
if (cancelled) return
const component = freshComponent
if (!component) {
setIsLoaded(true)
return
}
// Parse dependencies from JSON string
let deps: string[] = []
try {
deps = component.dependencies ? JSON.parse(component.dependencies) : []
deps = Array.isArray(deps) ? deps : []
} catch {
deps = []
}
const values = {
name: component.name,
routePath: component.routePath,
description: component.description || '',
dependencies: deps,
isActive: component.isActive,
}
setInitialValues(values)
setIsLoaded(true)
})
return () => {
cancelled = true
}
} else if (!isEditing && !isLoaded) {
// Yeni komponent için boş başla - TEMPLATE YOK
setIsLoaded(true)
}
}, [id, isEditing, isLoaded, refreshComponent])
const createDefaultVisualDocument = (componentName: string): DesignerDocument => {
const document = createEmptyDesignerDocument('visual')
document.nodes = [
{
id: createDesignerId(),
type: 'span',
kind: 'html',
props: { children: componentName.trim() },
events: {},
bindings: {},
children: [],
},
]
return document
}
const handleSubmit = async (values: typeof initialValues, { setSubmitting }: any) => {
if (!values.name.trim()) {
alert('Please enter a component name')
setSubmitting(false)
return
}
try {
const existingComponent = isEditing && id ? getComponent(id) : undefined
const defaultDocument = createDefaultVisualDocument(values.name)
const componentData = {
name: values.name.trim(),
routePath: values.routePath.trim(),
description: values.description.trim(),
dependencies: JSON.stringify(values.dependencies), // Serialize dependencies to JSON string
code: existingComponent
? existingComponent.code
: generateDesignerCode(values.name, defaultDocument),
props: existingComponent
? existingComponent.props
: JSON.stringify({ visualDesigner: defaultDocument }),
isActive: values.isActive,
}
if (isEditing && id) {
await updateComponent(id, componentData)
} else {
await addComponent(componentData)
}
await onSaved?.()
onClose?.()
} catch (error) {
console.error('Error saving component:', error)
alert('Failed to save component. Please try again.')
} finally {
setSubmitting(false)
}
}
// Loading state - komponent yüklenene kadar bekle
if (isEditing && !isLoaded) {
return (
<div className="flex min-h-72 items-center justify-center">
<div className="text-center">
<FaSync className="w-8 h-8 text-blue-500 animate-spin mx-auto mb-3" />
<p className="text-slate-600">{translate('::App.Platform.LoadingWithThreeDot')}</p>
</div>
</div>
)
}
return (
<Formik
enableReinitialize
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={handleSubmit}
>
{({ values, touched, errors, isSubmitting, setFieldValue, submitForm, isValid }) => {
return (
<>
<Form className="pb-3 pt-8">
<div className="space-y-3">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm border border-slate-200 dark:border-gray-700 p-3">
<div className="flex items-center gap-2 mb-3">
<div className="bg-blue-100 dark:bg-blue-900/20 p-1.5 rounded-lg">
<FaCog className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<h2 className="text-base font-semibold text-slate-900 dark:text-gray-100">
Component Settings
</h2>
</div>
<FormContainer size="sm">
<FormItem
label={translate('::App.DeveloperKitComponentEditor.ComponentName')}
invalid={!!(errors.name && touched.name)}
errorMessage={errors.name as string}
>
<Field
autoFocus
name="name"
type="text"
component={Input}
placeholder="e.g., Button, Card, Modal"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setFieldValue('name', e.target.value)
}}
/>
</FormItem>
<FormItem
label="Route Path"
invalid={!!(errors.routePath && touched.routePath)}
errorMessage={errors.routePath as string}
>
<Field
name="routePath"
type="text"
component={Input}
placeholder="e.g., /roles or /admin/reports"
/>
</FormItem>
<FormItem
label={translate('::App.Listform.ListformField.Description')}
invalid={!!(errors.description && touched.description)}
errorMessage={errors.description as string}
>
<Field
name="description"
type="text"
component={Input}
placeholder="Brief description of the component"
textArea={true}
/>
</FormItem>
<FormItem
label={translate('::App.DeveloperKitComponentEditor.Dependencies')}
invalid={!!(errors.dependencies && touched.dependencies)}
errorMessage={errors.dependencies as string}
>
<Field name="dependencies">
{() => (
<Input
type="text"
value={(values.dependencies || []).join(', ')}
placeholder="MyComponent, AnotherComponent, etc."
onChange={(e) =>
setFieldValue(
'dependencies',
e.target.value
.split(',')
.map((s) => s.trim())
.filter(Boolean),
)
}
/>
)}
</Field>
</FormItem>
<FormItem label={translate('::App.Listform.ListformField.IsActive')}>
<Field name="isActive" component={Checkbox} />
</FormItem>
</FormContainer>
</div>
</div>
</Form>
<Dialog.Footer className="flex flex-col items-stretch gap-2 border-t border-slate-200 pt-3 dark:border-slate-700">
<div className="flex items-center justify-end gap-2">
<Button type="button" size="sm" disabled={isSubmitting} onClick={onClose}>
{translate('::App.Platform.Cancel')}
</Button>
<Button
type="button"
variant="solid"
size="sm"
disabled={!canSave || isSubmitting || !values.name.trim() || !isValid}
className="flex items-center gap-2"
onClick={submitForm}
>
<FaRegSave className="h-4 w-4" />
{isSubmitting ? translate('::App.Platform.Saving') : translate('::App.Platform.Save')}
</Button>
</div>
</Dialog.Footer>
</>
)
}}
</Formik>
)
}
export default ComponentEditor

View file

@ -1,12 +0,0 @@
import DeveloperLayout from '@/components/layouts/DeveloperLayout'
import ComponentManager from '@/views/developerKit/ComponentManager'
// Existing dynamic-route records can still reference this physical module.
// Keep the compatibility entry point, but manage create/view operations in the manager modal.
const ComponentEditorPage = () => (
<DeveloperLayout>
<ComponentManager />
</DeveloperLayout>
)
export default ComponentEditorPage

View file

@ -1,505 +0,0 @@
import React, { useEffect, useState } from 'react'
import { useComponents } from '../../contexts/ComponentContext'
import {
FaPlus,
FaSearch,
FaRegEdit,
FaTrashAlt,
FaEye,
FaEyeSlash,
FaFilter,
FaCalendarAlt,
FaExternalLinkAlt,
FaCog,
FaSyncAlt,
FaTh,
FaList,
} from 'react-icons/fa'
import Widget from '@/components/common/Widget'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { Loading } from '../../components/shared'
import PageTitle from '@/components/shared/PageTitle'
import { Button } from '@/components/ui'
import Dialog from '@/components/ui/Dialog'
import ComponentEditor from './ComponentEditor'
import { parseComponentDependencies } from '@/contexts/componentRuntime'
import { usePermission } from '@/utils/hooks/usePermission'
import { COMPONENT_PERMISSION } from '@/constants/permission.constant'
import Input from '@/components/ui/Input'
import Select from '@/components/ui/Select'
import type { CustomComponent } from '@/proxy/developerKit/models'
import { useStoreActions, useStoreState } from '@/store/store'
import type { ListViewLayoutType } from '../admin/listForm/edit/types'
import { ACTION_BUTTON_CLASS, ACTION_BUTTON_HOVER } from '@/constants/actionButton.constant'
/** Layout tercihi diger listelerle ayni yerde (admin.lists.states) saklanir. */
const VIEW_STATE_CODE = 'developerkit-components'
const ComponentManager: React.FC = () => {
const {
components,
loading,
componentErrors,
updateComponent,
deleteComponent,
refreshComponents,
} = useComponents()
const [searchTerm, setSearchTerm] = useState('')
const [filterActive, setFilterActive] = useState<'all' | 'active' | 'inactive'>('all')
const [isRefreshing, setIsRefreshing] = useState(false)
const [editorComponentId, setEditorComponentId] = useState<string | null | undefined>(undefined)
const { states } = useStoreState((state) => state.admin.lists)
const { setStates } = useStoreActions((a) => a.admin.lists)
const viewMode: ListViewLayoutType =
states.find((s) => s.listFormCode === VIEW_STATE_CODE)?.layout === 'grid' ? 'grid' : 'card'
const setViewMode = (layout: ListViewLayoutType) =>
setStates({ listFormCode: VIEW_STATE_CODE, layout })
// Calculate statistics
const totalComponents = components?.length || 0
const activeComponents = components?.filter((c) => c.isActive).length || 0
const inactiveComponents = totalComponents - activeComponents
const { translate } = useLocalization()
const filterActiveOptions = [
{ value: 'all' as const, label: translate('::App.ComponentFilter.All') },
{ value: 'active' as const, label: translate('::App.EntityFilter.Active') },
{ value: 'inactive' as const, label: translate('::App.EntityFilter.Inactive') },
]
const { checkPermission } = usePermission()
const canCreate = checkPermission(COMPONENT_PERMISSION.CREATE)
const canUpdate = checkPermission(COMPONENT_PERMISSION.UPDATE)
const canDelete = checkPermission(COMPONENT_PERMISSION.DELETE)
useEffect(() => {
void refreshComponents()
}, [refreshComponents])
const handleRefresh = async () => {
setIsRefreshing(true)
try {
await refreshComponents()
} finally {
setIsRefreshing(false)
}
}
const filteredComponents = components?.filter((component) => {
const matchesSearch =
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(component.description || '').toLowerCase().includes(searchTerm.toLowerCase())
const matchesFilter =
filterActive === 'all' ||
(filterActive === 'active' && component.isActive) ||
(filterActive === 'inactive' && !component.isActive)
return matchesSearch && matchesFilter
})
const handleToggleActive = async (id: string, isActive: boolean) => {
try {
const component = components?.find((c) => c.id === id)
if (component) {
await updateComponent(id, { ...component, isActive })
}
} catch (err) {
console.error('Failed to toggle component status:', err)
}
}
const handleDelete = async (id: string) => {
if (window.confirm(translate('::App.DeveloperKitComponent.ConfirmDelete'))) {
try {
await deleteComponent(id)
} catch (err) {
console.error('Failed to delete component:', err)
}
}
}
const renderStatusToggle = (component: CustomComponent) => (
<Button
type="button"
variant="plain"
shape="circle"
disabled={!canUpdate}
className="!inline-flex !h-auto items-center gap-1 rounded !px-2 py-1 text-xs font-medium transition-colors"
onClick={() => handleToggleActive(component.id, !component.isActive)}
>
{component.isActive ? (
<>
<FaEye className="w-4 h-4" />
{translate('::App.Listform.ListformField.IsActive')}
</>
) : (
<>
<FaEyeSlash className="w-4 h-4" />
{translate('::App.Platform.Passive')}
</>
)}
</Button>
)
const renderActions = (component: CustomComponent) => (
<div className="flex items-center gap-1">
{canUpdate && (
<Button
type="button"
variant="plain"
shape="none"
title={translate('::App.Platform.Edit')}
icon={<FaRegEdit className="h-4 w-4" />}
className={`${ACTION_BUTTON_CLASS} ${ACTION_BUTTON_HOVER.blue}`}
onClick={() =>
window.open(
ROUTES_ENUM.protected.saas.developerKit.componentsEdit.replace(':id', component.id),
'_blank',
)
}
/>
)}
<Button
type="button"
variant="plain"
shape="none"
disabled={!component.isActive || !component.routePath?.trim()}
title={translate('::App.Platform.OpenUrl')}
icon={<FaExternalLinkAlt className="h-4 w-4" />}
className={`${ACTION_BUTTON_CLASS} ${ACTION_BUTTON_HOVER.slate}`}
onClick={() => {
const routePath = component.routePath.startsWith('/')
? component.routePath
: `/${component.routePath}`
window.open(routePath, '_blank')
}}
/>
<Button
type="button"
variant="plain"
shape="none"
title={translate('::App.Platform.View')}
icon={<FaCog className="h-4 w-4" />}
className={`${ACTION_BUTTON_CLASS} ${ACTION_BUTTON_HOVER.green}`}
onClick={() => setEditorComponentId(component.id)}
/>
{canDelete && (
<Button
type="button"
variant="plain"
shape="none"
title={translate('::App.Platform.Delete')}
icon={<FaTrashAlt className="h-4 w-4" />}
className={`${ACTION_BUTTON_CLASS} ${ACTION_BUTTON_HOVER.red}`}
onClick={() => handleDelete(component.id)}
/>
)}
</div>
)
return (
<div className="space-y-4">
<PageTitle title={translate('::' + 'App.DeveloperKit.Components')} />
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-2 mb-4">
<Widget
title={translate('::App.DeveloperKitComponent.Total')}
value={totalComponents}
color="purple"
icon="FaPuzzlePiece"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitComponent.TotalDescription')}
/>
<Widget
title={translate('::App.DeveloperKitComponent.Active')}
value={activeComponents}
color="green"
icon="FaCheckCircle"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitComponent.ActiveDescription')}
/>
<Widget
title={translate('::App.DeveloperKitComponent.Inactive')}
value={inactiveComponents}
color="gray"
icon="FaTimesCircle"
valueClassName="text-3xl"
subTitle={translate('::App.DeveloperKitComponent.InactiveDescription')}
/>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<Input
unstyle
type="text"
placeholder={translate('::App.DeveloperKitComponent.SearchPlaceholder')}
value={searchTerm}
className="w-full pl-10 pr-2 py-1 border border-slate-300 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex items-center gap-2">
<FaFilter className="w-5 h-5 text-slate-500 dark:text-gray-400" />
<Select
size="sm"
className="min-w-[9rem]"
options={filterActiveOptions}
value={filterActiveOptions.filter((o) => o.value === filterActive)}
onChange={(option) => setFilterActive(option?.value ?? 'all')}
/>
</div>
<Button
className="flex items-center gap-2"
disabled={loading || isRefreshing}
type="button"
variant="default"
onClick={() => void handleRefresh()}
>
<FaSyncAlt className={isRefreshing ? 'animate-spin' : ''} />
{translate('::App.Platform.Refresh')}
</Button>
{canCreate && (
<div>
<Button
type="button"
variant="solid"
className="flex items-center gap-2"
onClick={() => setEditorComponentId(null)}
>
<FaPlus className="w-4 h-4" />
{translate('::App.DeveloperKitComponent.New')}
</Button>
</div>
)}
<div className="flex items-center gap-1">
<Button
type="button"
icon={<FaTh />}
variant={viewMode === 'card' ? 'solid' : 'default'}
title={translate('::App.Platform.CardView')}
onClick={() => setViewMode('card')}
/>
<Button
type="button"
icon={<FaList />}
variant={viewMode === 'grid' ? 'solid' : 'default'}
title={translate('::App.Platform.ListView')}
onClick={() => setViewMode('grid')}
/>
</div>
</div>
{/* Components List */}
{loading ? (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<div className="text-center">
<Loading loading={loading} />
</div>
</div>
) : filteredComponents?.length > 0 ? (
viewMode === 'card' ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-4 gap-4">
{filteredComponents.map((component) => (
<div
key={component.id}
className="bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow"
>
<div className="p-4">
<div className="flex items-start justify-between">
{/* Sol taraf */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3
className="text-base font-semibold text-slate-900 dark:text-gray-100 truncate"
title={component.name}
>
{component.name}
</h3>
<div
className={`w-2 h-2 shrink-0 rounded-full ${
component.isActive ? 'bg-green-500' : 'bg-slate-300 dark:bg-gray-700'
}`}
/>
</div>
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2 truncate">
{parseComponentDependencies(component.dependencies).join(', ') ||
translate('::App.DeveloperKitComponent.NoDependencies')}
</p>
<p
className="text-slate-600 dark:text-gray-300 text-sm mb-2 truncate"
title={component.routePath}
>
{component.routePath}
</p>
{component.description && (
<p className="text-slate-600 dark:text-gray-300 text-sm mb-2 line-clamp-2">
{component.description}
</p>
)}
{componentErrors[component.name] && (
<p
className="text-xs text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded px-2 py-1 mb-2 break-words"
title={componentErrors[component.name]}
>
{componentErrors[component.name]}
</p>
)}
</div>
{/* Sag taraf */}
{component.lastModificationTime && (
<div className="flex items-center gap-1 text-xs text-slate-500 dark:text-gray-400 ml-2 whitespace-nowrap">
<FaCalendarAlt className="w-3 h-3" />
<span>
{new Date(component.lastModificationTime).toLocaleDateString() ?? ''}
</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-between gap-1 pt-2 border-t border-slate-100 dark:border-gray-700">
{renderStatusToggle(component)}
{renderActions(component)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-white dark:bg-gray-900 rounded-lg border border-slate-200 dark:border-gray-700 shadow-sm overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-gray-800 text-slate-600 dark:text-gray-300">
<tr>
<th className="text-left font-medium px-4 py-2">
{translate('::App.Platform.Name')}
</th>
<th className="text-left font-medium px-4 py-2">
{translate('::App.Platform.Dependencies')}
</th>
<th className="text-left font-medium px-4 py-2">
{translate('::App.Platform.RoutePath')}
</th>
<th className="text-left font-medium px-4 py-2">
{translate('::App.Platform.Description')}
</th>
<th className="text-left font-medium px-4 py-2 whitespace-nowrap">
{translate('::App.Platform.Modified')}
</th>
<th className="text-left font-medium px-4 py-2">
{translate('::App.Platform.Status')}
</th>
<th className="text-right font-medium px-4 py-2">
{translate('::App.Platform.Actions')}
</th>
</tr>
</thead>
<tbody>
{filteredComponents.map((component) => (
<tr
key={component.id}
className="border-t border-slate-100 dark:border-gray-700 hover:bg-slate-50 dark:hover:bg-gray-800"
>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 shrink-0 rounded-full ${
component.isActive ? 'bg-green-500' : 'bg-slate-300 dark:bg-gray-700'
}`}
/>
<span className="font-medium text-slate-900 dark:text-gray-100">
{component.name}
</span>
</div>
{componentErrors[component.name] && (
<p
className="mt-1 text-xs text-red-700 dark:text-red-300 break-words"
title={componentErrors[component.name]}
>
{componentErrors[component.name]}
</p>
)}
</td>
<td className="px-4 py-2 text-slate-600 dark:text-gray-300">
{parseComponentDependencies(component.dependencies).join(', ') ||
translate('::App.DeveloperKitComponent.NoDependencies')}
</td>
<td className="px-4 py-2 text-slate-600 dark:text-gray-300">
{component.routePath}
</td>
<td className="px-4 py-2 text-slate-600 dark:text-gray-300">
{component.description}
</td>
<td className="px-4 py-2 text-slate-500 dark:text-gray-400 whitespace-nowrap">
{component.lastModificationTime
? new Date(component.lastModificationTime).toLocaleDateString()
: ''}
</td>
<td className="px-4 py-2">{renderStatusToggle(component)}</td>
<td className="px-4 py-2">
<div className="flex justify-end">{renderActions(component)}</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
<div className="text-center py-12">
<div className="mx-auto">
<div className="bg-slate-100 rounded-full w-16 h-16 flex items-center justify-center mx-auto mb-4">
<FaPlus className="w-8 h-8 text-slate-500" />
</div>
<h3 className="text-lg font-medium text-slate-900 mb-2">
{searchTerm || filterActive !== 'all'
? translate('::App.EmptyFiltered.FilteredTitle')
: translate('::App.EmptyInitial.InitialTitle')}
</h3>
<p className="text-slate-600 mb-6">
{searchTerm || filterActive !== 'all'
? translate('::App.DeveloperKit.EmptyFilteredDescription')
: translate('::App.EmptyInitial.InitialDescription')}
</p>
{canCreate && !searchTerm && filterActive === 'all' && (
<button
type="button"
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
onClick={() => setEditorComponentId(null)}
>
<FaPlus className="w-4 h-4" />
{translate('::App.ComponentEditorTitle.Create')}
</button>
)}
</div>
</div>
)}
<Dialog
isOpen={editorComponentId !== undefined}
width={720}
height="85vh"
contentClassName="overflow-y-auto"
onClose={() => setEditorComponentId(undefined)}
onRequestClose={() => setEditorComponentId(undefined)}
>
<ComponentEditor
key={editorComponentId ?? 'new'}
componentId={editorComponentId}
onClose={() => setEditorComponentId(undefined)}
onSaved={refreshComponents}
/>
</Dialog>
</div>
)
}
export default ComponentManager

View file

@ -1,13 +0,0 @@
import ComponentManager from '@/views/developerKit/ComponentManager'
import DeveloperLayout from '@/components/layouts/DeveloperLayout'
import React from 'react'
const ComponentManagerPage: React.FC = () => {
return (
<DeveloperLayout>
<ComponentManager />
</DeveloperLayout>
)
}
export default ComponentManagerPage

View file

@ -1408,7 +1408,7 @@ const VisualComponentDesigner = () => {
// The tab shows what is being edited: the human readable description when the
// component carries one, the technical name otherwise.
const pageTitle =
component?.description?.trim() || component?.name || translate('::App.DeveloperKit.Components')
component?.description?.trim() || component?.name || translate('::App.DeveloperKit.CustomComponents')
const customNames = useMemo(
() => components.filter((item) => item.id !== id && item.isActive).map((item) => item.name),
[components, id],

View file

@ -9,7 +9,7 @@ import SurveyModal from './widgets/SurveyModal'
import AnnouncementModal from './widgets/AnnouncementModal'
import EventModal from './widgets/EventModal'
import { Container } from '@/components/shared'
import { Container, Loading } from '@/components/shared'
import { usePermission } from '@/utils/hooks/usePermission'
import {
AnnouncementDto,
@ -112,9 +112,11 @@ const IntranetDashboard: React.FC = () => {
const [selectedSurvey, setSelectedSurvey] = useState<SurveyDto | null>(null)
const [showSurveyModal, setShowSurveyModal] = useState(false)
const [isDesignMode, setIsDesignMode] = useState(false)
const { dashboardLayout: selectedLayout, hiddenWidgetIds, widgetOrder } = useStoreState(
(state) => state.admin.dashboard,
)
const {
dashboardLayout: selectedLayout,
hiddenWidgetIds,
widgetOrder,
} = useStoreState((state) => state.admin.dashboard)
const {
setDashboardLayout,
setHiddenWidgetIds,
@ -123,6 +125,8 @@ const IntranetDashboard: React.FC = () => {
} = useStoreActions((actions) => actions.admin.dashboard)
const [intranetDashboard, setIntranetDashboard] = useState<IntranetDashboardDto>()
// Ilk yukleme bitene kadar pano bos gorunuyordu; widget alani yerine spinner gosterilir.
const [loading, setLoading] = useState(true)
const { translate } = useLocalization()
const currentLocale = useLocale()
@ -134,6 +138,8 @@ const IntranetDashboard: React.FC = () => {
}
} catch {
// hata apiService tarafından ele alınıyor
} finally {
setLoading(false)
}
}
@ -234,7 +240,9 @@ const IntranetDashboard: React.FC = () => {
const codes = [...new Set(widgetOrder[column] || [])].filter((code) => findWidget(code))
widgets
.filter((widget) => widgetColumns[widget.column] === column && !assignedCodes.has(widget.code))
.filter(
(widget) => widgetColumns[widget.column] === column && !assignedCodes.has(widget.code),
)
.forEach((widget) => insertByOrder(codes, widget))
return codes
@ -642,37 +650,43 @@ const IntranetDashboard: React.FC = () => {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-12">
{dashboardColumns.map((column, index) => {
const isEmptyDropTarget =
isDesignMode && dragState.targetColumn === column && columnWidgetCodes(column).length === 0
{loading ? (
<Loading loading className="min-h-[320px]" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-12">
{dashboardColumns.map((column, index) => {
const isEmptyDropTarget =
isDesignMode &&
dragState.targetColumn === column &&
columnWidgetCodes(column).length === 0
return (
<div
key={column}
className={`${columnSpanClasses[activeLayout.columns[index]]} min-h-[100px] space-y-6 rounded-xl p-1 ${
isDesignMode &&
dragState.targetColumn === column &&
dragState.targetIndex === null
? 'bg-blue-50/80 ring-2 ring-blue-300 shadow-lg dark:bg-blue-900/20 dark:ring-blue-600'
: 'bg-transparent'
} transition-all duration-700 ease-out`}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleDragLeaveColumn}
onDrop={(e) => handleColumnDrop(e, column)}
>
{renderWidgets(column)}
{isEmptyDropTarget && (
<div className="flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/50 dark:border-blue-600 dark:bg-blue-900/10">
<span className="font-medium text-blue-600 dark:text-blue-400">
{translate('::App.Dashboard.DropWidgetHere')}
</span>
</div>
)}
</div>
)
})}
</div>
return (
<div
key={column}
className={`${columnSpanClasses[activeLayout.columns[index]]} min-h-[100px] space-y-6 rounded-xl p-1 ${
isDesignMode &&
dragState.targetColumn === column &&
dragState.targetIndex === null
? 'bg-blue-50/80 ring-2 ring-blue-300 shadow-lg dark:bg-blue-900/20 dark:ring-blue-600'
: 'bg-transparent'
} transition-all duration-700 ease-out`}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleDragLeaveColumn}
onDrop={(e) => handleColumnDrop(e, column)}
>
{renderWidgets(column)}
{isEmptyDropTarget && (
<div className="flex h-40 items-center justify-center rounded-xl border-2 border-dashed border-blue-300 bg-blue-50/50 dark:border-blue-600 dark:bg-blue-900/10">
<span className="font-medium text-blue-600 dark:text-blue-400">
{translate('::App.Dashboard.DropWidgetHere')}
</span>
</div>
)}
</div>
)
})}
</div>
)}
</div>
<AnimatePresence>

View file

@ -27,7 +27,7 @@ import {
} from 'react-icons/fa'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { useListFormStateStoring } from './useListFormStateStoring'
import { useListFormGridDto } from './shared/hooks'
import { useAvailableHeight, useListFormGridDto } from './shared/hooks'
import { Avatar, Dialog, Rate } from '@/components/ui'
import { getUsers } from '@/services/identity.service'
import { getTenants } from '@/services/tenant.service'
@ -236,6 +236,19 @@ const TodoBoardContent = ({
const stateStoring = gridDto.gridOptions.stateStoringDto
const currentUserName = useStoreState((state) => state.auth.user.userName)
const gridRef = useRef<DataGridRef<any, any> | undefined>(undefined)
const boardRef = useRef<HTMLDivElement>(null)
// Pano sayfanin kalan yuksekligini kaplar, kolonlar kendi icinde kayar. Sabit yukseklik
// yalnizca ekran tanimindan gelirse kullanilir; alt form olarak acildiginda pano kendi
// icerigi kadar yer kaplar, cunku yuksekligi sahibi kapsayici belirler.
const availableHeight = useAvailableHeight(boardRef)
const boardHeight =
gridDto.gridOptions.height > 0
? gridDto.gridOptions.height
: isSubForm
? undefined
: availableHeight
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef })
const { getBandedColumns, loadLookupDisplayValues } = useListFormColumns({
gridDto,
@ -1582,17 +1595,17 @@ const TodoBoardContent = ({
}
return (
<div className="pb-3">
<div className="flex flex-col pb-3">
{renderToolbar()}
<div className="overflow-x-auto">
<div className="flex min-h-[420px] min-w-max">
<div ref={boardRef} className="min-h-0 flex-1 overflow-x-auto" style={{ height: boardHeight }}>
<div className="flex h-full min-w-max">
{statuses.map((status) => {
const items = itemsForStatus(status)
const isAdding = newTodoStatus === status
return (
<section
key={status}
className={`group w-[310px] rounded-xl border-2 p-2 transition-all ${draggedColumn !== undefined && columnDropTarget === status ? 'border-violet-400 bg-violet-50 shadow-lg dark:bg-violet-950/30' : draggedKey !== undefined && todoDropTarget?.status === status ? 'border-blue-400 bg-blue-50/70 shadow-lg dark:bg-blue-950/20' : 'border-transparent bg-gray-50 dark:bg-gray-900/60'} ${draggedColumn === status ? 'opacity-50' : ''}`}
className={`group flex h-full w-[310px] flex-col rounded-xl border-2 p-2 transition-all ${draggedColumn !== undefined && columnDropTarget === status ? 'border-violet-400 bg-violet-50 shadow-lg dark:bg-violet-950/30' : draggedKey !== undefined && todoDropTarget?.status === status ? 'border-blue-400 bg-blue-50/70 shadow-lg dark:bg-blue-950/20' : 'border-transparent bg-gray-50 dark:bg-gray-900/60'} ${draggedColumn === status ? 'opacity-50' : ''}`}
onDragOver={(event) => {
event.preventDefault()
if (dragActiveRef.current && draggedColumn) {
@ -1786,7 +1799,7 @@ const TodoBoardContent = ({
</div>
</div>
)}
<div className="mt-3 space-y-2">
<div className="mt-3 min-h-0 flex-1 space-y-2 overflow-y-auto">
{items.map((row, index) => {
const key = row[keyField]
const tags = textList(fieldValue(row, options.tagExpr))

View file

@ -77,6 +77,53 @@ export const useWidgetGroupHeight = (
return widgetGroupHeight
}
/**
* Elemanın sayfadaki konumuna göre kalan viewport yüksekliğini ölçer.
*
* Sabit bir çıkarma değeri (`calc(100vh - 170px)`) toolbar sarmalandığında ya da ekran
* kırılımı değiştiğinde yanlış sonuç verir; burada elemanın kendi üst konumu ölçüldüğü için
* yükseklik her yerleşimde doğru kalır. Eleman henüz bağlanmadıysa `undefined` döner ve
* çıran içerik yüksekliğinde kalır.
*
* @param minHeight Ölçüm sonucu bunun altına düşmez; dar ekranda liste okunamaz hâle gelmesin diye.
* @param bottomGap Alt kenarda bırakılacak boşluk.
*/
export const useAvailableHeight = (
ref: RefObject<HTMLElement | null>,
minHeight = 240,
bottomGap = 12,
) => {
const [height, setHeight] = useState<number>()
useEffect(() => {
const element = ref.current
if (!element) return
const measure = () => {
const next = Math.max(
minHeight,
Math.round(window.innerHeight - element.getBoundingClientRect().top - bottomGap),
)
// Aynı değerde render tetiklenmez; ResizeObserver'ın kendi kendini beslemesi böyle önlenir.
setHeight((previous) => (previous === next ? previous : next))
}
measure()
// Üstteki toolbar/başlık büyüyüp küçüldüğünde eleman aşağı kayar; gövde gözlenerek yakalanır.
const resizeObserver = new ResizeObserver(measure)
resizeObserver.observe(document.body)
window.addEventListener('resize', measure)
return () => {
resizeObserver.disconnect()
window.removeEventListener('resize', measure)
}
}, [ref, minHeight, bottomGap])
return height
}
/** Liste formuna tanımlı özel JS/CSS kaynaklarını sayfaya ekler. */
export const useListFormCustomSources = (gridDto?: GridDto) => {
const customJsSources = gridDto?.gridOptions.customJsSources