diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs index 2adaecf..7dd339d 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/GridOptionsDto/LayoutDto.cs @@ -3,6 +3,7 @@ public class LayoutDto { public bool Grid { get; set; } = true; + public bool Card { get; set; } = true; public bool Pivot { get; set; } = true; public bool Chart { get; set; } = true; public bool Tree { get; set; } = true; diff --git a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs index 494a7d2..ea5b1ac 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/ListForms/Wizard/ListFormWizardDto.cs @@ -22,6 +22,7 @@ public class ListFormWizardDto public string DefaultLayout { get; set; } public bool Grid { get; set; } + public bool Card { get; set; } public bool Pivot { get; set; } public bool Tree { get; set; } public bool Chart { get; set; } diff --git a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs index 84604cb..14007c8 100644 --- a/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs +++ b/api/src/Sozsoft.Platform.Application/ListForms/ListFormWizardAppService.cs @@ -281,7 +281,7 @@ public class ListFormWizardAppService( ExportJson = WizardConsts.DefaultExportJson, IsSubForm = false, ShowNote = input.SubForms.Count > 0 || input.WorkflowCriteria.Count > 0, - LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), + LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Card, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), CultureName = LanguageCodes.En, ListFormCode = input.ListFormCode, Name = nameLangKey, diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 90d3364..e6a9f73 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -4680,6 +4680,12 @@ "en": "Grid Layout", "tr": "Liste Düzeni" }, + { + "resourceName": "Platform", + "key": "ListForms.ListFormEdit.DetailsLayoutDto.CardLayout", + "en": "Card Layout", + "tr": "Kart Düzeni" + }, { "resourceName": "Platform", "key": "ListForms.ListFormEdit.DetailsLayoutDto.PivotLayout", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs index 1e30caa..e2123ee 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/ListFormSeeder_DefaultJsons.cs @@ -67,6 +67,7 @@ public static class ListFormSeeder_DefaultJsons public static string DefaultLayoutJson(string DefaultLayout = "grid") => JsonSerializer.Serialize(new LayoutDto() { Grid = true, + Card = true, Pivot = true, Chart = true, Tree = true, diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs b/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs index 7cc5d0c..0d4fd34 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/WizardDataSeeder.cs @@ -393,7 +393,7 @@ public class WizardDataSeeder : IDataSeedContributor, ITransientDependency ExportJson = WizardConsts.DefaultExportJson, IsSubForm = false, ShowNote = input.SubForms.Count > 0 || input.WorkflowCriteria.Count > 0, - LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), + LayoutJson = WizardConsts.DefaultLayoutJson(input.DefaultLayout, input.Grid, input.Card, input.Pivot, input.Tree, input.Chart, input.Gantt, input.Scheduler), CultureName = LanguageCodes.En, ListFormCode = input.ListFormCode, Name = nameLangKey, diff --git a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs index 19b9f37..97d88e4 100644 --- a/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs +++ b/api/src/Sozsoft.Platform.Domain.Shared/WizardConsts.cs @@ -84,9 +84,10 @@ public static class WizardConsts Margin = 10 }); - public static string DefaultLayoutJson(string DefaultLayout = "grid", bool Grid = true, bool Pivot = true, bool Chart = true, bool Tree = true, bool Gantt = true, bool Scheduler = true) => JsonSerializer.Serialize(new + public static string DefaultLayoutJson(string DefaultLayout = "grid", bool Grid = true, bool Card = true, bool Pivot = true, bool Chart = true, bool Tree = true, bool Gantt = true, bool Scheduler = true) => JsonSerializer.Serialize(new { Grid = Grid, + Card = Card, Pivot = Pivot, Chart = Chart, Tree = Tree, diff --git a/ui/src/index.css b/ui/src/index.css index 41192b5..e6372fe 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -4,6 +4,22 @@ body { font-size: 14px; /* body’ye font-size uygulanmaz */ } +.list-card-view.dx-cardview { + padding: 0; + border-radius: 0; + background-color: transparent; +} + +.list-card-view .dx-cardview-card, +.list-card-view .dx-cardview-card-header .dx-toolbar, +.list-card-view .dx-cardview-card-footer { + border-radius: 0 !important; +} + +.list-card-view .dx-toolbar .dx-button-text { + text-transform: none !important; +} + .dx-form-group-with-caption > .dx-form-group-content { padding-top: 2px !important; } diff --git a/ui/src/proxy/admin/wizard/models.ts b/ui/src/proxy/admin/wizard/models.ts index dfff23d..04630f1 100644 --- a/ui/src/proxy/admin/wizard/models.ts +++ b/ui/src/proxy/admin/wizard/models.ts @@ -1,9 +1,9 @@ -import { SelectCommandTypeEnum } from '@/proxy/form/models' import { ListFormWorkflowCriteriaDto, SubFormDto, WidgetEditDto, WorkflowDto, + SelectCommandTypeEnum } from '@/proxy/form/models' import { ListViewLayoutType } from '@/views/admin/listForm/edit/types' @@ -39,6 +39,7 @@ export interface ListFormWizardDto { defaultLayout: ListViewLayoutType grid: boolean + card: boolean pivot: boolean tree: boolean chart: boolean diff --git a/ui/src/proxy/form/models.ts b/ui/src/proxy/form/models.ts index fa03677..4288f08 100644 --- a/ui/src/proxy/form/models.ts +++ b/ui/src/proxy/form/models.ts @@ -951,6 +951,7 @@ export interface ListFormWorkflowCriteriaDto { export interface LayoutDto { grid: boolean + card: boolean pivot: boolean tree: boolean chart: boolean diff --git a/ui/src/views/admin/listForm/edit/FormTabDetails.tsx b/ui/src/views/admin/listForm/edit/FormTabDetails.tsx index 9c5cbb5..105990a 100644 --- a/ui/src/views/admin/listForm/edit/FormTabDetails.tsx +++ b/ui/src/views/admin/listForm/edit/FormTabDetails.tsx @@ -26,6 +26,7 @@ const schema = Yup.object().shape({ defaultLayout: Yup.string(), layoutDto: Yup.object().shape({ grid: Yup.boolean(), + card: Yup.boolean(), pivot: Yup.boolean(), chart: Yup.boolean(), tree: Yup.boolean(), @@ -312,6 +313,22 @@ function FormTabDetails( /> + + + + + + + + { + previewTarget = null + if (previewElement) { + previewElement.style.opacity = '0' + previewElement.style.display = 'none' + } +} + +const bindPreviewDismissListeners = () => { + if (previewListenersBound) return + previewListenersBound = true + + document.addEventListener( + 'pointermove', + (event) => { + const target = event.target as Node | null + if ( + previewTarget && + (!previewTarget.isConnected || !target || !previewTarget.contains(target)) + ) { + hidePreviewElement() + } + }, + { passive: true }, + ) + document.addEventListener('scroll', hidePreviewElement, true) + window.addEventListener('blur', hidePreviewElement) + document.addEventListener('visibilitychange', () => { + if (document.hidden) hidePreviewElement() + }) +} const getPreviewElement = () => { if (!previewElement) { @@ -31,6 +65,7 @@ const getPreviewElement = () => { element.appendChild(image) document.body.appendChild(element) previewElement = element + bindPreviewDismissListeners() } return previewElement @@ -38,6 +73,7 @@ const getPreviewElement = () => { export const showImageHoverPreview = (src: string, event: React.MouseEvent) => { const element = getPreviewElement() + previewTarget = event.currentTarget const image = element.querySelector('img') as HTMLImageElement image.onerror = () => { image.onerror = null @@ -65,13 +101,11 @@ export const showImageHoverPreview = (src: string, event: React.MouseEvent { - if (previewElement) { - previewElement.style.opacity = '0' - previewElement.style.display = 'none' - } + hidePreviewElement() } export const openImageInNewTab = (url: string) => { + hidePreviewElement() if (!url.startsWith('data:image/')) { window.open(url, '_blank', 'noopener,noreferrer') return diff --git a/ui/src/views/list/CardView.tsx b/ui/src/views/list/CardView.tsx new file mode 100644 index 0000000..39d05ce --- /dev/null +++ b/ui/src/views/list/CardView.tsx @@ -0,0 +1,1234 @@ +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' +import { 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 { 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 + gridDto?: GridDto +} + +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> }, +): 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, 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 +} + +const lookupDisplayValuesEqual = ( + left: Record>, + right: Record>, +) => { + 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[], + knownValues: Record> = {}, +) => { + const entries = await Promise.all( + columns + .filter((column) => column.colData?.lookupDto?.cascadeParentFields) + .map(async (column) => { + const lookup = column.colData.lookupDto + const parentFields = lookup.cascadeParentFields + .split(',') + .map((field: string) => field.trim()) + .filter(Boolean) + const parentValueLists = parentFields.map((field: string) => { + 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[]>( + (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 } | undefined)?.store + return ((await store?.load?.()) ?? []) as Record[] + }), + ) + const values = new Map() + 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> +} + +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) => { + const cardStore = store as any + const originalLoad = cardStore._loadFunc?.bind(cardStore) + if (!originalLoad) return store + + const cachedLoads = new Map() + const pendingLoads = new Map>() + const pendingTotalCounts = new Map>() + const totalCounts = new Map() + 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 useMobileEditPopup = isMobileViewport() || isTouchLikeDevice() + const cardViewRef = useRef() + const searchParamsRef = useRef(searchParams) + const defaultSearchFilterRef = useRef(searchParams?.get('filter') ?? null) + const dataSourceSearchParamsRef = useRef() + 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('view') + const [isPopupFullScreen, setIsPopupFullScreen] = useState(false) + const [formData, setFormData] = useState>() + const formDataRef = useRef>() + formDataRef.current = formData + const lookupDisplayValuesRef = useRef>>({}) + const [lookupReadyState, setLookupReadyState] = useState({ listFormCode, ready: false }) + const [notePanelTarget, setNotePanelTarget] = useState<{ + entityName: string + entityId: string + } | null>(null) + const [extraFilters, setExtraFilters] = useState(() => + 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((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]) + const listFormColumnsRef = useRef({ + current: { + instance: () => ({ refresh: refreshData }), + }, + }).current + + const openNotePanel = useCallback( + (rowData: Record) => { + 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 listColumnsCacheRef = useRef<{ gridDto?: GridDto; columns: any[] }>({ columns: [] }) + if (listColumnsCacheRef.current.gridDto !== gridDto) { + listColumnsCacheRef.current = { + gridDto, + columns: flattenColumns(getBandedColumns()), + } + } + const listColumns = listColumnsCacheRef.current.columns + const cardColumnsCacheRef = useRef<{ + gridDto?: GridDto + columns: CardViewTypes.ColumnProperties[] + }>({ columns: [] }) + if (cardColumnsCacheRef.current.gridDto !== gridDto) { + cardColumnsCacheRef.current = { + gridDto, + 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 dataField = + templateData?.dataField ?? + templateData?.item?.dataField ?? + templateData?.editorOptions?.name ?? + templateData?.name + const column = listColumnsCacheRef.current.columns.find( + (candidate) => + candidate.dataField?.toLowerCase() === String(dataField ?? '').toLowerCase(), + ) + const component = templateData?.component + const currentFormData = component?.option?.('formData') ?? formDataRef.current ?? {} + + return ( + { + if (!dataField) return + component?.updateData?.(dataField, value) + formDataRef.current = { ...currentFormData, [dataField]: value } + }} + /> + ) + }, []) + + const renderImageViewerEditor = useCallback( + (templateData: any) => ( + + ), + [], + ) + + 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() + 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 + setFormData(nextData) + }, + [cascadeChildrenByParent], + ) + + const applyLookupDisplayValues = useCallback( + (values: Record>) => { + 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(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), + ]).then(([values, cascadeValues]) => + applyLookupDisplayValues({ ...values, ...cascadeValues }), + ) + }, [applyLookupDisplayValues, getLookupColumns, loadLookupDisplayValues]) + + const { createSelectDataSource } = useListFormCustomDataSource({ gridRef: cardViewRef }) + const dataSource = useMemo | 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 = {} + 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', + dataField: item.dataField, + name: item.dataField, + 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]) + + 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 onSelectionChanged = useCallback( + (event: CardViewTypes.SelectionChangedEvent) => { + const selectedCardsData = event.selectedCardsData as Record[] + 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 ( + + {!isSubForm && ( + + )} + {!gridDto || + !dataSource || + lookupReadyState.listFormCode !== listFormCode || + !lookupReadyState.ready ? ( +
+ {translate('::App.Loading')} +
+ ) : ( +
+ 0 ? gridDto.gridOptions.height : undefined} + width={gridDto.gridOptions.width || '100%'} + cardMinWidth={280} + 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) => { + setFormData((event.data as Record) ?? {}) + setMode('new') + setIsPopupFullScreen( + useMobileEditPopup || (editingOptions?.popup?.fullScreen ?? false), + ) + }} + onEditingStart={(event) => { + setFormData((event.data as Record) ?? {}) + setMode('edit') + setIsPopupFullScreen( + useMobileEditPopup || (editingOptions?.popup?.fullScreen ?? false), + ) + }} + onEditCanceled={() => { + setMode('view') + setIsPopupFullScreen(false) + }} + onSaved={() => { + setMode('view') + setIsPopupFullScreen(false) + }} + onDataErrorOccurred={(event) => { + toast.push( + + {String(event.error?.message ?? event.error ?? 'Select error')} + , + { placement: 'bottom-end' }, + ) + }} + onCardInserted={() => refreshParentData?.()} + onCardUpdated={() => refreshParentData?.()} + onCardRemoved={() => refreshParentData?.()} + > + + 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), + }, + }, + ], + }} + /> + 0}> + {orderedToolbarData.map((item, index) => ( + + ))} + {gridDto.gridOptions.extraFilterDto?.length ? ( + ( + + )} + /> + ) : null} + + + + + + + + + + + + + {gridDto.gridOptions.subFormsDto?.length > 0 && + gridDto.gridOptions.subFormsListFormType === SubFormTabTypeEnum.List && ( + <> +
+ + + )} + + filterData.setIsImportModalOpen(false)} + onRequestClose={() => filterData.setIsImportModalOpen(false)} + > + +

+ {translate('::ListForms.ListForm.ImportManager')} +

+
+ + + +
+
+ )} + + setToolbarModalData(undefined)} + onRequestClose={() => setToolbarModalData(undefined)} + > + {toolbarModalData?.content} + + + {notePanelTarget && ( + setNotePanelTarget(null)} + /> + )} +
+ ) +} + +export default CardView diff --git a/ui/src/views/list/List.tsx b/ui/src/views/list/List.tsx index 94e62c1..474de86 100644 --- a/ui/src/views/list/List.tsx +++ b/ui/src/views/list/List.tsx @@ -15,6 +15,7 @@ import { ListViewLayoutType } from '../admin/listForm/edit/types' import { FaChartArea, + FaThLarge, FaList, FaSitemap, FaTable, @@ -26,6 +27,7 @@ import { 🔥 LAZY VIEW IMPORTS ======================= */ const Grid = React.lazy(() => import('./Grid')) +const CardView = React.lazy(() => import('./CardView')) const Pivot = React.lazy(() => import('./Pivot')) const Tree = React.lazy(() => import('./Tree')) const Chart = React.lazy(() => import('./Chart')) @@ -34,6 +36,7 @@ const SchedulerView = React.lazy(() => import('./SchedulerView')) function isLayoutValid(dto: GridDto, layout: ListViewLayoutType | undefined): boolean { if (!layout || layout === 'grid') return true + if (layout === 'card') return true if (layout === 'pivot') return !!dto.gridOptions?.layoutDto?.pivot if (layout === 'chart') return !!dto.gridOptions?.layoutDto?.chart if (layout === 'tree') @@ -125,6 +128,7 @@ const List: React.FC = () => { const preload = useMemo( () => ({ grid: () => import('./Grid'), + card: () => import('./CardView'), pivot: () => import('./Pivot'), tree: () => import('./Tree'), chart: () => import('./Chart'), @@ -200,6 +204,16 @@ const List: React.FC = () => { onMouseEnter={() => preload.grid()} > + {gridDto.gridOptions.layoutDto.card && ( + + )} + {gridDto.gridOptions.layoutDto.pivot && (