MultiValueStringJsonConverter

This commit is contained in:
Sedat ÖZTÜRK 2026-09-01 16:44:05 +03:00
parent c4de55d459
commit 78bc20b645
10 changed files with 150 additions and 11 deletions

View file

@ -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.

View file

@ -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

View file

@ -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**

View file

@ -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; }
/// <summary>
/// Cok degerli alan: dizi de duz metin de gelebilir (<see cref="MultiValueStringJsonConverter"/>).
/// </summary>
[JsonConverter(typeof(MultiValueStringJsonConverter))]
public string Modules { get; set; }
}

View file

@ -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; }
/// <summary>
/// Form tarafinda dxTagBox oldugu icin dizi de gelebilir; converter pipe ile
/// ayrilmis tek string'e cevirir (<see cref="MultiValueStringJsonConverter"/>).
/// </summary>
[JsonConverter(typeof(MultiValueStringJsonConverter))]
public string Modules { get; set; }
}

View file

@ -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;
/// <summary>
/// Cok degerli editorler (dxTagBox) formda deger olarak dizi tasir; SQL yolunda diziyi
/// <c>QueryHelper</c> 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.
/// </summary>
public class MultiValueStringJsonConverter : JsonConverter<string>
{
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<string>();
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);
}
}

View file

@ -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<EditingFormDto>()
@ -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<EditingFormDto>()

View file

@ -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[] {

View file

@ -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

View file

@ -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<unknown, unknown>()
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,
})