import { attachClosestEdge, type Edge, extractClosestEdge, } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, dropTargetForElements, } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { disableNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/disable-native-drag-preview'; import clsx from 'clsx'; import Fuse, { type FuseResultMatch } from 'fuse.js'; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styles from './table-config.module.css'; import { ItemTableListColumnConfig } from '/@/renderer/components/item-list/types'; import { ListConfigBooleanControl, ListConfigTable, } from '/@/renderer/features/shared/components/list-config-menu'; import { type DataTableProps, ItemListSettings, useSettingsStore, useSettingsStoreActions, } from '/@/renderer/store'; import { ActionIcon, ActionIconGroup } from '/@/shared/components/action-icon/action-icon'; import { Badge } from '/@/shared/components/badge/badge'; import { Checkbox } from '/@/shared/components/checkbox/checkbox'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Slider } from '/@/shared/components/slider/slider'; import { Stack } from '/@/shared/components/stack/stack'; import { TextInput } from '/@/shared/components/text-input/text-input'; import { Text } from '/@/shared/components/text/text'; import { Tooltip } from '/@/shared/components/tooltip/tooltip'; import { useDebouncedState } from '/@/shared/hooks/use-debounced-state'; import { dndUtils, DragData, DragOperation, DragTarget } from '/@/shared/types/drag-and-drop'; import { ItemListKey, ListPaginationType } from '/@/shared/types/types'; interface TableConfigProps { enablePinColumnButtons?: boolean; extraOptions?: { component: React.ReactNode; id: string; label: string; }[]; listKey: ItemListKey; optionsConfig?: { [key: string]: { disabled?: boolean; hidden?: boolean; }; }; tableColumnsData: { label: string; value: string }[]; tableKey?: 'detail' | 'main'; } export const TableConfig = ({ enablePinColumnButtons = true, extraOptions, listKey, optionsConfig, tableColumnsData, tableKey = 'main', }: TableConfigProps) => { const { t } = useTranslation(); const list = useSettingsStore((state) => state.lists[listKey]) as ItemListSettings; const { setList } = useSettingsStoreActions(); const table = tableKey === 'detail' ? (list?.detail ?? list?.table) : list?.table; const setTableUpdate = useCallback( (patch: Partial) => { if (tableKey === 'detail') { setList(listKey, { detail: patch } as Parameters< ReturnType['setList'] >[1]); } else { setList(listKey, { table: patch }); } }, [listKey, setList, tableKey], ); const advancedSettings = useMemo(() => { const allOptions = [ { component: ( setList(listKey, { pagination: value as ListPaginationType }) } size="sm" value={list.pagination} w="100%" /> ), id: 'pagination', label: t('table.config.general.pagination', { postProcess: 'sentenceCase' }), size: 'sm', }, { component: ( setList(listKey, { itemsPerPage: value })} restrictToMarks w="100%" /> ), id: 'itemsPerPage', label: ( {t('table.config.general.pagination_itemsPerPage', { postProcess: 'sentenceCase', })} {list.itemsPerPage} ), }, { component: ( setTableUpdate({ size: value as 'compact' | 'default' | 'large', }) } size="sm" value={table?.size ?? 'default'} w="100%" /> ), id: 'size', label: t('table.config.general.size', { postProcess: 'titleCase', }), }, { component: ( setTableUpdate({ enableHeader: e })} value={table.enableHeader} /> ), id: 'enableHeader', label: t('table.config.general.showHeader', { postProcess: 'sentenceCase', }), }, { component: ( setTableUpdate({ enableRowHoverHighlight: e })} value={table.enableRowHoverHighlight} /> ), id: 'enableRowHoverHighlight', label: t('table.config.general.rowHoverHighlight', { postProcess: 'sentenceCase', }), }, { component: ( setTableUpdate({ enableAlternateRowColors: e })} value={table.enableAlternateRowColors} /> ), id: 'enableAlternateRowColors', label: t('table.config.general.alternateRowColors', { postProcess: 'sentenceCase', }), }, { component: ( setTableUpdate({ enableHorizontalBorders: e })} value={table.enableHorizontalBorders} /> ), id: 'enableHorizontalBorders', label: t('table.config.general.horizontalBorders', { postProcess: 'sentenceCase', }), }, { component: ( setTableUpdate({ enableVerticalBorders: e })} value={table.enableVerticalBorders} /> ), id: 'enableVerticalBorders', label: t('table.config.general.verticalBorders', { postProcess: 'sentenceCase', }), }, { component: ( setTableUpdate({ autoFitColumns: e })} value={ tableKey === 'main' ? (table as DataTableProps).autoFitColumns : false } /> ), id: 'autoFitColumns', label: t('table.config.general.autoFitColumns', { postProcess: 'sentenceCase' }), }, ...(extraOptions || []), ]; // Filter and apply config (hidden/disabled) return allOptions .map((option) => { const config = optionsConfig?.[option.id]; if (config?.hidden) { return null; } return option; }) .filter((option): option is NonNullable => option !== null); }, [ t, list.pagination, list.itemsPerPage, table, tableKey, extraOptions, setList, listKey, setTableUpdate, optionsConfig, ]); return ( <> setTableUpdate({ columns })} value={table.columns} /> ); }; const TableColumnConfig = ({ data, enablePinColumnButtons, onChange, value, }: { data: { label: string; value: string }[]; enablePinColumnButtons: boolean; onChange: (value: ItemTableListColumnConfig[]) => void; value: ItemTableListColumnConfig[]; }) => { const { t } = useTranslation(); const valueRef = useRef(value); const onChangeRef = useRef(onChange); useLayoutEffect(() => { valueRef.current = value; onChangeRef.current = onChange; }); const labelMap = useMemo(() => { return data.reduce( (acc, item) => { acc[item.value] = item.label; return acc; }, {} as Record, ); }, [data]); const handleChangeEnabled = useCallback((item: ItemTableListColumnConfig, checked: boolean) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], isEnabled: checked }; onChangeRef.current(newValues); }, []); const handleMoveUp = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); if (index === 0) return; const newValues = [...currentValue]; [newValues[index], newValues[index - 1]] = [newValues[index - 1], newValues[index]]; onChangeRef.current(newValues); }, []); const handleMoveDown = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); if (index === currentValue.length - 1) return; const newValues = [...currentValue]; [newValues[index], newValues[index + 1]] = [newValues[index + 1], newValues[index]]; onChangeRef.current(newValues); }, []); const handlePinToLeft = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; const isPinned = newValues[index].pinned; const isPinnedLeft = isPinned === 'left'; if (isPinnedLeft) { newValues[index] = { ...newValues[index], pinned: null }; } else { newValues[index] = { ...newValues[index], pinned: 'left' }; } onChangeRef.current(newValues); }, []); const handlePinToRight = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; const isPinned = newValues[index].pinned; const isPinnedRight = isPinned === 'right'; if (isPinnedRight) { newValues[index] = { ...newValues[index], pinned: null }; } else { newValues[index] = { ...newValues[index], pinned: 'right' }; } onChangeRef.current(newValues); }, []); const handleAlignLeft = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], align: 'start' }; onChangeRef.current(newValues); }, []); const handleAlignCenter = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], align: 'center' }; onChangeRef.current(newValues); }, []); const handleAlignRight = useCallback((item: ItemTableListColumnConfig) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], align: 'end' }; onChangeRef.current(newValues); }, []); const handleAutoSize = useCallback((item: ItemTableListColumnConfig, checked: boolean) => { const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], autoSize: checked }; onChangeRef.current(newValues); }, []); const handleRowWidth = useCallback( (item: ItemTableListColumnConfig, number: number | string) => { if (typeof number !== 'number') { number = 0; } if (number < 0) { number = 0; } if (number > 2000) { number = 2000; } const currentValue = valueRef.current; const index = currentValue.findIndex((v) => v.id === item.id); const newValues = [...currentValue]; newValues[index] = { ...newValues[index], width: number }; onChangeRef.current(newValues); }, [], ); const [searchColumns, setSearchColumns] = useDebouncedState('', 300); const fuse = useMemo(() => { return new Fuse(value, { getFn: (obj) => { return labelMap[obj.id] || ''; }, includeMatches: true, includeScore: true, keys: ['id', 'label'], threshold: 0.3, }); }, [value, labelMap]); const filteredColumns = useMemo(() => { if (!searchColumns.trim()) { return value.map((item) => ({ item, matches: null })); } const results = fuse.search(searchColumns); const resultMap = new Map(results.map((result) => [result.item.id, result.matches])); return value.map((item) => ({ item, matches: resultMap.get(item.id) || null, })); }, [value, searchColumns, fuse]); const handleReorder = useCallback((idFrom: string, idTo: string, edge: Edge | null) => { const currentValue = valueRef.current; const idList = currentValue.map((item) => item.id); const newIdOrder = dndUtils.reorderById({ edge, idFrom, idTo, list: idList, }); // Map the new ID order back to full items const newOrder = newIdOrder.map((id) => currentValue.find((item) => item.id === id)!); onChangeRef.current(newOrder); }, []); return ( {t('common.tableColumns', { postProcess: 'sentenceCase' })} setSearchColumns(e.currentTarget.value)} placeholder={t('common.search', { postProcess: 'sentenceCase', })} size="xs" />
{filteredColumns.map(({ item, matches }) => ( ))}
); }; const DragHandle = ({ dragHandleRef, }: { dragHandleRef: React.RefObject; }) => { return ( } size="xs" style={{ cursor: 'grab' }} variant="default" /> ); }; const TableColumnItem = memo( ({ enablePinColumnButtons, handleAlignCenter, handleAlignLeft, handleAlignRight, handleAutoSize, handleChangeEnabled, handleMoveDown, handleMoveUp, handlePinToLeft, handlePinToRight, handleReorder, handleRowWidth, item, label, matches, }: { enablePinColumnButtons: boolean; handleAlignCenter: (item: ItemTableListColumnConfig) => void; handleAlignLeft: (item: ItemTableListColumnConfig) => void; handleAlignRight: (item: ItemTableListColumnConfig) => void; handleAutoSize: (item: ItemTableListColumnConfig, checked: boolean) => void; handleChangeEnabled: (item: ItemTableListColumnConfig, checked: boolean) => void; handleMoveDown: (item: ItemTableListColumnConfig) => void; handleMoveUp: (item: ItemTableListColumnConfig) => void; handlePinToLeft: (item: ItemTableListColumnConfig) => void; handlePinToRight: (item: ItemTableListColumnConfig) => void; handleReorder: (idFrom: string, idTo: string, edge: Edge | null) => void; handleRowWidth: (item: ItemTableListColumnConfig, number: number | string) => void; item: ItemTableListColumnConfig; label: string; matches: null | readonly FuseResultMatch[]; }) => { const { t } = useTranslation(); const ref = useRef(null); const dragHandleRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const [isDraggedOver, setIsDraggedOver] = useState(null); useEffect(() => { if (!ref.current || !dragHandleRef.current) { return; } return combine( draggable({ element: dragHandleRef.current, getInitialData: () => { const data = dndUtils.generateDragData({ id: [item.id], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }); return data; }, onDragStart: () => { setIsDragging(true); }, onDrop: () => { setIsDragging(false); }, onGenerateDragPreview: (data) => { disableNativeDragPreview({ nativeSetDragImage: data.nativeSetDragImage }); }, }), dropTargetForElements({ canDrop: (args) => { const data = args.source.data as unknown as DragData; const isSelf = (args.source.data.id as string[])[0] === item.id; return ( dndUtils.isDropTarget(data.type, [DragTarget.TABLE_COLUMN]) && !isSelf ); }, element: ref.current, getData: ({ element, input }) => { const data = dndUtils.generateDragData({ id: [item.id], operation: [DragOperation.REORDER], type: DragTarget.TABLE_COLUMN, }); return attachClosestEdge(data, { allowedEdges: ['top', 'bottom'], element, input, }); }, onDrag: (args) => { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); setIsDraggedOver(closestEdgeOfTarget); }, onDragLeave: () => { setIsDraggedOver(null); }, onDrop: (args) => { const closestEdgeOfTarget: Edge | null = extractClosestEdge(args.self.data); const from = args.source.data.id as string[]; const to = args.self.data.id as string[]; handleReorder(from[0], to[0], closestEdgeOfTarget); setIsDraggedOver(null); }, }), ); }, [item.id, handleReorder]); return (
0, })} ref={ref} > handleChangeEnabled(item, e.currentTarget.checked)} size="sm" /> handleMoveUp(item)} size="xs" tooltip={{ label: t('table.config.general.moveUp', { postProcess: 'sentenceCase', }), }} variant="subtle" /> handleMoveDown(item)} size="xs" tooltip={{ label: t('table.config.general.moveDown', { postProcess: 'sentenceCase', }), }} variant="subtle" /> {enablePinColumnButtons && ( handlePinToLeft(item)} size="xs" tooltip={{ label: t('table.config.general.pinToLeft', { postProcess: 'sentenceCase', }), }} variant={item.pinned === 'left' ? 'filled' : 'subtle'} /> handlePinToRight(item)} size="xs" tooltip={{ label: t('table.config.general.pinToRight', { postProcess: 'sentenceCase', }), }} variant={item.pinned === 'right' ? 'filled' : 'subtle'} /> )} handleAlignLeft(item)} size="xs" tooltip={{ label: t('table.config.general.alignLeft', { postProcess: 'sentenceCase', }), }} variant={item.align === 'start' ? 'filled' : 'subtle'} /> handleAlignCenter(item)} size="xs" tooltip={{ label: t('table.config.general.alignCenter', { postProcess: 'sentenceCase', }), }} variant={item.align === 'center' ? 'filled' : 'subtle'} /> handleAlignRight(item)} size="xs" tooltip={{ label: t('table.config.general.alignRight', { postProcess: 'sentenceCase', }), }} variant={item.align === 'end' ? 'filled' : 'subtle'} /> {item.pinned === null && ( handleAutoSize(item, e.currentTarget.checked) } size="xs" /> )} } max={2000} min={0} onChange={(value) => handleRowWidth(item, value)} size="xs" step={10} stepHoldDelay={300} stepHoldInterval={100} value={item.width} variant="subtle" />
); }, (prevProps, nextProps) => { // Custom comparison function for better memoization return ( prevProps.enablePinColumnButtons === nextProps.enablePinColumnButtons && prevProps.item.id === nextProps.item.id && prevProps.item.isEnabled === nextProps.item.isEnabled && prevProps.item.autoSize === nextProps.item.autoSize && prevProps.item.width === nextProps.item.width && prevProps.item.pinned === nextProps.item.pinned && prevProps.item.align === nextProps.item.align && prevProps.label === nextProps.label && prevProps.matches === nextProps.matches ); }, );