diff --git a/.github/instructions/lowcode.instructions.md b/.github/instructions/lowcode.instructions.md index 7b9b042f..a21c111f 100644 --- a/.github/instructions/lowcode.instructions.md +++ b/.github/instructions/lowcode.instructions.md @@ -1352,8 +1352,8 @@ cascade eksik yetenek değildir ve analizde `Ready to Start`'ı engellemez. Tam | `DataSourceType` | `1` StaticData · `2` Query · `3` WebService | | `ValueExpr` / `DisplayExpr` | Varsayılan `Key` / `Name` | | `LookupQuery` | Kaynağın kendisi (tipe göre biçim değişir, aşağıda) | -| `CascadeParentFields` | **Çocuk alanda** doldurulur; değerleri sorguya taşınacak ebeveyn alan(lar), virgülle. Sırayla `@param0`, `@param1`… olur (`Query`/`WebService`); gelmeyen parametre `NULL` bağlanır | -| `CascadeRelationField` | **Çocuk alanda** doldurulur; `StaticData`'da ebeveynin değeriyle karşılaştırılan alan (öğedeki `group`) | +| `CascadeParentFields` | **Çocuk alanda** doldurulur; değerleri sorguya taşınacak ebeveyn alan(lar), virgülle. Sırayla `@param0`, `@param1`… olur (`Query`/`WebService`); boş ya da gelmeyen ebeveyn `NULL` bağlanır. Değer her zaman **metin** parametre olarak bağlanır (`ListFormSelectAppService.ToLookupParameter`), sayı anahtarlı ebeveyn de çalışır; dönüşümü kolon tipine göre veritabanı yapar. `WebService` gövdesine değer JSON kaçışlı yazılır | +| `CascadeRelationField` | **Çocuk alanda**, yalnızca `StaticData`'da anlamlıdır: öğede ilk ebeveynin değeriyle karşılaştırılan alan; boşsa `group`. Ebeveyn boşken liste süzülmez | | `CascadeFilterOperator` | `StaticData` cascade filtresinin operatörü; varsayılan `=` | | `CascadeEmptyFields` | **Ebeveyn alanda** doldurulur; bu alan değişince boşaltılacak çocuk alanlar, virgülle. Ara halka (İl) hem çocuk hem ebeveyn olduğu için iki tarafı birden taşır | @@ -1409,7 +1409,8 @@ ya da `EditorOptions`'ta `grouped: true` vardır ve öğeler `group` alanı taş `[Key]` + `[Name]` takma adlarını döndürür ve `ValueExpr: "Key"`, `DisplayExpr: "Name"` yazılır. Bağımlı liste `grouped` ile değil cascade ile kurulur. -> Lookup verisi önbelleklenir; aynı sorgu aynı filtrelerle bir kez yüklenir. +> Lookup verisi önbelleklenir; aynı sorgu aynı filtrelerle bir kez yüklenir. Grid, Tree, Card ve +> Form aynı veri kaynağını kullanır (`views/form/useLookupDataSource.ts`); cascade kuralı tek yerdedir. ### 4.3.5 Varsayılan değerler — `*FieldsDefaultValueJson` diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Select/LookupRequestDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Select/LookupRequestDto.cs index 1b935738..1d7b0aa7 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Select/LookupRequestDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Select/LookupRequestDto.cs @@ -4,6 +4,7 @@ public class LookupRequestDto { public string ListFormCode { get; set; } public string ListFormFieldName { get; set; } - public string[] Filters { get; set; } + /// Cascade ebeveyn degerleri, CascadeParentFields sirasiyla; sayi ya da metin olabilir. + public object[] Filters { get; set; } } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs index 5563436e..724a043c 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormSelectAppService.cs @@ -418,22 +418,17 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe var lookup = JsonSerializer.Deserialize(field.LookupJson); if (!string.IsNullOrWhiteSpace(lookup?.LookupQuery)) { - var parameters = new Dictionary(); - if (input.Filters != null) - { - for (int i = 0; i < input.Filters.Length; i++) - { - parameters.Add("@param" + i, input.Filters[i]); - } - } - + // Cascade ebeveyn degerleri @param0… olarak baglanir; gelmeyen ebeveyn NULL olur ki + // sorgu "(Kolon = @param0 OR @param0 IS NULL)" kalibiyla tum listeyi donsun. var cascadeParameterCount = lookup.CascadeParentFields? .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Length ?? 0; + var parameterCount = Math.Max(input.Filters?.Length ?? 0, cascadeParameterCount); - for (int i = input.Filters?.Length ?? 0; i < cascadeParameterCount; i++) + var parameters = new Dictionary(); + for (var i = 0; i < parameterCount; i++) { - parameters.Add("@param" + i, null!); + parameters.Add("@param" + i, ToLookupParameter(input.Filters, i)!); } var lookupQuery = defaultValueHelper.GetDefaultValue(lookup.LookupQuery); @@ -444,6 +439,26 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe return new List(); } + /// + /// Ebeveyn degeri istemcide sayi, metin ya da bool olabilir; parametre her zaman metin olarak + /// baglanir ki sorgudaki kolon tipine gore veritabani donustursun (int kolona "5" de gider). + /// + private static string? ToLookupParameter(object[]? filters, int index) + { + var value = filters is not null && index < filters.Length ? filters[index] : null; + + return value switch + { + null => null, + JsonElement { ValueKind: JsonValueKind.Null or JsonValueKind.Undefined } => null, + JsonElement { ValueKind: JsonValueKind.String } element => NullIfEmpty(element.GetString()), + JsonElement element => element.GetRawText(), + _ => NullIfEmpty(Convert.ToString(value, CultureInfo.InvariantCulture)) + }; + + static string? NullIfEmpty(string? text) => string.IsNullOrEmpty(text) ? null : text; + } + public async Task GetNextSequenceValueAsync(string SequenceName) { var sequenceManager = LazyServiceProvider.LazyGetRequiredService(); @@ -451,93 +466,6 @@ public class ListFormSelectAppService : PlatformAppService, IListFormSelectAppSe return await sequenceManager.GetNextSequenceAsync(SequenceName); } - private static string WrapLookupQueryWithInFilter( - string lookupQuery, - string listFormSelectCommand, - string listFormFieldName) - { - if (string.IsNullOrWhiteSpace(lookupQuery)) - throw new ArgumentException("LookupQuery boş olamaz."); - - if (string.IsNullOrWhiteSpace(listFormSelectCommand)) - throw new ArgumentException("SelectCommand boş olamaz."); - - if (string.IsNullOrWhiteSpace(listFormFieldName)) - throw new ArgumentException("ListFormFieldName boş olamaz."); - - return $@" - SELECT * - FROM ( - {lookupQuery} - ) t - WHERE ""Key"" IN ( - SELECT ""{listFormFieldName}"" - FROM ""{listFormSelectCommand}"" - GROUP BY ""{listFormFieldName}"" - )"; - } - - private static string AddCascadeWhereCondition( - string sql, - string cascadeParentFields, - int paramStartIndex = 0) - { - if (string.IsNullOrWhiteSpace(sql)) - throw new ArgumentException("SQL boş olamaz."); - - if (string.IsNullOrWhiteSpace(cascadeParentFields)) - return sql; - - var fields = cascadeParentFields - .Split(',', StringSplitOptions.RemoveEmptyEntries) - .Select(f => f.Trim()) - .ToArray(); - - if (!fields.Any()) - return sql; - - var conditions = new List(); - for (int i = 0; i < fields.Length; i++) - { - var field = fields[i]; - - // Zaten " ile başlıyorsa dokunma - if (!field.StartsWith("\"")) - field = $"\"{field}\""; - - conditions.Add($"{field} = @param{paramStartIndex + i}"); - } - - string whereClause = string.Join(" AND ", conditions); - var upperSql = sql.ToUpperInvariant(); - - if (upperSql.Contains(" WHERE ")) - { - return InsertBeforeClause(sql, "AND " + whereClause); - } - else - { - return InsertBeforeClause(sql, "WHERE " + whereClause); - } - } - - private static string InsertBeforeClause(string sql, string insertText) - { - var upperSql = sql.ToUpperInvariant(); - string[] clauses = { " GROUP BY ", " ORDER BY ", " HAVING " }; - - foreach (var clause in clauses) - { - int index = upperSql.IndexOf(clause); - if (index > -1) - { - return sql.Insert(index, " " + insertText + " "); - } - } - - return sql + " " + insertText; - } - //Workflow kriterlerini alma private async Task> GetWorkflowCriteriaAsync(string listFormCode) { diff --git a/ui/src/views/form/shared/formItems.ts b/ui/src/views/form/shared/formItems.ts index 17691326..cab3e42a 100644 --- a/ui/src/views/form/shared/formItems.ts +++ b/ui/src/views/form/shared/formItems.ts @@ -39,7 +39,7 @@ type BuildFormItemsParams = { mode: RowMode columns?: GridColumnData[] /** Lookup alanları için DevExtreme dataSource üretici. */ - getLookupDataSource: (options: any, colData: ColumnFormatDto, formData: any) => any + getLookupDataSource: (colData: ColumnFormatDto, formData: any) => any formData: any } @@ -57,7 +57,7 @@ const toFormItem = ( const lookupEditorOptions = colFormat.lookupDto?.dataSourceType ? { - dataSource: getLookupDataSource(colFormat.editorOptions, colFormat, formData), + dataSource: getLookupDataSource(colFormat, formData), valueExpr: colFormat.lookupDto?.valueExpr?.toLowerCase(), displayExpr: colFormat.lookupDto?.displayExpr?.toLowerCase(), } diff --git a/ui/src/views/form/useFormData.tsx b/ui/src/views/form/useFormData.tsx index c098096f..8f0fd7b9 100644 --- a/ui/src/views/form/useFormData.tsx +++ b/ui/src/views/form/useFormData.tsx @@ -262,7 +262,7 @@ const useGridData = (props: { ...item.editorOptions, dataSource: keepCustomDataSource ? currentDataSource - : getLookupDataSource(colData?.editorOptions, colData, formData || null), + : getLookupDataSource(colData, formData || null), valueExpr: item.editorOptions?.valueExpr ?? colData?.lookupDto?.valueExpr?.toLowerCase(), displayExpr: diff --git a/ui/src/views/form/useLookupDataSource.ts b/ui/src/views/form/useLookupDataSource.ts index 8e2fe93e..fcbf15b5 100644 --- a/ui/src/views/form/useLookupDataSource.ts +++ b/ui/src/views/form/useLookupDataSource.ts @@ -14,17 +14,48 @@ export { clearLookupCache } from '../shared/lookupCache' const isLookupAllowed = (listFormCode?: string, isSubForm?: boolean) => isSubForm || !listFormCode || window.location.pathname.includes(listFormCode) -const createLookupStaticDataSource = ( +const splitFields = (value?: string | null) => + (value ?? '') + .split(',') + .map((field) => field.trim()) + .filter(Boolean) + +const isEmptyValue = (value: unknown) => value === undefined || value === null || value === '' + +/** + * Cascade filtresi. Query/WebService için ebeveyn değerleri sırayla `@param0…` olur; boş ebeveyn + * `null` gider ve sorgu `(Kolon = @param0 OR @param0 IS NULL)` kalıbıyla tüm listeyi döner. + * StaticData için öğelerdeki `CascadeRelationField` alanı (varsayılan `group`) ilk ebeveynin + * değeriyle karşılaştırılır; ebeveyn boşsa süzülmez. + */ +export const buildCascadeFilters = (lookupDto: any, rowData: any): any[] | null => { + const parentFields = splitFields(lookupDto?.cascadeParentFields) + if (!parentFields.length) return null + + if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { + const parentValue = rowData?.[parentFields[0]] + if (isEmptyValue(parentValue)) return null + + return [ + lookupDto.cascadeRelationField || 'group', + lookupDto.cascadeFilterOperator || '=', + parentValue, + ] + } + + return parentFields.map((field) => (isEmptyValue(rowData?.[field]) ? null : rowData[field])) +} + +export const createLookupStaticDataSource = ( load: () => any, - filter: any = null, - key: any = 'static', - sort: any = 'name', + filter: any[] | null, + cacheKey: string, + sort = 'name', ) => ({ store: new CustomStore({ - key, loadMode: 'raw', load: () => - cachedLookupLoad(`static:${key}`, async () => { + cachedLookupLoad(`static:${cacheKey}`, async () => { const result = await Promise.resolve(load()) if (result === undefined || result === null) return [] return Array.isArray(result) ? result : [result] @@ -34,10 +65,10 @@ const createLookupStaticDataSource = ( filter, }) -const createLookupQueryDataSource = ( +export const createLookupQueryDataSource = ( listFormCode?: string, listFormFieldName?: string, - filters?: any[], + filters?: any[] | null, isSubForm?: boolean, ) => new CustomStore({ @@ -72,10 +103,13 @@ const createLookupQueryDataSource = ( }, }) -const createLookupApiDataSource = ( +/** Gövde JSON şablonudur; değer tırnak içine yerleştiği için JSON kaçışlı yazılır. */ +const toJsonText = (value: unknown) => (value === null ? 'null' : JSON.stringify(String(value)).slice(1, -1)) + +export const createLookupApiDataSource = ( listFormCode?: string, lookupQuery?: string, - filters?: any[], + filters?: any[] | null, keyName?: string, isSubForm?: boolean, ) => @@ -99,7 +133,7 @@ const createLookupApiDataSource = ( // Gövdedeki @param0, @param1… yer tutucularını cascade filtreleriyle doldur. let resolvedBody = bodyTemplate ?? '' filters?.forEach((filterValue, index) => { - resolvedBody = resolvedBody.replace(new RegExp(`@param${index}`, 'g'), String(filterValue)) + resolvedBody = resolvedBody.replace(new RegExp(`@param${index}\\b`, 'g'), toJsonText(filterValue)) }) try { @@ -129,6 +163,10 @@ const createLookupApiDataSource = ( }, }) +/** + * Lookup kolonunun veri kaynağı. Grid/Tree/Card (`useListFormColumns`) ve Form aynı kuralı + * kullanır; `rowData` cascade ebeveynlerinin okunduğu kayıttır. + */ export const useLookupDataSource = ({ listFormCode, isSubForm, @@ -139,24 +177,9 @@ export const useLookupDataSource = ({ const { translate, cultureName } = useLocalization() const getLookupDataSource = useCallback( - (options: any, colData: any, data: any) => { + (colData: any, rowData: any) => { const { lookupDto } = colData - const filters: any[] = [] - - // Cascading alanlar: parent değeri yoksa null gönderilir, istek yine de yapılır. - if (lookupDto.cascadeParentFields) { - if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { - filters.push([ - lookupDto?.cascadeRelationField, - lookupDto?.cascadeFilterOperator, - options?.data?.[lookupDto?.cascadeParentField] ?? null, - ]) - } else { - for (const cascadeParentField of lookupDto.cascadeParentFields.split(',')) { - filters.push(data?.[cascadeParentField.trim()] ?? null) - } - } - } + const filters = buildCascadeFilters(lookupDto, rowData) switch (lookupDto.dataSourceType) { case UiLookupDataSourceTypeEnum.StaticData: @@ -166,8 +189,8 @@ export const useLookupDataSource = ({ ...item, name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name, })), - filters.length ? filters : null, - `static:${cultureName}:${listFormCode}:${colData.fieldName}`, + filters, + `${cultureName}:${listFormCode}:${colData.fieldName}`, ) case UiLookupDataSourceTypeEnum.Query: return createLookupQueryDataSource(listFormCode, colData.fieldName, filters, isSubForm) diff --git a/ui/src/views/list/useListFormColumns.ts b/ui/src/views/list/useListFormColumns.ts index 81334634..13f9950a 100644 --- a/ui/src/views/list/useListFormColumns.ts +++ b/ui/src/views/list/useListFormColumns.ts @@ -17,11 +17,8 @@ import { UiLookupDataSourceTypeEnum, } from '@/proxy/form/models' import { addCss, getEditingFormFields } from './Utils' -import { - cachedLookupLoad, - clearLookupCache, - clearLookupCacheByPrefix, -} from '../shared/lookupCache' +import { clearLookupCache, clearLookupCacheByPrefix } from '../shared/lookupCache' +import { useLookupDataSource } from '../form/useLookupDataSource' import { NO_IMAGE, normalizeImageSize, @@ -169,7 +166,7 @@ const useListFormColumns = ({ onShowNote?: (rowData: Record) => void }) => { const dialog: any = useDialogContext() - const { translate, cultureName } = useLocalization() + const { translate } = useLocalization() const { checkPermission } = usePermission() const isPwaMode = usePWA() @@ -178,55 +175,12 @@ const useListFormColumns = ({ clearLookupCache() }, [listFormCode]) + const { getLookupDataSource } = useLookupDataSource({ listFormCode, isSubForm }) + + // DataGrid lookup'ı satırı `options.data` ile verir; görünen metin yüklemesi satırsız çağırır. const lookupDataSource = useCallback( - (options: any, colData: any, listFormCode: string) => { - const { lookupDto } = colData - const filters = [] - if (lookupDto.cascadeParentFields) { - if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { - filters.push([ - lookupDto?.cascadeRelationField, - lookupDto?.cascadeFilterOperator, - options?.data[lookupDto?.cascadeParentField], - ]) - //TODO: Statik data test edilecek - } else { - const data = options?.data ?? options - for (const cascadeParentField of lookupDto.cascadeParentFields.split(',')) { - filters.push(data[cascadeParentField]) - } - } - } - //UiLookupDataSourceTypeEnum : - // Data = 1 (Statik Data), - // Query = 2 (API'den geliyor fakat API query çalıştırıyor) - // WebService = 3 (API servisten geliyor) - if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.StaticData) { - return createLookupStaticDataSource( - () => - JSON.parse(lookupDto?.lookupQuery).map((item: any) => ({ - ...item, - name: typeof item.name === 'string' ? translate(`::${item.name}`) : item.name, - })), - filters.length ? filters : null, - `static:${cultureName}:${listFormCode}:${colData.fieldName}`, // cache key - ) - } else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.Query) { - return createLookupQueryDataSource(listFormCode, colData.fieldName, filters) - } else if (lookupDto.dataSourceType == UiLookupDataSourceTypeEnum.WebService) { - return createLookupApiDataSource( - listFormCode, - lookupDto?.lookupQuery, - filters, - colData.lookupDto?.valueExpr?.toLowerCase(), - ) - } else { - return { - store: [], - } - } - }, - [listFormCode, translate, cultureName], + (options: any, colData: any) => getLookupDataSource(colData, options?.data ?? options), + [getLookupDataSource], ) const loadLookupDisplayValues = useCallback( @@ -241,7 +195,7 @@ const useListFormColumns = ({ ) .map(async (column) => { const lookup = column.colData!.lookupDto - const source = lookupDataSource({}, column.colData, listFormCode) + const source = lookupDataSource({}, column.colData) const store = source instanceof CustomStore ? source @@ -270,7 +224,7 @@ const useListFormColumns = ({ return Object.fromEntries(entries) as Record> }, - [listFormCode, lookupDataSource], + [lookupDataSource], ) /** @@ -311,7 +265,7 @@ const useListFormColumns = ({ const prefix = getCachePrefix(colData) if (!prefix) return - const source = lookupDataSource({}, colData, listFormCode) + const source = lookupDataSource({}, colData) const store = source instanceof CustomStore ? source @@ -347,115 +301,6 @@ const useListFormColumns = ({ [gridDto, listFormCode, lookupDataSource], ) - const createLookupStaticDataSource = ( - load: () => any, - filter: any = null, - key: any = 'static', - sort: any = 'name', - ) => ({ - store: new CustomStore({ - key, - loadMode: 'raw', - load: async () => { - // load fonksiyonu sync sonuç döndürüyor olabilir, o yüzden Promise.resolve ile sar - return cachedLookupLoad(`static:${key}`, async () => { - const result = await Promise.resolve(load()) - if (result === undefined || result === null) return [] - return Array.isArray(result) ? result : [result] - }) - }, - }), - sort, - filter, - }) - - const createLookupQueryDataSource = ( - listFormCode?: string, - listFormFieldName?: string, - filters?: any[], - ) => { - return new CustomStore({ - loadMode: 'raw', - load: async () => { - if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) { - return [] - } - - try { - const cacheKey = `query:${listFormCode}:${listFormFieldName}:${JSON.stringify(filters ?? null)}` - return cachedLookupLoad(cacheKey, async () => { - const response = await dynamicFetch('list-form-select/lookup', 'POST', null, { - listFormCode, - listFormFieldName, - filters, - }) - - return (response.data ?? []).map((a: any) => ({ - key: a.Key, - name: a.Name, - group: a.Group, - - ...a, - })) - }) - } catch (error) { - console.error('Lookup query data could not be loaded:', error) - return [] - } - }, - }) - } - - const createLookupApiDataSource = ( - listFormCode?: string, - lookupQuery?: string, - filters?: any[], - keyName?: string, - ) => { - return new CustomStore({ - key: keyName, - loadMode: 'raw', - load: async () => { - if (!isSubForm && listFormCode && !window.location.pathname.includes(listFormCode)) { - return [] - } - if (!lookupQuery) { - return [] - } - - const [method, url, body, keySelector, nameSelector, groupSelector] = lookupQuery.split(';') - - // body içindeki @paramN'leri filtrelerle değiştir - let resolvedBody = body - if (filters?.length) { - for (let i = 0; i < filters.length; i++) { - resolvedBody = resolvedBody.replace(new RegExp(`@param${i}`, 'g'), filters[i]) - } - } - - try { - const cacheKey = `api:${lookupQuery}:${JSON.stringify(filters ?? null)}` - return cachedLookupLoad(cacheKey, async () => { - const response = await dynamicFetch(url, method, null, resolvedBody) - let { data } = response - if (!data) return [] - if (!Array.isArray(data)) data = [data] - return data.map((a: any) => ({ - key: eval(keySelector), - name: eval(nameSelector), - group: eval(groupSelector), - - ...a, - })) - }) - } catch (error) { - console.error('Lookup API data could not be loaded:', error) - return [] - } - }, - }) - } - const getCommandColumn = useCallback((): GridColumnData | undefined => { if (!gridDto) { return @@ -817,9 +662,8 @@ const useListFormColumns = ({ column.lookup = { valueExpr: colData.lookupDto?.valueExpr?.toLowerCase(), displayExpr: colData.lookupDto?.displayExpr?.toLowerCase(), - dataSource: (o) => lookupDataSource(o, colData, listFormCode), + dataSource: (o) => lookupDataSource(o, colData), } - //column.lookup.dataSource = lookupDataSource(null, colData) //cascadeEmptyFields verisi dolu ise bu kolon/field bir parent field dir if (colData.lookupDto.cascadeEmptyFields) { @@ -891,7 +735,7 @@ const useListFormColumns = ({ return columns }, - [gridDto, lookupDataSource, translate, checkPermission, dialog, isPwaMode, listFormCode], + [gridDto, lookupDataSource, translate], ) const getBandedColumns = useCallback(() => {