From 78bc20b64590aaf5bbd2477125a0375e4f10a133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:44:05 +0300 Subject: [PATCH] MultiValueStringJsonConverter --- .github/instructions/dotnet.instructions.md | 6 ++ .../lowcode-reference.instructions.md | 12 +++- .github/instructions/lowcode.instructions.md | 24 +++++++- .../DynamicApi/CreateTenantFromOrderInput.cs | 5 ++ .../DynamicApi/CreateUpdateTenantInput.cs | 6 ++ .../MultiValueStringJsonConverter.cs | 56 +++++++++++++++++++ .../ListFormSeeder_Administration.cs | 4 +- .../Migrations/ListFormSeeder_Saas.cs | 2 +- claude.md | 5 ++ .../views/list/useListFormCustomDataSource.ts | 41 ++++++++++++-- 10 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/MultiValueStringJsonConverter.cs diff --git a/.github/instructions/dotnet.instructions.md b/.github/instructions/dotnet.instructions.md index ce9f8c21..5f778896 100644 --- a/.github/instructions/dotnet.instructions.md +++ b/.github/instructions/dotnet.instructions.md @@ -118,6 +118,12 @@ Ek kurallar: uyarı bırakılmaz. - **Localization**: kullanıcıya görünen metin koda gömülmez, `PlatformResource` anahtarı üzerinden verilir. +- **Çok değerli alan taşıyan girdi DTO'su**: ekranda `dxTagBox` (ya da çoklu seçimli + `dxGridBox`) ile doldurulan bir alan tipli bir DTO'ya bağlanıyorsa property `string` + kalır ve `[JsonConverter(typeof(MultiValueStringJsonConverter))]` ile işaretlenir — + UI diziyi olduğu gibi yollar, converter `PlatformConsts.MultiValueDelimiter` ile tek + string üretir. Varsayılan `list-form-data/*` ucundan geçen ekranlarda bu iş + `QueryHelper` tarafından yapılır, DTO yoktur; okuma DTO'larına öznitelik eklenmez. - **Sabitler**: bağlantı dizesi, tenant id, anahtar/secret koda yazılmaz; konfigürasyon veya `SettingDefinition` üzerinden okunur. diff --git a/.github/instructions/lowcode-reference.instructions.md b/.github/instructions/lowcode-reference.instructions.md index 90e11053..c1c88d5b 100644 --- a/.github/instructions/lowcode-reference.instructions.md +++ b/.github/instructions/lowcode-reference.instructions.md @@ -309,6 +309,16 @@ komuta parametre olarak katılır (`lowcode` §4.3.5). **String birleştirme ile Service'e devretmek istiyorsan bu alanı o ucun adresiyle değiştirirsin — ekranın geri kalanı (form, doğrulama, yetki) aynı kalır. +**Çok değerli alanlar bu iki yolda farklı davranır.** Varsayılan uçta payload bir sözlüktür +(`data: {...}`) ve `dxTagBox` dizisini `QueryHelper.GetFormattedValue` `|` ile birleştirir. +Tipli bir DTO alan uçta (`list-form-dynamic-api/...` gibi) böyle bir adım yoktur: dizi `string` +property'ye bağlanamaz ve istek `400` ile düşer. Çözüm, DTO alanına +`[JsonConverter(typeof(MultiValueStringJsonConverter))]` eklemektir — `Modules` alanında +uygulanmış örneği `CreateUpdateTenantInput` / `CreateTenantFromOrderInput` taşır. Yalnızca +**girdi** DTO'larına eklenir; okuma DTO'larında (`SettingDefinitionDto.Providers`, +`PlatformPermissionGrantInfoDto.Modules`) karşılığı yoktur çünkü o ekranlar varsayılan SQL +yolundan kaydeder. + --- ## 5. Ekran seviyesindeki diğer bloklar @@ -755,7 +765,7 @@ sessizce yok sayılır: | DTO | Hangi `EditorType2` | Anahtarlar (varsayılan) | | --- | --- | --- | | `GridBoxOptionsDto` | `dxGridBox` | `Columns[]` (ilk kolon `Key` ile aynı adda olmalı), `SelectionMode` (`"single"`), `FilterRowVisible` (`false`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`), `Height` (`250`), `Width` (`500`) | -| `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) | +| `TagBoxOptionsDto` | `dxTagBox` | `ShowSelectionControls` (`true`), `MaxDisplayedTags` (`3`), `ShowMultiTagOnly` (`false`), `ApplyValueMode` (`"useButtons"`), `SearchEnabled` (`true`), `AcceptCustomValue` (`false`), `ShowClearButton` (`true`) — değer tek kolonda `\|` ile saklanır, bkz. §4.3 | | `ImageUploadOptionsDto` | `dxImageUpload` · `dxImageViewer` | `UploadUrl`, `Accept`, `Multiple`, `MaxFileSize` (bayt), `Width`, `Height` — hepsi nullable, varsayılanı yok | Çözüm **yalnızca alanın `EditorType2` değeri eşleşiyorsa** yapılır: `dxTextBox` bir alana diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index fca93aba..540128de 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -738,7 +738,7 @@ anahtarları listeler; ortak anahtarlar (§4.3.3) her editörde geçerlidir. | `dxLookup` | Tek seçim (çok kayıt, mobil dostu) | FK tipi | `dropDownOptions.*`, `searchEnabled`, `applyValueMode` + **lookup zorunlu** | | `dxDropDownBox` | Özel içerikli açılır kutu | FK tipi | `dropDownOptions.width/height/hideOnOutsideClick`, `deferRendering`, `openOnFieldClick` | | `dxGridBox` | Çok kolonlu seçim ızgarası | FK tipi | `columns`\*, `filterRowVisible`\*, `selectionMode`\*, `acceptCustomValue`\* + **lookup zorunlu** | -| `dxTagBox` | Çok değerli seçim | String (virgüllü) / ilişki tablosu | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `searchEnabled`\*, `acceptCustomValue`\*, `hideSelectedItems`, `multiline` + **lookup zorunlu** | +| `dxTagBox` | Çok değerli seçim | String (`\|` ile ayrılmış — §4.3.2.1) | `showSelectionControls`\*, `maxDisplayedTags`\*, `showMultiTagOnly`\*, `applyValueMode`\*, `searchEnabled`\*, `acceptCustomValue`\*, `hideSelectedItems`, `multiline` + **lookup zorunlu** | | `dxAutocomplete` | Serbest metin + öneri | String (16) | `minSearchLength`, `searchExpr`, `searchTimeout` | | `dxColorBox` | Renk | String (16) | `editAlphaChannel`, `keyStep` | | `dxSlider` | Tek değerli kaydırıcı | Sayı | `min`, `max`, `tooltip.showMode` | @@ -768,6 +768,28 @@ sayı, `multiple` bir boolean olmalıdır. `3` (WebService) ise noktalı virgülle ayrılmış bir çağrı tarifidir. Tam sözleşme ve cascade (ebeveyn–çocuk) kurulumu için §4.3.4. +### 4.3.2.1 Çok değerli alanların depolama sözleşmesi + +`dxTagBox` (ve `selectionMode: "multiple"` verilmiş `dxGridBox`) tek bir **metin kolonunda**, +değerleri `|` ile ayırarak saklar (`PlatformConsts.MultiValueDelimiter`). İlişki tablosu +açmaya gerek yoktur; kolon `NVARCHAR(n)` olarak tanımlanır ve `DbSourceType: 16` verilir. + +Zincirin iki yönü **farklı yerlerde** kapanır — yeni ekran üretirken üçünü birden kontrol et: + +| Yön | Nerede olur | Not | +| --- | --- | --- | +| Okuma (`"A\|B"` → `["A","B"]`) | UI, `useListFormCustomDataSource` | Alanın `EditorType2` değeri `dxTagBox` ise kolon `extras.multiValue` alır; ayrıştırma buna bakar | +| Yazma, **SQL yolu** (`["A","B"]` → `"A\|B"`) | Backend, `QueryHelper.GetFormattedValue` | Yalnızca alanın `DbSourceType`'ı String (16) ise birleştirir — sayısal tip verirsen dizi sessizce bozulur | +| Yazma, **tipli DTO yolu** | Yok — elle kurulur | Aşağıya bak | + +**Tuzak:** ekranın `*ServiceAddress` alanı varsayılan `list-form-data/*` yerine tipli bir DTO +alan bir uca (`list-form-dynamic-api/...`, Custom Endpoint, Dynamic Service) bakıyorsa +`QueryHelper` devreye girmez; gelen dizi `string` property'ye bağlanamaz ve istek +`400 Bad Request` + `"The JSON value could not be converted to System.String"` ile düşer. +Bu durumda DTO'daki alana `[JsonConverter(typeof(MultiValueStringJsonConverter))]` eklenir +(`Application.Contracts/ListForms/DynamicApi/`); converter hem diziyi hem düz metni kabul eder +ve `|` ile birleştirilmiş tek string üretir. Salt okuma DTO'larına eklenmez. + ### 4.3.3 Her editörde geçerli ortak seçenekler Aşağıda en sık kullanılanlar özetlenir; **tam sözlük ve grup grup liste §6.3'tedir** — diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateTenantFromOrderInput.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateTenantFromOrderInput.cs index 600b47be..1f95f8e6 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateTenantFromOrderInput.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateTenantFromOrderInput.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; namespace Sozsoft.Platform.ListForms.DynamicApi; @@ -6,5 +7,9 @@ public class CreateTenantFromOrderInput { public Guid OrderId { get; set; } public string Name { get; set; } + /// + /// Cok degerli alan: dizi de duz metin de gelebilir (). + /// + [JsonConverter(typeof(MultiValueStringJsonConverter))] public string Modules { get; set; } } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs index 20ffaea5..5e9e6c1e 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/CreateUpdateTenantInput.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json.Serialization; namespace Sozsoft.Platform.ListForms.DynamicApi; @@ -27,6 +28,11 @@ public class CreateUpdateTenantInput public string FaxNumber { get; set; } public string Email { get; set; } public string Website { get; set; } + /// + /// Form tarafinda dxTagBox oldugu icin dizi de gelebilir; converter pipe ile + /// ayrilmis tek string'e cevirir (). + /// + [JsonConverter(typeof(MultiValueStringJsonConverter))] public string Modules { get; set; } } diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/MultiValueStringJsonConverter.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/MultiValueStringJsonConverter.cs new file mode 100644 index 00000000..e65fc8f6 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/DynamicApi/MultiValueStringJsonConverter.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Sozsoft.Platform.ListForms.DynamicApi; + +/// +/// Cok degerli editorler (dxTagBox) formda deger olarak dizi tasir; SQL yolunda diziyi +/// QueryHelper pipe ile birlestirir, tipli DTO tasiyan dynamic API uclarinda ise +/// boyle bir adim yoktur. Bu donusturucu ayni sozlesmeyi DTO tarafinda kurar: gelen deger +/// dizi de olsa duz metin de olsa pipe ile ayrilmis tek string uretir. +/// +public class MultiValueStringJsonConverter : JsonConverter +{ + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + return reader.GetString(); + } + + var values = new List(); + + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var value = reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture), + JsonTokenType.True => bool.TrueString, + JsonTokenType.False => bool.FalseString, + JsonTokenType.Null => null, + _ => throw new JsonException($"Unsupported token '{reader.TokenType}' in multi value field.") + }; + + if (!string.IsNullOrWhiteSpace(value)) + { + values.Add(value.Trim()); + } + } + + return values.Count == 0 ? null : string.Join(PlatformConsts.MultiValueDelimiter, values); + } + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + writer.WriteStringValue(value); + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs index 98e0bf20..9581b458 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Administration.cs @@ -79,7 +79,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep PermissionJson = DefaultPermissionJson(AbpIdentity.PermissionGroups.Create, AbpIdentity.PermissionGroups.Default, AbpIdentity.PermissionGroups.Update, AbpIdentity.PermissionGroups.Delete, AbpIdentity.PermissionGroups.Export, AbpIdentity.PermissionGroups.Import, AbpIdentity.PermissionGroups.Note), PagerOptionJson = DefaultPagerOptionJson, InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { - new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@ID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } + new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@NEWID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } }), EditingOptionJson = DefaultEditingOptionJson(listFormName, 600, 350, true, true, true, true, false), EditingFormJson = JsonSerializer.Serialize(new List() @@ -215,7 +215,7 @@ public class ListFormSeeder_Administration : IDataSeedContributor, ITransientDep PagerOptionJson = DefaultPagerOptionJson, TreeOptionJson = DefaultTreeOptionJson("Name", "ParentName", true), InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { - new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@ID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } + new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@NEWID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } }), EditingOptionJson = DefaultEditingOptionJson(listFormName, 600, 600, true, true, true, true, false), EditingFormJson = JsonSerializer.Serialize(new List() diff --git a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs index 0350db2f..3917b90a 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Migrations/ListFormSeeder_Saas.cs @@ -1155,7 +1155,7 @@ public class ListFormSeeder_Saas : IDataSeedContributor, ITransientDependency PermissionJson = DefaultPermissionJson(listFormName), PagerOptionJson = DefaultPagerOptionJson, InsertFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { - new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@ID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } + new() { FieldName = "Id", FieldDbType = DbType.Guid, Value = "@NEWID", CustomValueType = FieldCustomValueTypeEnum.CustomKey } }), DeleteCommand = $"DELETE FROM \"{TableNameResolver.GetFullTableName(nameof(TableNameEnum.GlobalSearch))}\" WHERE \"Id\"=@Id", DeleteFieldsDefaultValueJson = JsonSerializer.Serialize(new FieldsDefaultValue[] { diff --git a/claude.md b/claude.md index 011c6c3c..d8fc7f11 100644 --- a/claude.md +++ b/claude.md @@ -165,6 +165,11 @@ Bir çelişki varsa sıra: `ai.instructions.md` → `dotnet.instructions.md` → - **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. - **`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; + tipli DTO alan bir uca (`list-form-dynamic-api/...`, Custom Endpoint, Dynamic Service) + bağlıysa DTO alanına `[JsonConverter(typeof(MultiValueStringJsonConverter))]` eklenir — + yoksa istek `400` ile düşer. Ayrıntı: `lowcode.instructions.md` §4.3.2.1. ### Definition of done diff --git a/ui/src/views/list/useListFormCustomDataSource.ts b/ui/src/views/list/useListFormCustomDataSource.ts index d6806b04..0e79a5c4 100644 --- a/ui/src/views/list/useListFormCustomDataSource.ts +++ b/ui/src/views/list/useListFormCustomDataSource.ts @@ -83,11 +83,30 @@ const useListFormCustomDataSource = ({ layout?: ListViewLayoutType | string, cols?: GridColumnData[] | CardViewTypes.Column[], ) => { + // Tree'de satir anahtari hiyerarsiyi kuran treeOptionDto.keyExpr alanidir (orn. "Name"), + // fakat insert/update/delete uclari her zaman ListForm'daki KeyFieldName ile eslesme yapar. + // Iki alan farkliysa sunucuya satirin gercek anahtar degeri gonderilir; aksi halde + // WHERE sartina hicbir kayit dusmez ve islem sessizce bos doner. + const storeKeyFieldName = + layout === layoutTypes.tree + ? gridOptions.treeOptionDto?.keyExpr + : gridOptions.keyFieldName + const usesAlternateStoreKey = + !!gridOptions.keyFieldName && + !!storeKeyFieldName && + storeKeyFieldName !== gridOptions.keyFieldName + const primaryKeyByStoreKey = new Map() + const toPrimaryKey = (key: unknown) => { + if (!usesAlternateStoreKey) return key + + const rowData = (gridRef?.current?.instance() as any)?.getNodeByKey?.(key)?.data + const primaryKey = rowData?.[gridOptions.keyFieldName as string] + + return primaryKey ?? primaryKeyByStoreKey.get(key) ?? key + } + const store: any = new CustomStore({ - key: - layout === layoutTypes.tree - ? gridOptions.treeOptionDto?.keyExpr - : gridOptions.keyFieldName, + key: storeKeyFieldName, useDefaultSearch: true, load: async (loadOptions) => { // URL'den sort parametresini al ve loadOptions'a ekle @@ -292,6 +311,16 @@ const useListFormCustomDataSource = ({ summary: response.data.summary, groupCount: response.data.groupCount, } + + if (usesAlternateStoreKey) { + retValue.data.forEach((row: any) => { + const storeKey = row?.[storeKeyFieldName as string] + const primaryKey = row?.[gridOptions.keyFieldName as string] + if (storeKey !== undefined && storeKey !== null && primaryKey !== undefined) { + primaryKeyByStoreKey.set(storeKey, primaryKey) + } + }) + } return retValue } catch (error) { @@ -373,7 +402,7 @@ const useListFormCustomDataSource = ({ const deleteUrl = getServiceAddress(gridOptions.deleteServiceAddress) return dynamicFetch(deleteUrl, 'POST', searchParams, { - keys: [key], + keys: [toPrimaryKey(key)], listFormCode, }) }, @@ -384,7 +413,7 @@ const useListFormCustomDataSource = ({ const updateUrl = getServiceAddress(gridOptions.updateServiceAddress) return dynamicFetch(updateUrl, 'POST', searchParams, { - keys: [key], + keys: [toPrimaryKey(key)], data: values, listFormCode, })