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

1371 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 {
ColumnFormatDto,
DbTypeEnum,
FieldCustomValueTypeEnum,
GridDto,
PlatformEditorTypes,
SubFormTabTypeEnum,
} from '@/proxy/form/models'
import { getList, getNextSequenceValue } from '@/services/form.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { usePermission } from '@/utils/hooks/usePermission'
import { useStoreActions, useStoreState } from '@/store'
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 type { DataType } from 'devextreme/common'
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 {
autoNumber,
extractSearchParamsFields,
getEditingFormGroups,
GridExtraFilterState,
safeJsonParse,
} from './Utils'
import {
buildEditPopupToolbarItems,
getEditPopupHeight,
getEditPopupMaxHeight,
getMobilePopupPosition,
shouldUseMobileEditPopup,
} from './shared/editPopup'
import { getValueByField, resolveEditorType } from './shared/editingForm'
import { createEditingFormHandlers } from './shared/editingFormScripts'
import { flattenColumns } from './shared/columns'
import { getInitialExtraFilters, mergeFilterTriplets } from './shared/hooks'
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'
import WidgetGroup from '@/components/ui/Widget/WidgetGroup'
interface CardViewProps {
listFormCode: string
searchParams?: URLSearchParams
isSubForm?: boolean
level?: number
refreshData?: () => Promise<void>
gridDto?: GridDto
}
const cardColumnCountOptions = [1, 2, 3, 4, 5] as const
const defaultCardColumnCount = 3
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')
const isHtmlColumn = column.colData?.editorType2 === 'dxHtmlEditor'
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
}
: isHtmlColumn
? (templateOptions: any) => {
const templateData = templateOptions?.model ?? templateOptions
const container = document.createElement('div')
container.innerHTML = String(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 getEditingFieldName = (dataField?: string | null) =>
String(dataField ?? '')
.split(':')[0]
.trim()
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 loadCascadeLookupDisplayValues = async (
columns: any[],
rows: Record<string, any>[],
) => {
const entries = await Promise.all(
columns
.filter((column) => column.colData?.lookupDto?.cascadeParentFields)
.map(async (column) => {
const lookup = column.colData.lookupDto
const parentFields: string[] = String(lookup.cascadeParentFields)
.split(',')
.map((field) => field.trim())
.filter(Boolean)
const contexts = [
...new Map(
rows.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
? pendingCount.then((count) => (count !== undefined ? count : originalTotalCount(options)))
: 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)
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)
const useMobileEditPopup = shouldUseMobileEditPopup()
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 widgetGroupRef = useRef<HTMLDivElement>(null)
const formDataRef = useRef<Record<string, any>>()
useEffect(() => {
formDataRef.current = formData
}, [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(
() =>
mergeFilterTriplets([
...extractSearchParamsFields(
safeJsonParse(defaultSearchFilterRef.current, null, 'Search filter parse error:'),
),
...extraFilters
.filter((filter) => !!filter.value)
.map(
(filter) => [filter.fieldName, filter.operator, filter.value] as [string, string, any],
),
]),
[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])
const setCardColumnCount = useCallback(
(cardColumnCount: number) => {
setListState({
listFormCode,
layout: savedListState?.layout ?? 'card',
cardColumnCount,
})
},
[listFormCode, savedListState?.layout, setListState],
)
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,
})
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
) {
listColumnsCacheRef.current = {
gridDto,
localizationRevision,
columns: flattenColumns(getBandedColumns() ?? []),
}
}
const listColumns = listColumnsCacheRef.current.columns
const cardColumnsCacheRef = useRef<{
listColumns?: any[]
columns: CardViewTypes.ColumnProperties[]
}>({ columns: [] })
if (cardColumnsCacheRef.current.listColumns !== listColumns) {
cardColumnsCacheRef.current = {
listColumns,
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) => {
const configuredDataField =
templateData?.dataField ??
templateData?.item?.dataField ??
templateData?.editorOptions?.name ??
templateData?.name
const dataField = getEditingFieldName(configuredDataField)
const column = listColumnsCacheRef.current.columns.find(
(candidate) => candidate.dataField?.toLowerCase() === String(dataField ?? '').toLowerCase(),
)
const component = templateData?.component
const currentFormData = component?.option?.('formData') ?? formDataRef.current ?? {}
const editorValue = templateData?.editorOptions?.value
return (
<ImageUploadEditorComponent
{...templateData}
column={column}
value={
editorValue !== undefined ? editorValue : getValueByField(currentFormData, dataField)
}
setValue={(value: any) => {
if (!dataField) return
if (typeof templateData?.editorOptions?.onValueChanged === 'function') {
templateData.editorOptions.onValueChanged({ value })
} else {
component?.updateData?.(dataField, value)
}
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)
const disabled = parentFields?.some((field: string) => !getValueByField(nextData, field))
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
},
[cascadeChildrenByParent],
)
// Grid/Tree/Scheduler ile aynı editorScript altyapısı; CardView'ın New ve Edit
// popup formlarında da script'ler çalışsın diye bağlanır.
const editingFormDataRef = useRef<Record<string, any>>({})
const editingFormInstanceRef = useRef<any>()
const editorScriptHandlers = useMemo(
() =>
createEditingFormHandlers({
getGridDto: () => gridDto,
getGridInstance: () => cardViewRef.current?.instance?.(),
editingFormDataRef,
editingFormInstanceRef,
}),
[gridDto],
)
const onEditingFormContentReady = useCallback(
(event: any) => {
editingFormInstanceRef.current = event.component
editorScriptHandlers.onContentReady(event)
},
[editorScriptHandlers],
)
const onEditingFormDataChanged = useCallback(
(event: any) => {
// Önce cascade temizliği, sonra alanın kendi script'i.
onEditingFormFieldChanged(event)
editorScriptHandlers.onFieldDataChanged(event)
},
[editorScriptHandlers, onEditingFormFieldChanged],
)
const applyLookupDisplayValues = useCallback((values: Record<string, Map<string, string>>) => {
if (lookupDisplayValuesEqual(lookupDisplayValuesRef.current, values)) return
lookupDisplayValuesRef.current = values
}, [])
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((values) => {
if (!active) return
applyLookupDisplayValues(values)
setLookupReadyState((current) =>
current.listFormCode === listFormCode && current.ready
? current
: { listFormCode, ready: true },
)
})
.catch((error) => {
console.error('CardView lookup preload error:', error)
if (active) {
setLookupReadyState((current) =>
current.listFormCode === listFormCode && current.ready
? current
: { 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),
]).then(([values, cascadeValues]) => applyLookupDisplayValues({ ...values, ...cascadeValues }))
}, [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: ColumnFormatDto) => {
const fieldName = item.fieldName!
const column = item
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.fieldName, 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,
}
}
// Grid ve Form'da editorOptions.placeholder kolonun PlaceHolder alanını ezer;
// burada tersi olduğu için aynı yapılandırma CardView'da farklı davranıyordu.
if (column.placeHolder && editorOptions.placeholder === undefined) {
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: formDataRef.current ?? {} })
: lookup.dataSource,
displayExpr: lookup.displayExpr,
valueExpr: lookup.valueExpr,
}
}
return {
itemType: 'simple',
dataField: fieldName,
name: fieldName,
colSpan: item.colSpan,
label: {
text: item.fieldName!.includes(':')
? item.fieldName!.split(':')[1]
: translate('::' + (column.captionName || column.fieldName)),
},
editorType: resolveEditorType(item.editorType2),
editorOptions,
template:
item.editorType2 === PlatformEditorTypes.dxImageUpload
? imageUploadFormTemplate
: item.editorType2 === PlatformEditorTypes.dxImageViewer
? imageViewerFormTemplate
: undefined,
}
},
[gridDto, imageUploadFormTemplate, imageViewerFormTemplate, mode, translate],
)
const editingFormItems = useMemo(() => {
if (!gridDto || mode === 'view') return []
const groups = [...(getEditingFormGroups(gridDto) ?? [])].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.editOrderNo ?? 0) - (right.editOrderNo ?? 0))
.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.editOrderNo ?? 0) - (right.editOrderNo ?? 0))
.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])
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(),
})
}
})
actionsElement.appendChild(button)
})
return actionsElement
}, [])
const editingOptions = gridDto?.gridOptions.editingOptionDto
const selectionOptions = gridDto?.gridOptions.selectionDto
const onInitNewCard = useCallback(
(event: CardViewTypes.InitNewCardEvent<Record<string, any>>) => {
const nextFormData = event.data as Record<string, any>
formDataRef.current = nextFormData
setMode('new')
setIsPopupFullScreen(useMobileEditPopup || (editingOptions?.popup?.fullScreen ?? false))
event.promise = (async () => {
const rawFilter = searchParamsRef.current?.get('filter')
let filters: any[] = []
if (rawFilter) {
try {
filters = extractSearchParamsFields(JSON.parse(rawFilter))
} catch (error) {
console.error('Filter parse edilemedi:', error)
}
}
for (const column of gridDto?.columnFormats ?? []) {
const fieldName = column.fieldName
if (!fieldName || extraFilters.some((filter) => filter.fieldName === fieldName)) {
continue
}
if (column.defaultValue !== null && column.defaultValue !== undefined) {
if (typeof column.defaultValue === 'string' && column.defaultValue === '@AUTONUMBER') {
nextFormData[fieldName] = autoNumber()
} else if (column.defaultValueType === FieldCustomValueTypeEnum.Sequence) {
try {
const response = await getNextSequenceValue(String(column.defaultValue))
nextFormData[fieldName] = response.data
} catch (error) {
console.error('Sequence default value alınamadı:', {
fieldName,
defaultValue: column.defaultValue,
error,
})
}
} else {
nextFormData[fieldName] = column.defaultValue
}
}
const fieldMatch = filters.find(([field]) => field === fieldName)
if (!fieldMatch) continue
const value = fieldMatch[2]
switch (column.dataType as DataType) {
case 'date':
case 'datetime':
nextFormData[fieldName] = new Date(value)
break
case 'number':
nextFormData[fieldName] = Number(value)
break
case 'boolean':
nextFormData[fieldName] = value === true || value === 'true'
break
case 'object':
try {
nextFormData[fieldName] = JSON.parse(value)
} catch {
nextFormData[fieldName] = value
}
break
default:
nextFormData[fieldName] = value
break
}
}
formDataRef.current = nextFormData
setFormData({ ...nextFormData })
})()
},
[editingOptions?.popup?.fullScreen, extraFilters, gridDto, useMobileEditPopup],
)
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 (
<>
<div ref={widgetGroupRef} className={(gridDto?.widgets?.length ?? 0) > 0 ? 'mt-2' : ''}>
<WidgetGroup widgetGroups={gridDto?.widgets ?? []} />
</div>
<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
key={`CardView-${listFormCode}-${localizationRevision}`}
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}
cardsPerRow={cardColumnCount}
allowColumnReordering={gridDto.gridOptions.columnOptionDto?.allowColumnReordering}
rtlEnabled={gridDto.gridOptions.columnOptionDto?.rtlEnabled}
className="list-card-view list-view-toolbar-host"
hoverStateEnabled={gridDto.gridOptions.columnOptionDto?.hoverStateEnabled}
remoteOperations={{ paging: true, filtering: true, sorting: true }}
wordWrapEnabled={gridDto.gridOptions.rowDto?.whiteSpace !== 'nowrap'}
onContentReady={onContentReady}
onSelectionChanged={onSelectionChanged}
onInitNewCard={onInitNewCard}
onEditingStart={(event) => {
const nextFormData = (event.data as Record<string, any>) ?? {}
formDataRef.current = nextFormData
setFormData(nextFormData)
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',
onContentReady: onEditingFormContentReady,
onFieldDataChanged: onEditingFormDataChanged,
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: getEditPopupHeight(useMobileEditPopup, isPopupFullScreen),
maxHeight: getEditPopupMaxHeight(
useMobileEditPopup,
isPopupFullScreen,
editingOptions?.popup?.height,
),
fullScreen: isPopupFullScreen,
position: useMobileEditPopup
? getMobilePopupPosition()
: editingOptions?.popup?.position,
resizeEnabled: editingOptions?.popup?.resizeEnabled,
dragEnabled: editingOptions?.popup?.dragEnabled,
restorePosition: editingOptions?.popup?.restorePosition,
toolbarItems: buildEditPopupToolbarItems(translate, isPopupFullScreen, {
onSave: () => cardViewRef.current?.instance().saveEditData(),
onCancel: () => cardViewRef.current?.instance().cancelEditData(),
onToggleFullScreen: () => setIsPopupFullScreen((current) => !current),
}),
}}
/>
<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),
}}
/>
)
})}
{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')}
/>
<FilterPanel
visible={gridDto.gridOptions.filterPanelDto.visible}
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,
}}
/>
<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}
showInfo={gridDto.gridOptions.pagerOptionDto?.showInfo}
infoText={gridDto.gridOptions.pagerOptionDto?.infoText}
showNavigationButtons={gridDto.gridOptions.pagerOptionDto?.showNavigationButtons}
displayMode={gridDto.gridOptions.pagerOptionDto?.displayMode}
/>
<ColumnChooser
enabled={gridDto.gridOptions.columnOptionDto?.columnChooserEnabled}
mode={gridDto.gridOptions.columnOptionDto?.columnChooserMode}
/>
<LoadPanel
visible={gridDto.gridOptions.pagerOptionDto?.loadPanelEnabled !== false}
message={gridDto.gridOptions.pagerOptionDto?.loadPanelText}
/>
</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