sozsoft-platform/ui/src/views/list/CardView.tsx

1327 lines
48 KiB
TypeScript
Raw Normal View History

2026-07-16 08:19:17 +00:00
import Container from '@/components/shared/Container'
import { Loading } from '@/components/shared'
import { ImportDashboard } from '@/components/importManager/ImportDashboard'
import { Dialog, Notification, toast } from '@/components/ui'
import { APP_NAME, DX_CLASSNAMES } from '@/constants/app.constant'
import {
DbTypeEnum,
EditingFormItemDto,
GridDto,
PlatformEditorTypes,
SubFormTabTypeEnum,
} from '@/proxy/form/models'
import { getList } from '@/services/form.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
2026-07-16 14:01:18 +00:00
import { useStoreActions, useStoreState } from '@/store'
2026-07-16 08:19:17 +00:00
import DxCardView, {
CardViewRef,
CardViewTypes,
CardHeader,
ColumnChooser,
Editing,
FilterPanel,
HeaderFilter,
LoadPanel,
Pager,
Paging,
SearchPanel,
Selection,
Sorting,
Toolbar,
ToolbarItem,
} from 'devextreme-react/card-view'
import CustomStore from 'devextreme/data/custom_store'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Helmet } from 'react-helmet'
import { NotePanel } from '../form/notes/NotePanel'
import SubForms from '../form/SubForms'
import GridFilterDialogs from './GridFilterDialogs'
import { GridExtraFilterToolbar } from './GridExtraFilterToolbar'
import { ImageUploadEditorComponent } from './editors/ImageUploadEditorComponent'
import { ImageViewerEditorComponent } from './editors/ImageViewerEditorComponent'
import { extractSearchParamsFields, GridExtraFilterState, safeJsonParse } from './Utils'
import { useFilters } from './useFilters'
import { useListFormColumns } from './useListFormColumns'
import { useListFormCustomDataSource } from './useListFormCustomDataSource'
import { orderListToolbarItems } from './toolbarOrder'
import { updateWorkflowApprovalToolbarItems, useToolbar } from './useToolbar'
import { layoutTypes } from '../admin/listForm/edit/types'
import { RowMode } from '../form/types'
interface CardViewProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
2026-07-16 14:01:18 +00:00
const cardColumnCountOptions = [1, 2, 3, 4, 5] as const
const defaultCardColumnCount = 3
2026-07-16 08:19:17 +00:00
const flattenColumns = (columns: any[] = []): any[] =>
columns.flatMap((column) => (column?.columns?.length ? flattenColumns(column.columns) : [column]))
const isTouchLikeDevice = () =>
typeof window !== 'undefined' &&
(window.matchMedia?.('(pointer: coarse)').matches || window.matchMedia?.('(hover: none)').matches)
const isMobileViewport = () =>
typeof window !== 'undefined' && window.matchMedia?.('(max-width: 767px)').matches
const getPopupHeight = (useMobilePopup: boolean, fullScreen: boolean) =>
useMobilePopup && fullScreen ? '100%' : 'auto'
const getPopupMaxHeight = (
useMobilePopup: boolean,
fullScreen: boolean,
configuredHeight?: number,
) => {
if (useMobilePopup && fullScreen) return '100%'
return configuredHeight && configuredHeight > 0 ? configuredHeight : '90vh'
}
const toCardColumn = (
column: any,
lookupDisplayValuesRef: { current: Record<string, Map<string, string>> },
): CardViewTypes.ColumnProperties => {
const dataField = column.dataField
const originalCustomizeText =
typeof column.customizeText === 'function' ? column.customizeText : undefined
const isImageColumn =
typeof column.cellTemplate === 'function' &&
Object.prototype.hasOwnProperty.call(column.extras ?? {}, 'imageUploadOptions')
return Object.fromEntries(
Object.entries({
alignment: column.alignment,
allowEditing: column.allowEditing,
allowFiltering: column.allowFiltering,
allowHeaderFiltering: column.allowHeaderFiltering,
allowReordering: column.allowReordering,
allowSearch: column.allowSearch,
allowSorting: column.allowSorting,
calculateDisplayValue:
typeof column.calculateDisplayValue === 'function'
? column.calculateDisplayValue
: undefined,
calculateFieldValue:
typeof column.calculateCellValue === 'function' ? column.calculateCellValue : undefined,
calculateFilterExpression:
typeof column.calculateFilterExpression === 'function'
? column.calculateFilterExpression
: undefined,
calculateSortValue: column.calculateSortValue,
caption: column.caption,
customizeText: column.lookup
? (fieldInfo: any) =>
lookupDisplayValuesRef.current[dataField]?.get(String(fieldInfo.value)) ??
originalCustomizeText?.(fieldInfo) ??
fieldInfo.valueText
: originalCustomizeText,
dataField: column.dataField,
dataType: column.dataType,
editorOptions: column.editorOptions,
filterValue: column.filterValue,
fieldValueTemplate:
isImageColumn && typeof column.cellTemplate === 'function'
? (templateOptions: any) => {
const templateData = templateOptions?.model ?? templateOptions
const container = document.createElement('div')
column.cellTemplate(container, {
column,
data: templateData.field?.card?.data,
displayValue: templateData.field?.displayValue,
value: templateData.field?.value,
})
return container
}
: undefined,
format: column.format,
headerFilter: column.headerFilter,
lookup: column.lookup,
name: column.name,
showInColumnChooser: column.showInColumnChooser,
sortIndex: column.sortIndex,
sortOrder: column.sortOrder,
validationRules: column.validationRules,
visible: column.visible,
visibleIndex: column.visibleIndex,
}).filter(([, value]) => value !== undefined),
) as CardViewTypes.ColumnProperties
}
const getValueByField = (data: Record<string, any>, field?: string | null) => {
if (!field) return undefined
if (Object.prototype.hasOwnProperty.call(data, field)) return data[field]
const key = Object.keys(data).find((item) => item.toLowerCase() === field.toLowerCase())
return key ? data[key] : undefined
}
2026-07-16 14:01:18 +00:00
const getEditingFieldName = (dataField?: string | null) =>
String(dataField ?? '')
.split(':')[0]
.trim()
2026-07-16 08:19:17 +00:00
const lookupDisplayValuesEqual = (
left: Record<string, Map<string, string>>,
right: Record<string, Map<string, string>>,
) => {
const fields = Object.keys(left)
if (fields.length !== Object.keys(right).length) return false
return fields.every((field) => {
const leftValues = left[field]
const rightValues = right[field]
return (
rightValues?.size === leftValues.size &&
[...leftValues].every(([key, value]) => rightValues.get(key) === value)
)
})
}
const isGridDtoForList = (gridDto: GridDto | undefined, listFormCode: string) =>
gridDto?.gridOptions.listFormCode?.toLowerCase() === listFormCode.toLowerCase()
const getInitialExtraFilters = (gridDto?: GridDto): GridExtraFilterState[] =>
(gridDto?.gridOptions.extraFilterDto ?? []).map((filter) => ({
fieldName: filter.fieldName,
operator: filter.operator,
controlType: filter.controlType,
value: filter.defaultValue ?? '',
}))
const loadCascadeLookupDisplayValues = async (
columns: any[],
rows: Record<string, any>[],
knownValues: Record<string, Map<string, string>> = {},
) => {
const entries = await Promise.all(
columns
.filter((column) => column.colData?.lookupDto?.cascadeParentFields)
.map(async (column) => {
const lookup = column.colData.lookupDto
2026-07-16 14:01:18 +00:00
const parentFields: string[] = String(lookup.cascadeParentFields)
2026-07-16 08:19:17 +00:00
.split(',')
2026-07-16 14:01:18 +00:00
.map((field) => field.trim())
2026-07-16 08:19:17 +00:00
.filter(Boolean)
2026-07-16 14:01:18 +00:00
const parentValueLists: string[][] = parentFields.map((field) => {
2026-07-16 08:19:17 +00:00
const values = Object.entries(knownValues).find(
([key]) => key.toLowerCase() === field.toLowerCase(),
)?.[1]
return values ? [...values.keys()] : []
})
const preloadContexts = parentValueLists.every((values) => values.length > 0)
? parentValueLists.reduce<Record<string, any>[]>(
(contexts, values, index) =>
contexts
.flatMap((context) =>
values.map((value) => ({ ...context, [parentFields[index]]: value })),
)
.slice(0, 100),
[{}],
)
: []
const sourceRows = rows.length > 0 ? rows : preloadContexts
const contexts = [
...new Map(
sourceRows.map((row) => [
JSON.stringify(parentFields.map((field: string) => getValueByField(row, field))),
row,
]),
).values(),
]
const loadedItems = await Promise.all(
contexts.map(async (data) => {
const source = column.lookup?.dataSource?.({ data })
const store =
source instanceof CustomStore
? source
: (source as { store?: CustomStore<any, any> } | undefined)?.store
return ((await store?.load?.()) ?? []) as Record<string, any>[]
}),
)
const values = new Map<string, string>()
loadedItems.flat().forEach((item) => {
const value = getValueByField(item, lookup.valueExpr)
const text = getValueByField(item, lookup.displayExpr)
if (value !== undefined && value !== null && text !== undefined && text !== null) {
values.set(String(value), String(text))
}
})
return [column.dataField, values] as const
}),
)
return Object.fromEntries(entries) as Record<string, Map<string, string>>
}
const getCardLoadKey = (options: any) =>
JSON.stringify({
filter: options.filter,
group: options.group,
groupSummary: options.groupSummary,
requireGroupCount: options.requireGroupCount,
requireTotalCount: options.requireTotalCount,
searchExpr: options.searchExpr,
searchOperation: options.searchOperation,
searchValue: options.searchValue,
skip: options.skip,
sort: options.sort,
take: options.take,
totalSummary: options.totalSummary,
})
const getCardCountKey = (options: any) =>
JSON.stringify({
filter: options.filter,
group: options.group,
searchExpr: options.searchExpr,
searchOperation: options.searchOperation,
searchValue: options.searchValue,
})
const deduplicateCardStoreLoads = (store: CustomStore<any, any>) => {
const cardStore = store as any
const originalLoad = cardStore._loadFunc?.bind(cardStore)
if (!originalLoad) return store
const cachedLoads = new Map<string, any>()
const pendingLoads = new Map<string, Promise<any>>()
const pendingTotalCounts = new Map<string, Promise<number | undefined>>()
const totalCounts = new Map<string, number>()
const invalidateSelectCache = () => {
cachedLoads.clear()
pendingLoads.clear()
pendingTotalCounts.clear()
totalCounts.clear()
}
cardStore._loadFunc = (options: any) => {
const loadKey = getCardLoadKey(options)
if (cachedLoads.has(loadKey)) return Promise.resolve(cachedLoads.get(loadKey))
const pending = pendingLoads.get(loadKey)
if (pending) return pending
const originalRequest = originalLoad(options)
const effectiveLoadKey = getCardLoadKey(options)
const request = Promise.resolve(originalRequest)
.then((result) => {
cachedLoads.set(loadKey, result)
cachedLoads.set(effectiveLoadKey, result)
if (typeof result?.totalCount === 'number') {
totalCounts.set(getCardCountKey(options), result.totalCount)
}
while (cachedLoads.size > 20) {
const oldestKey = cachedLoads.keys().next().value
if (oldestKey === undefined) break
cachedLoads.delete(oldestKey)
totalCounts.delete(oldestKey)
}
return result
})
.finally(() => {
pendingLoads.delete(loadKey)
pendingLoads.delete(effectiveLoadKey)
})
pendingLoads.set(loadKey, request)
pendingLoads.set(effectiveLoadKey, request)
const countKey = getCardCountKey(options)
const countRequest = request.then((result) =>
typeof result?.totalCount === 'number' ? result.totalCount : undefined,
)
pendingTotalCounts.set(countKey, countRequest)
countRequest.then(
() => pendingTotalCounts.delete(countKey),
() => pendingTotalCounts.delete(countKey),
)
return request
}
const originalTotalCount = cardStore._totalCountFunc?.bind(cardStore)
if (originalTotalCount) {
cardStore._totalCountFunc = (options: any) => {
const countKey = getCardCountKey(options)
const cachedCount = totalCounts.get(countKey)
if (cachedCount !== undefined) return Promise.resolve(cachedCount)
const pendingCount = pendingTotalCounts.get(countKey)
return pendingCount
2026-07-16 14:01:18 +00:00
? pendingCount.then((count) => (count !== undefined ? count : originalTotalCount(options)))
2026-07-16 08:19:17 +00:00
: originalTotalCount(options)
}
}
;['_insertFunc', '_updateFunc', '_removeFunc'].forEach((functionName) => {
const originalMutation = cardStore[functionName]?.bind(cardStore)
if (!originalMutation) return
cardStore[functionName] = (...args: any[]) => {
invalidateSelectCache()
return originalMutation(...args)
}
})
cardStore.invalidateSelectCache = invalidateSelectCache
return store
}
const CardView = (props: CardViewProps) => {
const {
listFormCode,
searchParams,
isSubForm,
level,
refreshData: refreshParentData,
gridDto: externalGridDto,
} = props
const { translate } = useLocalization()
const { checkPermission } = usePermission()
const currentUser = useStoreState((state) => state.auth.user)
2026-07-16 14:01:18 +00:00
const savedListState = useStoreState((state) =>
state.admin.lists.states.find((item) => item.listFormCode === listFormCode),
)
const cardColumnCount = savedListState?.cardColumnCount ?? defaultCardColumnCount
const setListState = useStoreActions((actions) => actions.admin.lists.setStates)
const currentCulture = useStoreState(
(state) => state.abpConfig.config?.localization.currentCulture.cultureName,
)
const localizationTexts = useStoreState((state) => state.abpConfig.texts)
2026-07-16 08:19:17 +00:00
const useMobileEditPopup = isMobileViewport() || isTouchLikeDevice()
const cardViewRef = useRef<CardViewRef>()
const searchParamsRef = useRef(searchParams)
const defaultSearchFilterRef = useRef(searchParams?.get('filter') ?? null)
const dataSourceSearchParamsRef = useRef<URLSearchParams>()
const extraFilterMountedRef = useRef(false)
const [gridDtoState, setGridDtoState] = useState<{
listFormCode: string
value?: GridDto
}>(() => ({
listFormCode,
value: isGridDtoForList(externalGridDto, listFormCode) ? externalGridDto : undefined,
}))
const gridDto = gridDtoState.listFormCode === listFormCode ? gridDtoState.value : undefined
const [mode, setMode] = useState<RowMode>('view')
const [isPopupFullScreen, setIsPopupFullScreen] = useState(false)
const [formData, setFormData] = useState<Record<string, any>>()
const formDataRef = useRef<Record<string, any>>()
formDataRef.current = formData
const lookupDisplayValuesRef = useRef<Record<string, Map<string, string>>>({})
const [lookupReadyState, setLookupReadyState] = useState({ listFormCode, ready: false })
const [notePanelTarget, setNotePanelTarget] = useState<{
entityName: string
entityId: string
} | null>(null)
const [extraFilters, setExtraFilters] = useState<GridExtraFilterState[]>(() =>
getInitialExtraFilters(
isGridDtoForList(externalGridDto, listFormCode) ? externalGridDto : undefined,
),
)
const mergedExtraFilter = useMemo(() => {
const base = safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:')
const triplets = [
...extractSearchParamsFields(base),
...extraFilters
.filter(
(filter) => filter.value !== undefined && filter.value !== null && filter.value !== '',
)
.map(
(filter) =>
[filter.fieldName, filter.operator, filter.value] as [string, string, unknown],
),
]
const unique = triplets.reduce(
(result, current) => {
const index = result.findIndex((item) => item[0] === current[0] && item[1] === current[1])
if (index >= 0) result[index] = current
else result.push(current)
return result
},
[] as [string, string, unknown][],
)
return unique.reduce<any>((result, filter) => (result ? [result, 'and', filter] : filter), null)
}, [extraFilters])
if (!dataSourceSearchParamsRef.current) {
dataSourceSearchParamsRef.current = new URLSearchParams(searchParams?.toString())
if (mergedExtraFilter) {
dataSourceSearchParamsRef.current.set('filter', JSON.stringify(mergedExtraFilter))
}
}
useEffect(() => {
searchParamsRef.current = searchParams
defaultSearchFilterRef.current = searchParams?.get('filter') ?? null
dataSourceSearchParamsRef.current = undefined
extraFilterMountedRef.current = false
setExtraFilters([])
lookupDisplayValuesRef.current = {}
setLookupReadyState({ listFormCode, ready: false })
setFormData(undefined)
setMode('view')
}, [listFormCode, searchParams])
useEffect(() => {
if (isGridDtoForList(externalGridDto, listFormCode)) {
setGridDtoState({ listFormCode, value: externalGridDto })
setExtraFilters(getInitialExtraFilters(externalGridDto))
return
}
let active = true
getList({ listFormCode }).then((response: any) => {
if (active) {
setGridDtoState({ listFormCode, value: response.data })
setExtraFilters(getInitialExtraFilters(response.data))
}
})
return () => {
active = false
}
}, [externalGridDto, listFormCode])
const refreshData = useCallback(() => {
const cardDataSource = cardViewRef.current?.instance().getDataSource()
;(cardDataSource?.store() as any)?.invalidateSelectCache?.()
const reload = cardDataSource?.reload()
Promise.resolve(reload).finally(() => refreshParentData?.())
}, [refreshParentData])
2026-07-16 14:01:18 +00:00
const setCardColumnCount = useCallback(
(cardColumnCount: number) => {
setListState({
listFormCode,
layout: savedListState?.layout ?? 'card',
cardColumnCount,
})
},
[listFormCode, savedListState?.layout, setListState],
)
2026-07-16 08:19:17 +00:00
const listFormColumnsRef = useRef({
current: {
instance: () => ({ refresh: refreshData }),
},
}).current
const openNotePanel = useCallback(
(rowData: Record<string, any>) => {
const entityId = getValueByField(rowData, gridDto?.gridOptions.keyFieldName)
if (entityId === undefined || entityId === null || entityId === '') return
setNotePanelTarget({
entityName: gridDto?.gridOptions.listFormCode ?? listFormCode,
entityId: String(entityId),
})
},
[gridDto, listFormCode],
)
const { getBandedColumns, loadLookupDisplayValues } = useListFormColumns({
gridDto,
listFormCode,
isSubForm,
gridRef: listFormColumnsRef,
onShowNote: openNotePanel,
})
2026-07-16 14:01:18 +00:00
const localizationRevisionRef = useRef({
culture: currentCulture,
texts: localizationTexts,
value: 0,
})
if (
localizationRevisionRef.current.culture !== currentCulture ||
localizationRevisionRef.current.texts !== localizationTexts
) {
localizationRevisionRef.current = {
culture: currentCulture,
texts: localizationTexts,
value: localizationRevisionRef.current.value + 1,
}
}
const localizationRevision = localizationRevisionRef.current.value
const listColumnsCacheRef = useRef<{
gridDto?: GridDto
localizationRevision?: number
columns: any[]
}>({ columns: [] })
if (
listColumnsCacheRef.current.gridDto !== gridDto ||
listColumnsCacheRef.current.localizationRevision !== localizationRevision
) {
2026-07-16 08:19:17 +00:00
listColumnsCacheRef.current = {
gridDto,
2026-07-16 14:01:18 +00:00
localizationRevision,
2026-07-16 08:19:17 +00:00
columns: flattenColumns(getBandedColumns()),
}
}
const listColumns = listColumnsCacheRef.current.columns
const cardColumnsCacheRef = useRef<{
2026-07-16 14:01:18 +00:00
listColumns?: any[]
2026-07-16 08:19:17 +00:00
columns: CardViewTypes.ColumnProperties[]
}>({ columns: [] })
2026-07-16 14:01:18 +00:00
if (cardColumnsCacheRef.current.listColumns !== listColumns) {
2026-07-16 08:19:17 +00:00
cardColumnsCacheRef.current = {
2026-07-16 14:01:18 +00:00
listColumns,
2026-07-16 08:19:17 +00:00
columns: listColumns
.filter((column) => column?.type !== 'buttons' && column?.dataField)
.map((column) => toCardColumn(column, lookupDisplayValuesRef)),
}
}
const cardColumns = cardColumnsCacheRef.current.columns
const cardColumnsRef = useRef(cardColumns)
cardColumnsRef.current = cardColumns
const commandButtons = listColumns.find((column) => column?.type === 'buttons')?.buttons ?? []
const commandButtonsRef = useRef(commandButtons)
commandButtonsRef.current = commandButtons
const renderImageUploadEditor = useCallback((templateData: any) => {
2026-07-16 14:01:18 +00:00
const configuredDataField =
2026-07-16 08:19:17 +00:00
templateData?.dataField ??
templateData?.item?.dataField ??
templateData?.editorOptions?.name ??
templateData?.name
2026-07-16 14:01:18 +00:00
const dataField = getEditingFieldName(configuredDataField)
2026-07-16 08:19:17 +00:00
const column = listColumnsCacheRef.current.columns.find(
2026-07-16 14:01:18 +00:00
(candidate) => candidate.dataField?.toLowerCase() === String(dataField ?? '').toLowerCase(),
2026-07-16 08:19:17 +00:00
)
const component = templateData?.component
const currentFormData = component?.option?.('formData') ?? formDataRef.current ?? {}
2026-07-16 14:01:18 +00:00
const editorValue = templateData?.editorOptions?.value
2026-07-16 08:19:17 +00:00
return (
<ImageUploadEditorComponent
{...templateData}
column={column}
2026-07-16 14:01:18 +00:00
value={
editorValue !== undefined ? editorValue : getValueByField(currentFormData, dataField)
}
2026-07-16 08:19:17 +00:00
setValue={(value: any) => {
if (!dataField) return
2026-07-16 14:01:18 +00:00
if (typeof templateData?.editorOptions?.onValueChanged === 'function') {
templateData.editorOptions.onValueChanged({ value })
} else {
component?.updateData?.(dataField, value)
}
2026-07-16 08:19:17 +00:00
formDataRef.current = { ...currentFormData, [dataField]: value }
}}
/>
)
}, [])
const renderImageViewerEditor = useCallback(
(templateData: any) => (
<ImageViewerEditorComponent {...templateData} fallbackFormData={formDataRef.current} />
),
[],
)
const imageUploadFormTemplate = useCallback(
(templateOptions: any) => {
const container = document.createElement('div')
createRoot(container).render(
renderImageUploadEditor(templateOptions?.model ?? templateOptions),
)
return container
},
[renderImageUploadEditor],
)
const imageViewerFormTemplate = useCallback(
(templateOptions: any) => {
const container = document.createElement('div')
createRoot(container).render(
renderImageViewerEditor(templateOptions?.model ?? templateOptions),
)
return container
},
[renderImageViewerEditor],
)
const cascadeChildrenByParent = useMemo(() => {
const result = new Map<string, string[]>()
gridDto?.columnFormats.forEach((column) => {
if (!column.fieldName || !column.lookupDto?.cascadeParentFields) return
column.lookupDto.cascadeParentFields.split(',').forEach((parentField) => {
const key = parentField.trim().toLowerCase()
if (!key) return
result.set(key, [...(result.get(key) ?? []), column.fieldName!])
})
})
return result
}, [gridDto])
const onEditingFormFieldChanged = useCallback(
(event: any) => {
if (!event.dataField) return
const childFields = cascadeChildrenByParent.get(event.dataField.toLowerCase())
const currentData = event.component?.option?.('formData') ?? formDataRef.current ?? {}
const nextData = { ...currentData, [event.dataField]: event.value }
formDataRef.current = nextData
if (!childFields?.length) return
childFields.forEach((childField) => {
const column = listColumnsCacheRef.current.columns.find(
(candidate) => candidate.dataField?.toLowerCase() === childField.toLowerCase(),
)
const source = column?.lookup?.dataSource?.({ data: nextData })
const parentFields = column?.colData?.lookupDto?.cascadeParentFields
?.split(',')
.map((field: string) => field.trim())
.filter(Boolean)
2026-07-16 14:01:18 +00:00
const disabled = parentFields?.some((field: string) => !getValueByField(nextData, field))
2026-07-16 08:19:17 +00:00
const editor = event.component?.getEditor?.(childField)
editor?.option?.({ dataSource: source, disabled: Boolean(disabled), value: null })
event.component?.updateData?.(childField, null)
nextData[childField] = null
})
formDataRef.current = nextData
setFormData(nextData)
},
[cascadeChildrenByParent],
)
2026-07-16 14:01:18 +00:00
const applyLookupDisplayValues = useCallback((values: Record<string, Map<string, string>>) => {
if (lookupDisplayValuesEqual(lookupDisplayValuesRef.current, values)) return
lookupDisplayValuesRef.current = values
}, [])
2026-07-16 08:19:17 +00:00
const getLookupColumns = useCallback(
() =>
listColumnsCacheRef.current.columns.filter(
(column) => column?.type !== 'buttons' && column?.dataField,
),
[],
)
useEffect(() => {
if (!gridDto) return
let active = true
const columns = getLookupColumns()
loadLookupDisplayValues(columns)
.then(async (values) => ({
...values,
...(await loadCascadeLookupDisplayValues(columns, [], values)),
}))
.then((values) => {
if (!active) return
applyLookupDisplayValues(values)
setLookupReadyState({ listFormCode, ready: true })
})
.catch((error) => {
console.error('CardView lookup preload error:', error)
if (active) setLookupReadyState({ listFormCode, ready: true })
})
return () => {
active = false
}
}, [applyLookupDisplayValues, getLookupColumns, gridDto, listFormCode, loadLookupDisplayValues])
const onContentReady = useCallback(() => {
const rows = (cardViewRef.current?.instance().getDataSource().items() ?? []) as Record<
string,
any
>[]
const columns = getLookupColumns()
Promise.all([
loadLookupDisplayValues(columns),
loadCascadeLookupDisplayValues(columns, rows),
2026-07-16 14:01:18 +00:00
]).then(([values, cascadeValues]) => applyLookupDisplayValues({ ...values, ...cascadeValues }))
2026-07-16 08:19:17 +00:00
}, [applyLookupDisplayValues, getLookupColumns, loadLookupDisplayValues])
const { createSelectDataSource } = useListFormCustomDataSource({ gridRef: cardViewRef })
const dataSource = useMemo<CustomStore<any, any> | undefined>(() => {
if (!gridDto) return undefined
return deduplicateCardStoreLoads(
createSelectDataSource(
gridDto.gridOptions,
listFormCode,
dataSourceSearchParamsRef.current,
layoutTypes.card,
cardColumnsRef.current as any,
),
)
}, [createSelectDataSource, gridDto, listFormCode])
const { filterToolbarData, ...filterData } = useFilters({
gridDto,
gridRef: cardViewRef,
listFormCode,
})
const { toolbarData, toolbarModalData, setToolbarModalData } = useToolbar({
gridDto,
listFormCode,
getSelectedRowKeys: () => cardViewRef.current?.instance().getSelectedCardKeys() ?? [],
getSelectedRowsData: () => cardViewRef.current?.instance().getSelectedCardsData() ?? [],
refreshData,
getFilter: () => cardViewRef.current?.instance().getCombinedFilter(),
layout: layoutTypes.card,
})
const orderedToolbarData = useMemo(() => {
const unsupportedItems = new Set([
'applyFilterButton',
'exportButton',
'fitColumns',
'groupPanel',
'resetGridState',
'saveGridState',
])
return orderListToolbarItems([...toolbarData, ...filterToolbarData])
.filter((item) => !unsupportedItems.has(String(item.name)))
.map((item) => {
if (item.name !== 'addRowButton') return item
return {
...item,
name: 'addCardButton',
options: item.options?.onClick
? item.options
: {
...item.options,
onClick: () => cardViewRef.current?.instance().addCard(),
},
}
})
}, [filterToolbarData, toolbarData])
const mapEditingFormItem = useCallback(
(item: EditingFormItemDto) => {
const fieldName = item.dataField.split(':')[0]
const column = gridDto?.columnFormats.find(
(candidate) => candidate.fieldName?.toLowerCase() === fieldName.toLowerCase(),
)
if (!column) return undefined
if (
mode === 'new' &&
fieldName.toLowerCase() === gridDto?.gridOptions.keyFieldName?.toLowerCase()
) {
return undefined
}
const isAllowed =
mode === 'new'
? column.canCreate && column.allowAdding
: mode === 'edit'
? column.canUpdate && column.allowEditing
: false
if (!isAllowed) return undefined
let editorOptions: Record<string, any> = {}
try {
editorOptions = item.editorOptions ? JSON.parse(item.editorOptions) : {}
} catch (error) {
console.error('EditorOptions parse error:', item.dataField, error)
}
if (column.sourceDbType === DbTypeEnum.Date) {
editorOptions = {
type: 'date',
dateSerializationFormat: 'yyyy-MM-dd',
displayFormat: 'shortDate',
...editorOptions,
}
} else if (
column.sourceDbType === DbTypeEnum.DateTime ||
column.sourceDbType === DbTypeEnum.DateTime2 ||
column.sourceDbType === DbTypeEnum.DateTimeOffset
) {
editorOptions = {
type: 'datetime',
dateSerializationFormat: 'yyyy-MM-ddTHH:mm:ss',
displayFormat: 'shortDateShortTime',
...editorOptions,
}
}
if (column.placeHolder) {
editorOptions.placeholder = translate('::' + column.placeHolder)
}
const cardColumn = cardColumnsRef.current.find(
(candidate) => candidate.dataField?.toLowerCase() === fieldName.toLowerCase(),
) as any
const lookup = cardColumn?.lookup
if (lookup) {
editorOptions = {
...editorOptions,
dataSource:
typeof lookup.dataSource === 'function'
? lookup.dataSource({ data: formData ?? {} })
: lookup.dataSource,
displayExpr: lookup.displayExpr,
valueExpr: lookup.valueExpr,
}
}
return {
itemType: 'simple',
2026-07-16 14:01:18 +00:00
dataField: fieldName,
name: fieldName,
2026-07-16 08:19:17 +00:00
colSpan: item.colSpan,
label: {
text: item.dataField.includes(':')
? item.dataField.split(':')[1]
: translate('::' + (column.captionName || column.fieldName)),
},
editorType:
item.editorType2 === PlatformEditorTypes.dxGridBox
? 'dxDropDownBox'
: item.editorType2 === PlatformEditorTypes.dxImageUpload ||
item.editorType2 === PlatformEditorTypes.dxImageViewer
? undefined
: item.editorType2,
editorOptions,
template:
item.editorType2 === PlatformEditorTypes.dxImageUpload
? imageUploadFormTemplate
: item.editorType2 === PlatformEditorTypes.dxImageViewer
? imageViewerFormTemplate
: undefined,
}
},
[formData, gridDto, imageUploadFormTemplate, imageViewerFormTemplate, mode, translate],
)
const editingFormItems = useMemo(() => {
if (!gridDto || mode === 'view') return []
const groups = [...(gridDto.gridOptions.editingFormDto ?? [])].sort(
(left, right) => left.order - right.order,
)
const tabbedGroups = groups.filter((group) => group.itemType === 'tabbed')
const result: any[] = []
groups.forEach((group) => {
const items = [...(group.items ?? [])]
.sort((left, right) => left.order - right.order)
.map(mapEditingFormItem)
.filter(Boolean)
if (group.itemType !== 'tabbed') {
if (!items.length) return
result.push({
itemType: group.itemType || 'group',
caption: group.caption,
colCount: useMobileEditPopup ? 1 : group.colCount || 1,
colSpan: group.colSpan || 1,
items,
})
return
}
if (group !== tabbedGroups[0]) return
const tabs = tabbedGroups
.map((tab) => ({
title: tab.caption,
colCount: useMobileEditPopup ? 1 : tab.colCount || 1,
items: [...(tab.items ?? [])]
.sort((left, right) => left.order - right.order)
.map(mapEditingFormItem)
.filter(Boolean),
}))
.filter((tab) => tab.items.length > 0)
if (tabs.length) result.push({ itemType: 'tabbed', tabs })
})
return result
}, [gridDto, mapEditingFormItem, mode, useMobileEditPopup])
useEffect(() => {
if (!extraFilterMountedRef.current) {
extraFilterMountedRef.current = true
return
}
if (mergedExtraFilter) {
const serializedFilter = JSON.stringify(mergedExtraFilter)
searchParamsRef.current?.set('filter', serializedFilter)
dataSourceSearchParamsRef.current?.set('filter', serializedFilter)
} else {
searchParamsRef.current?.delete('filter')
dataSourceSearchParamsRef.current?.delete('filter')
}
cardViewRef.current?.instance().getDataSource()?.reload()
}, [mergedExtraFilter])
2026-07-16 14:01:18 +00:00
const renderCardHeader = useCallback((templateOptions: any, suppliedContainer?: HTMLElement) => {
const templateData = templateOptions?.model ?? templateOptions
const card = templateData?.card ?? templateData
const row = { data: card?.data ?? {}, isEditing: false, key: card?.key }
const templateContainer = templateOptions?.container ?? suppliedContainer
const domContainer =
templateContainer instanceof HTMLElement ? templateContainer : templateContainer?.get?.(0)
domContainer?.replaceChildren()
const actionsElement = document.createElement('div')
actionsElement.className =
'flex min-h-10 w-full flex-wrap items-center justify-end gap-1 px-2 py-1'
commandButtonsRef.current.forEach((action: any, index: number) => {
let visible = action.visible ?? true
if (typeof visible === 'function') {
try {
visible = visible({ row, card })
} catch {
visible = false
}
}
if (!visible) return
const name = String(action.name ?? action.hint ?? `action-${index}`)
const button = document.createElement('button')
button.type = 'button'
button.className =
'rounded px-2 py-1 text-xs font-medium hover:bg-gray-100 dark:hover:bg-gray-700'
button.textContent = action.text || action.hint || name
button.title = action.hint ?? action.text ?? name
button.addEventListener('click', (event) => {
event.stopPropagation()
if (name === 'edit') {
cardViewRef.current?.instance().editCard(card.index)
} else if (name === 'delete') {
cardViewRef.current?.instance().deleteCard(card.index)
} else {
action.onClick?.({
event,
row,
card,
component: cardViewRef.current?.instance(),
})
2026-07-16 08:19:17 +00:00
}
})
2026-07-16 14:01:18 +00:00
actionsElement.appendChild(button)
})
2026-07-16 08:19:17 +00:00
2026-07-16 14:01:18 +00:00
return actionsElement
}, [])
2026-07-16 08:19:17 +00:00
const editingOptions = gridDto?.gridOptions.editingOptionDto
const selectionOptions = gridDto?.gridOptions.selectionDto
const onSelectionChanged = useCallback(
(event: CardViewTypes.SelectionChangedEvent) => {
const selectedCardsData = event.selectedCardsData as Record<string, any>[]
setFormData(selectedCardsData[0])
const component = event.component
const toolbar = component.option('toolbar') as any
const deleteSelectedItemIndex = toolbar?.items?.findIndex(
(item: any) => item.name === 'deleteSelectedRecords',
)
if (deleteSelectedItemIndex >= 0) {
const optionPath = `toolbar.items[${deleteSelectedItemIndex}].options.visible`
const nextVisible = selectedCardsData.length > 1
if (component.option(optionPath) !== nextVisible) {
component.option(optionPath, nextVisible)
}
}
updateWorkflowApprovalToolbarItems(
component,
gridDto?.gridOptions.workflowDto,
selectedCardsData,
currentUser,
)
},
[currentUser, gridDto],
)
return (
<Container className={DX_CLASSNAMES}>
{!isSubForm && (
<Helmet
titleTemplate={`%s | ${APP_NAME}`}
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle={APP_NAME}
/>
)}
{!gridDto ||
!dataSource ||
lookupReadyState.listFormCode !== listFormCode ||
!lookupReadyState.ready ? (
<div className="p-4">
<Loading loading>{translate('::App.Loading')}</Loading>
</div>
) : (
<div className="p-1">
<DxCardView
2026-07-16 14:01:18 +00:00
key={`CardView-${listFormCode}-${localizationRevision}`}
2026-07-16 08:19:17 +00:00
ref={cardViewRef as any}
fieldHintEnabled
id={`CardView-${listFormCode}`}
dataSource={dataSource}
columns={cardColumns}
height={gridDto.gridOptions.height > 0 ? gridDto.gridOptions.height : undefined}
width={gridDto.gridOptions.width || '100%'}
cardMinWidth={280}
2026-07-16 14:01:18 +00:00
cardsPerRow={cardColumnCount}
allowColumnReordering={gridDto.gridOptions.columnOptionDto?.allowColumnReordering}
rtlEnabled={gridDto.gridOptions.columnOptionDto?.rtlEnabled}
2026-07-16 08:19:17 +00:00
className="list-card-view"
hoverStateEnabled={gridDto.gridOptions.columnOptionDto?.hoverStateEnabled}
remoteOperations={{ paging: true, filtering: true, sorting: true }}
wordWrapEnabled={gridDto.gridOptions.rowDto?.whiteSpace !== 'nowrap'}
onContentReady={onContentReady}
onSelectionChanged={onSelectionChanged}
onInitNewCard={(event) => {
2026-07-16 14:01:18 +00:00
const nextFormData = (event.data as Record<string, any>) ?? {}
formDataRef.current = nextFormData
setFormData(nextFormData)
2026-07-16 08:19:17 +00:00
setMode('new')
setIsPopupFullScreen(
useMobileEditPopup || (editingOptions?.popup?.fullScreen ?? false),
)
}}
onEditingStart={(event) => {
2026-07-16 14:01:18 +00:00
const nextFormData = (event.data as Record<string, any>) ?? {}
formDataRef.current = nextFormData
setFormData(nextFormData)
2026-07-16 08:19:17 +00:00
setMode('edit')
setIsPopupFullScreen(
useMobileEditPopup || (editingOptions?.popup?.fullScreen ?? false),
)
}}
onEditCanceled={() => {
setMode('view')
setIsPopupFullScreen(false)
}}
onSaved={() => {
setMode('view')
setIsPopupFullScreen(false)
}}
onDataErrorOccurred={(event) => {
toast.push(
<Notification type="danger" duration={2500}>
{String(event.error?.message ?? event.error ?? 'Select error')}
</Notification>,
{ placement: 'bottom-end' },
)
}}
onCardInserted={() => refreshParentData?.()}
onCardUpdated={() => refreshParentData?.()}
onCardRemoved={() => refreshParentData?.()}
>
<CardHeader template={renderCardHeader} />
<Editing
allowAdding={
editingOptions?.allowAdding && checkPermission(gridDto.gridOptions.permissionDto?.c)
}
allowUpdating={
editingOptions?.allowUpdating &&
checkPermission(gridDto.gridOptions.permissionDto?.u)
}
allowDeleting={
editingOptions?.allowDeleting &&
checkPermission(gridDto.gridOptions.permissionDto?.d)
}
confirmDelete={editingOptions?.confirmDelete}
form={{
colCount: 1,
items: editingFormItems,
labelLocation: 'top',
onFieldDataChanged: onEditingFormFieldChanged,
showColonAfterLabel: false,
}}
popup={{
animation: {},
deferRendering: true,
wrapperAttr: useMobileEditPopup ? { class: 'mobile-edit-popup' } : undefined,
title:
(mode === 'new' ? '✚ ' : '🖊️ ') + translate('::' + editingOptions?.popup?.title),
showTitle: editingOptions?.popup?.showTitle,
hideOnOutsideClick: editingOptions?.popup?.hideOnOutsideClick,
width: useMobileEditPopup
? '100%'
: Number(editingOptions?.popup?.width) > 0
? editingOptions?.popup?.width
: 'auto',
maxWidth: useMobileEditPopup ? '100%' : '95vw',
height: getPopupHeight(useMobileEditPopup, isPopupFullScreen),
maxHeight: getPopupMaxHeight(
useMobileEditPopup,
isPopupFullScreen,
editingOptions?.popup?.height,
),
fullScreen: isPopupFullScreen,
position: useMobileEditPopup
? {
my: 'top center',
at: 'top center',
of: typeof window !== 'undefined' ? window : undefined,
}
: editingOptions?.popup?.position,
resizeEnabled: editingOptions?.popup?.resizeEnabled,
dragEnabled: editingOptions?.popup?.dragEnabled,
restorePosition: editingOptions?.popup?.restorePosition,
toolbarItems: [
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Save'),
type: 'default',
onClick: () => cardViewRef.current?.instance().saveEditData(),
},
},
{
widget: 'dxButton',
toolbar: 'bottom',
location: 'after',
options: {
text: translate('::Cancel'),
onClick: () => cardViewRef.current?.instance().cancelEditData(),
},
},
{
widget: 'dxButton',
toolbar: 'top',
location: 'after',
options: {
icon: isPopupFullScreen ? 'collapse' : 'fullscreen',
hint: isPopupFullScreen
? translate('::Normal Boyut')
: translate('::Tam Ekran'),
stylingMode: 'text',
onClick: () => setIsPopupFullScreen((current) => !current),
},
},
],
}}
/>
2026-07-16 14:01:18 +00:00
<Toolbar visible>
{cardColumnCountOptions.map((columnCount) => {
const selected = cardColumnCount === columnCount
return (
<ToolbarItem
key={`card-column-count-${columnCount}`}
location="before"
locateInMenu="never"
widget="dxButton"
options={{
text: String(columnCount),
hint: String(columnCount),
type: selected ? 'default' : 'normal',
stylingMode: selected ? 'contained' : 'text',
elementAttr: {
'aria-label': String(columnCount),
'aria-pressed': selected,
},
onClick: () => setCardColumnCount(columnCount),
}}
/>
)
})}
2026-07-16 08:19:17 +00:00
{orderedToolbarData.map((item, index) => (
<ToolbarItem key={`${item.name ?? 'toolbar'}-${index}`} {...(item as any)} />
))}
{gridDto.gridOptions.extraFilterDto?.length ? (
<ToolbarItem
location="before"
render={() => (
<GridExtraFilterToolbar
filters={gridDto.gridOptions.extraFilterDto ?? []}
extraFilters={extraFilters}
setExtraFilters={setExtraFilters}
/>
)}
/>
) : null}
</Toolbar>
<Sorting mode={gridDto.gridOptions.sortMode} />
<SearchPanel
visible={gridDto.gridOptions.searchPanelDto.visible}
width={gridDto.gridOptions.searchPanelDto.width}
placeholder={translate('::App.Search')}
/>
2026-07-16 14:01:18 +00:00
<FilterPanel
visible={gridDto.gridOptions.filterPanelDto.visible}
filterEnabled={gridDto.gridOptions.filterPanelDto.filterEnabled}
texts={gridDto.gridOptions.filterPanelDto.texts}
/>
<HeaderFilter
visible={gridDto.gridOptions.headerFilterDto.visible}
width={gridDto.gridOptions.headerFilterDto.width}
height={gridDto.gridOptions.headerFilterDto.height}
search={{
enabled: gridDto.gridOptions.headerFilterDto.allowSearch,
timeout: gridDto.gridOptions.headerFilterDto.searchTimeout,
}}
/>
2026-07-16 08:19:17 +00:00
<Selection
mode={selectionOptions?.mode}
allowSelectAll={selectionOptions?.allowSelectAll}
selectAllMode={selectionOptions?.selectAllMode}
showCheckBoxesMode={selectionOptions?.showCheckBoxesMode}
/>
<Paging defaultPageSize={gridDto.gridOptions.pageSize ?? 20} />
<Pager
visible={gridDto.gridOptions.pagerOptionDto?.visible}
allowedPageSizes={gridDto.gridOptions.pagerOptionDto?.allowedPageSizes
?.split(',')
.map(Number)}
showPageSizeSelector={gridDto.gridOptions.pagerOptionDto?.showPageSizeSelector}
2026-07-16 14:01:18 +00:00
showInfo={gridDto.gridOptions.pagerOptionDto?.showInfo}
infoText={gridDto.gridOptions.pagerOptionDto?.infoText}
2026-07-16 08:19:17 +00:00
showNavigationButtons={gridDto.gridOptions.pagerOptionDto?.showNavigationButtons}
displayMode={gridDto.gridOptions.pagerOptionDto?.displayMode}
/>
<ColumnChooser
enabled={gridDto.gridOptions.columnOptionDto?.columnChooserEnabled}
mode={gridDto.gridOptions.columnOptionDto?.columnChooserMode}
/>
2026-07-16 14:01:18 +00:00
<LoadPanel
visible={gridDto.gridOptions.pagerOptionDto?.loadPanelEnabled !== false}
message={gridDto.gridOptions.pagerOptionDto?.loadPanelText}
/>
2026-07-16 08:19:17 +00:00
</DxCardView>
{gridDto.gridOptions.subFormsDto?.length > 0 &&
gridDto.gridOptions.subFormsListFormType === SubFormTabTypeEnum.List && (
<>
<hr className="my-2" />
<SubForms gridDto={gridDto} formData={formData} level={level ?? 0} />
</>
)}
<Dialog
width={1000}
isOpen={filterData.isImportModalOpen || false}
onClose={() => filterData.setIsImportModalOpen(false)}
onRequestClose={() => filterData.setIsImportModalOpen(false)}
>
<Dialog.Header className="-mt-5 border-b border-gray-200 p-2 dark:border-gray-700">
<h2 className="text-lg font-semibold">
{translate('::ListForms.ListForm.ImportManager')}
</h2>
</Dialog.Header>
<Dialog.Body className="mt-2 flex flex-col">
<ImportDashboard gridDto={gridDto} />
</Dialog.Body>
</Dialog>
</div>
)}
<Dialog
isOpen={toolbarModalData?.open || false}
onClose={() => setToolbarModalData(undefined)}
onRequestClose={() => setToolbarModalData(undefined)}
>
{toolbarModalData?.content}
</Dialog>
<GridFilterDialogs gridRef={cardViewRef as any} listFormCode={listFormCode} {...filterData} />
{notePanelTarget && (
<NotePanel
isVisible
entityName={notePanelTarget.entityName}
entityId={notePanelTarget.entityId}
onToggle={() => setNotePanelTarget(null)}
/>
)}
</Container>
)
}
export default CardView