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,
})