Custom Component ve Wizard Import/Export özelliği
This commit is contained in:
parent
54e3390cbf
commit
59ec8950a3
46 changed files with 1627 additions and 1770 deletions
40
.github/instructions/ai.instructions.md
vendored
40
.github/instructions/ai.instructions.md
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
2
.github/instructions/list.instructions.md
vendored
2
.github/instructions/list.instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
58
.github/instructions/lowcode.instructions.md
vendored
58
.github/instructions/lowcode.instructions.md
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
82
README.md
82
README.md
|
|
@ -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` açı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
|
||||
açı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}/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
20
claude.md
20
claude.md
|
|
@ -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` açı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;
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
159
configs/seeds/host/wizard/RoleList.json
Normal file
159
configs/seeds/host/wizard/RoleList.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?',
|
||||
|
|
|
|||
|
|
@ -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>({
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* çağı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
|
||||
|
|
|
|||
Loading…
Reference in a new issue