/* eslint-disable react-refresh/only-export-components */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { type CellArray, CompactSelection, type DataEditorProps, type DataEditorRef, type EditableGridCell, type GetCellsThunk, type GridCell, GridCellKind, type GridColumn, type GridMouseEventArgs, type GridSelection, type HeaderClickedEventArgs, type Item, type Rectangle, } from '@glideapps/glide-data-grid'; import { IconGrid4x4 } from '@tabler/icons-react'; import { getUUID } from '@warkypublic/artemis-kit'; import { getNestedValue } from '@warkypublic/artemis-kit/object'; import { createSyncStore } from '@warkypublic/zustandsyncstore'; import { produce } from 'immer'; import { type PropsWithChildren, type ReactNode, useEffect } from 'react'; import { type MantineBetterMenuInstance, type MantineBetterMenuInstanceItem, useMantineBetterMenus, } from '../../MantineBetterMenu'; import { ColumnFilterSet, type GridlerColumn, type GridlerColumns } from './Column'; import { SortDownSprite } from './sprites/SortDown'; import { SortUpSprite } from './sprites/SortUp'; import { SpriteImage } from './sprites/SpriteImage'; export type FilterOption = { datatype?: 'array' | 'boolean' | 'date' | 'function' | 'number' | 'object' | 'string'; id: string; operator: FilterOptionOperator; value: string; value2?: string; }; export type FilterOptionOperator = | 'between' | 'contains' | 'endswith' | 'eq' | 'gt' | 'gte' | 'lt' | 'lte' | 'neq' | 'startswith'; export interface GridlerProps extends PropsWithChildren { askAPIRowNumber?: (key: string) => Promise; columns?: GridlerColumns; defaultSort?: Array; enableOddEvenRowColor?: boolean; getMenuItems?: ( id: string, storeState: GridlerState, row?: any, col?: GridlerColumn, defaultItems?: Array ) => Array; glideProps?: Partial; headerHeight?: number; height?: number | string; hideMenu?: (id: string) => void; keyField?: string; maxConcurrency?: number; onChange?: (values: Array>) => void; onMounted?: (getState: GridlerState['getState'], setState: GridlerState['setState']) => void; onUnMounted?: () => void; pageSize?: number; progressiveScroll?: boolean; RenderCell?: >( row: TRowType, colindex: number, colid: string, value: any, store: GridlerState ) => GridCell; rowHeight?: number; sections?: { bottom?: React.ReactNode; left?: React.ReactNode; right?: React.ReactNode; rightElementEnd?: React.ReactNode; rightElementStart?: React.ReactNode; top?: React.ReactNode; }; selectedRow?: number; selectMode?: 'cell' | 'row'; showMenu?: (id: string, options?: Partial) => void; tooltipBarProps?: React.HTMLAttributes; total_rows?: number; uniqueid: string; useAPIQuery?: (index: number) => Promise>>; values?: Array>; width?: number | string; } export interface GridlerState { _active_requests?: Array<{ controller: AbortController; page: number }>; _activeTooltip?: ReactNode; _events: EventTarget; _glideref?: DataEditorRef; _gridSelection?: GridSelection; _gridSelectionRows?: GridSelection['rows']; _loadingList: CompactSelection; _page_data: Record>; _scrollTimeout?: any | number; _visibleArea: Rectangle; _visiblePages: Rectangle; addError: (err: string, ...args: Array) => void; colFilters?: Array; colOrder?: Record; colSize?: Record; colSort?: Array; data?: Array; errors: Array; focused?: boolean; get: () => GridlerState; getCellContent: (cell: Item) => GridCell; getCellsForSelection: ( selection: Rectangle, abortSignal: AbortSignal ) => CellArray | GetCellsThunk; getRowBuffer: (row: number) => any; getState: (key: K) => GridlerStoreState[K]; hasLocalData: boolean; loadingData?: boolean; loadPage: (page: number, clearMode?: 'all' | 'page') => Promise; mounted: boolean; onCellEdited: (cell: Item, newVal: EditableGridCell) => void; onColumnMoved: (from: number, to: number) => void; onColumnProposeMove: (startIndex: number, endIndex: number) => boolean; onColumnResize: ( column: GridColumn, newSize: number, colIndex: number, newSizeWithGrow: number ) => void; onContextClick: (area: string, event: any, col?: number, row?: number) => void; onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void; onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void; onItemHovered: (args: GridMouseEventArgs) => void; onVisibleRegionChanged: ( r: Rectangle, tx: number, ty: number, extras: { freezeRegion?: Rectangle; freezeRegions?: readonly Rectangle[]; selected?: Item; } ) => void; pageSize: number; reload?: () => Promise; renderColumns?: GridlerColumns; setState: ( key: K, value: Partial ) => void; setStateFN: ( key: K, value: (current: GridlerStoreState[K]) => Partial ) => Promise; toCell: >(row: TRowType, col: number) => GridCell; } export type GridlerStoreState = GridlerProps & GridlerState; export type SortOption = { direction: 'asc' | 'desc'; id: string; order?: number }; const { Provider, useStore: useGridlerStore } = createSyncStore( (set, get) => ({ _events: new EventTarget(), _loadingList: CompactSelection.empty(), _page_data: {}, _visibleArea: { height: 10000, width: 1000, x: 0, y: 0 }, _visiblePages: { height: 0, width: 0, x: 0, y: 0 }, addError: (err: string, ...args: Array) => { const s = get(); console.log('Gridler Error', s.uniqueid, err, args); set( produce((state: GridlerStoreState) => { state.errors = [...state.errors, err]; }) ); }, errors: [], get: () => get(), getCellContent: (cell: Item) => { const state = get(); const [col, row] = cell; const buffer = state.getRowBuffer(row); if (buffer !== undefined) { return state.toCell(buffer, col); } return { allowOverlay: false, kind: GridCellKind.Loading, }; }, getCellsForSelection: (selection: Rectangle, _abortSignal: AbortSignal) => { return async () => { const state = get(); //const firstPage = Math.max(0, Math.floor(selection.y / state.pageSize)); //const lastPage = Math.floor((selection.y + selection.height) / state.pageSize); await state.setStateFN('_visibleArea', (_cv) => { //if (r.x === cv.x && r.y === cv.y && r.width === cv.width && r.height === cv.height) // return cv; return selection; }); const result: GridCell[][] = []; for (let y = selection.y; y < selection.y + selection.height; y++) { const row: GridCell[] = []; for (let x = selection.x; x < selection.x + selection.width; x++) { row.push(state.getCellContent([x, y])); } result.push(row); } //console.log('Gridler:Debug:getCellsForSelection', selection, result); return result as CellArray; }; }, getRowBuffer: (row: number) => { const state = get(); //Handle local data if (state.data && state.data.length > 0) { if (state.data[row] === undefined) { return { allowOverlay: false, kind: GridCellKind.Loading, }; } return state.data[row]; } //Handle remote paged data const firstPage = Math.max(0, Math.floor(row / state.pageSize)); const upperPage = state.pageSize * firstPage; const index = row - upperPage; const rowData = state._page_data?.[firstPage]?.[index]; return rowData; }, getState: (key) => { return get()[key]; }, hasLocalData: false, keyField: 'id', loadPage: async (pPage: number, clearMode?: 'all' | 'page') => { const state = get(); const page = pPage < 0 ? 0 : pPage; const result = state._events.dispatchEvent( new CustomEvent('before_loadPage', { detail: { clearMode, page: pPage, state }, }) ); if (!result) { return; } const damageList: { cell: [number, number] }[] = []; const colLen = Object.keys(state.renderColumns ?? [1, 2, 3]).length; const upperPage = state.pageSize * page; const hasPage = state._page_data?.[page]?.length > 0; //console.log('loadPage', page, clearMode, { upperPage, hasPage, pz: state.pageSize, colLen }); if (clearMode === 'all') { state._active_requests?.forEach((r) => { r.controller?.abort?.(); }); state.setState('_page_data', {}); state.setState('_active_requests', []); state.loadPage(page); return; } if (!state.useAPIQuery) { console.warn('No useAPIQuery function defined, cannot load page', page); return; } if (!hasPage && clearMode !== 'page') { state .useAPIQuery?.(page) .then((data) => { state.setStateFN('_page_data', (cv) => { return { ...cv, [page]: data }; }); for (let row = page; row <= upperPage + state.pageSize; row++) { for (let col = 0; col <= colLen; col++) { damageList.push({ cell: [col, row], }); } } state._glideref?.updateCells(damageList); state._events.dispatchEvent( new CustomEvent('loadPage', { detail: { clearMode, data, page: pPage, state }, }) ); }) .catch((e) => { console.warn('loadPage Error: ', page, e); state._events.dispatchEvent( new CustomEvent('loadPage_error', { detail: { clearMode, error: e, page: pPage, state }, }) ); }); } }, maxConcurrency: 1, mounted: false, onCellEdited: (cell: Item, newVal: EditableGridCell) => { const state = get(); const [, row] = cell; //const current = state._editData?.[row]; //if (current === undefined) return; //todo: complete state._events.dispatchEvent( new CustomEvent('onCellEdited', { detail: { cell, newVal, row, state }, }) ); }, onColumnMoved: (from: number, to: number) => { const s = get(); const fromItem = s.renderColumns?.[from]; const toItem = s.renderColumns?.[to]; if (fromItem?.disableMove || toItem?.disableMove) { return; } s.setStateFN('colOrder', (cols) => { const renderCols = cols ?? s.renderColumns ?.map((col, i) => [col.id, i]) .reduce((acc, [id, i]) => ({ ...acc, [id]: i }), {}); if (!fromItem?.id || !toItem?.id) { return renderCols; } return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from }; }); }, onColumnProposeMove: (startIndex: number, endIndex: number) => { const s = get(); const fromItem = s.renderColumns?.[startIndex]; const toItem = s.renderColumns?.[endIndex]; if (fromItem?.disableMove || toItem?.disableMove) { return false; } return true; }, onColumnResize: ( column: GridColumn, newSize: number, _colIndex: number, _newSizeWithGrow: number ) => { const s = get(); const col = s.renderColumns?.find((col) => col.id === column.id); if (col?.disableResize) { return; } if (col?.maxWidth && newSize < col?.maxWidth) { return; } if (col) { s.setStateFN('colSize', (cols) => { if (cols && col && cols[col.id] === newSize) { return cols; } return { ...cols, [col.id]: newSize }; }); } }, onContextClick: (area: string, event: any, col?: number, row?: number) => { const s = get(); const coldef = s.renderColumns?.[col ?? -1]; const items = area === 'menu' ? [ { label: `Side menu`, }, ] : coldef ? [ { items: [ { label: 'Sort Ascending', leftSection: , onClick: () => { s.setStateFN('colSort', (c) => { const cols = [...(c ?? [])]; const idx = cols.findIndex((search) => search.id === coldef.id); const dir = 'asc'; if (idx < 0) { const newSort: SortOption = { direction: dir, id: coldef.id, order: cols?.length, }; cols.push(newSort); } else if (idx >= 0) { cols[idx].direction = dir; } return cols; }); }, }, { label: 'Sort Descending', leftSection: , onClick: () => { s.setStateFN('colSort', (c) => { const cols = [...(c ?? [])]; const idx = cols.findIndex((search) => search.id === coldef.id); const dir = 'desc'; if (idx < 0) { const newSort: SortOption = { direction: dir, id: coldef.id, order: cols?.length, }; cols.push(newSort); } else if (idx >= 0) { cols[idx].direction = dir; } return cols; }); }, }, { label: `Filter ${coldef?.title ?? coldef?.id}`, }, { renderer: , }, ], label: `Column Settings for ${coldef?.title ?? coldef?.id}`, leftSection: , }, ] : []; s.hideMenu?.(area); s.showMenu?.(area, { items: coldef?.getMenuItems?.( area, s, col && row ? s.getRowBuffer(row) : undefined, coldef, items ) ?? s.getMenuItems?.(area, s, col && row ? s.getRowBuffer(row) : undefined, coldef, items) ?? items, x: event.clientX ?? event.bounds?.x, y: event.clientY ?? event.bounds?.y, }); }, onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => { const s = get(); event.preventDefault(); const col = s.renderColumns?.[colIndex]; if (!col) { return; } if (!col.disableSort) { s.setStateFN('colSort', (c) => { const cols = [...(c ?? [])]; const idx = cols.findIndex((search) => search.id === col.id); if (idx < 0) { const newSort: SortOption = { direction: 'asc', id: col.id, order: cols?.length }; cols.push(newSort); } else if (idx >= 0 && cols[idx].direction === 'asc') { cols[idx].direction = 'desc'; } else if (idx >= 0 && cols[idx].direction === 'desc') { cols.splice(idx, 1); } return cols; }); } }, onHeaderMenuClick: (col: number, screenPosition: Rectangle) => { const s = get(); const coldef = s.renderColumns?.[col]; if (!coldef) { return; } const sortItems = [ { label: `Sort ${coldef?.title ?? coldef?.id}`, }, { label: 'Sort Ascending', leftSection: , onClick: () => { s.setStateFN('colSort', (c) => { const cols = [...(c ?? [])]; const idx = cols.findIndex((search) => search.id === coldef.id); const dir = 'asc'; if (idx < 0) { const newSort: SortOption = { direction: dir, id: coldef.id, order: cols?.length }; cols.push(newSort); } else if (idx >= 0) { cols[idx].direction = dir; } return cols; }); }, }, { label: 'Sort Descending', leftSection: , onClick: () => { s.setStateFN('colSort', (c) => { const cols = [...(c ?? [])]; const idx = cols.findIndex((search) => search.id === coldef.id); const dir = 'desc'; if (idx < 0) { const newSort: SortOption = { direction: dir, id: coldef.id, order: cols?.length }; cols.push(newSort); } else if (idx >= 0) { cols[idx].direction = dir; } return cols; }); }, }, { isDivider: true, }, ]; const items = [ ...(coldef.disableSort ? [] : sortItems), { label: `Filter ${coldef?.title ?? coldef?.id}`, }, { renderer: , }, { isDivider: true, }, { label: `Refresh`, onClickAsync: async () => { await s.reload?.(); }, }, ]; s.hideMenu?.('header-menu'); s.showMenu?.('header-menu', { items: coldef?.getMenuItems?.('header-menu', s, undefined, coldef, items) ?? s.getMenuItems?.('header-menu', s, undefined, coldef, items), x: screenPosition.x, y: screenPosition.y, }); }, onItemHovered: (args: GridMouseEventArgs) => { const s = get(); s.setState('_activeTooltip', undefined); if (args.kind === 'cell') { //_activeTooltip const coldef = s.renderColumns?.[args.location[0]]; if (coldef?.tooltip && typeof coldef?.tooltip === 'string') { s.setState('_activeTooltip', coldef?.tooltip); } else if (coldef?.tooltip && typeof coldef?.tooltip === 'function') { const buffer = s.getRowBuffer(args.location[1]); s.setState('_activeTooltip', coldef?.tooltip(buffer, args.location[1], args.location[0])); } } }, onVisibleRegionChanged: ( region: Rectangle, _tx: number, _ty: number, _extras: { freezeRegion?: Rectangle; freezeRegions?: readonly Rectangle[]; selected?: Item; } ) => { const state = get(); if (state._scrollTimeout) { clearTimeout(state._scrollTimeout); } const onVisibleRegionChangedDebounced = () => { //console.log('Gridler:Debug:VisibleRegionChanged', r); const state = get(); const firstPage = Math.max(0, Math.floor(region.y / state.pageSize)); // const lastPage = Math.floor((region.y + region.height) / state.pageSize); // const upperPage = state.pageSize * firstPage; const previousPage = Math.max(0, Math.floor(state._visiblePages.y / state.pageSize)); const pageDif = firstPage - previousPage; // console.log( // 'Page dif', // pageDif, // { previousPage, firstPage, lastPage, onder: pageDif - 1 === lastPage }, // region // ); // if (pageDif - 1 === lastPage) { // console.log('Is onder', lastPage, pageDif); // } if (state.progressiveScroll && pageDif > 1) { const state = get(); const newpos = (previousPage + 2) * state.pageSize * (state.rowHeight ?? 22); // console.log('Pending scroll exec', newpos, { // pageSize: state.pageSize, // rowHeight: state.rowHeight // }); state._glideref?.scrollTo( 0, { amount: newpos, unit: 'px', }, 'vertical' ); return; } //console.log('VIsi change', { firstPage, lastPage }, state?._page_data[lastPage]); // if (state._active_requests && state._active_requests.filter((f)=>f.page > firstPage - 1)) { // } //console.log('Gridler:Debug:VisibleRegionChanged', region, firstPage, lastPage); state.setState('_visiblePages', region); }; state.setState( '_scrollTimeout', setTimeout(() => { onVisibleRegionChangedDebounced(); }, 100) ); }, pageSize: 50, setState: (key, value) => { set( produce((state) => { state[key] = value; }) ); }, setStateFN: (key, value) => { const p = new Promise((resolve, reject) => { set( produce((state) => { if (typeof value === 'function') { state[key] = (value as (value: any) => any)(state[key]); } else { reject(new Error(`Not a function ${value}`)); throw Error(`Not a function ${value}`); } }) ); resolve(); }); return p; }, toCell: >(row: T, col: number): GridCell => { const s = get(); const columns = s.renderColumns; const coldef: GridlerColumn | undefined = columns?.[col]; const ref: string = coldef?.id ?? coldef?.title ?? String(col); if (ref === undefined || ref === null || row === undefined || row === null) { if (coldef?.Cell) { return coldef?.Cell(row, col, ref, undefined, s) as GridCell; } if (s.RenderCell) { return s.RenderCell(row, col, ref, undefined, s); } return { allowOverlay: false, kind: GridCellKind.Loading, }; } try { const val = String(ref).includes('.') ? (getNestedValue(ref, row) ?? '') : row?.[ref]; if (coldef?.Cell) { return coldef?.Cell(row, col, ref, val, s) as GridCell; } if (s.RenderCell) { return s.RenderCell(row, col, ref, val, s); } return { allowOverlay: true, data: val, displayData: String(val), kind: GridCellKind.Text, }; } catch (e) { if (s.RenderCell) { return s.RenderCell(row, col, ref, row?.[ref ?? ''], s); } return { allowOverlay: false, kind: GridCellKind.Loading, skeletonWidthVariability: 50, }; } }, total_rows: 1000, uniqueid: getUUID(), }), (props) => { const [setState, getState] = props.useStore((s) => [s.setState, s.getState]); const menus = useMantineBetterMenus(); useEffect(() => { const onMounted = getState('onMounted'); if (typeof onMounted === 'function') { onMounted(getState, setState); } setState('mounted', true); if (window && window.document) { const portalElement = window.document.getElementById('portal'); if (!portalElement) { const div = window.document.createElement('div'); div.id = 'portal'; div.setAttribute('data-gridler-portal', props.uniqueid); window.document.body.appendChild(div); } } getState('_events').dispatchEvent( new CustomEvent('mounted', { detail: {}, }) ); return () => { const onUnMounted = getState('onUnMounted'); setState('mounted', false); getState('_events').dispatchEvent( new CustomEvent('unmounted', { detail: {}, }) ); if (typeof onUnMounted === 'function') { onUnMounted(); } }; }, [setState, getState]); /// logic to apply the selected row. useEffect(() => { const ref = getState('_glideref'); const keyField = getState('keyField') ?? 'id'; const selectedRow = getState('selectedRow') ?? props.selectedRow; const askAPIRowNumber = getState('askAPIRowNumber'); let rowIndex = -1; if (selectedRow && ref) { const page_data = getState('_page_data'); const pageSize = getState('pageSize'); for (const p in page_data) { for (const r in page_data[p]) { const idx = Number(p) * pageSize + Number(r); //console.log('Found row', idx, page_data[p][r]?.[keyField], selectedRow); if (String(page_data[p][r]?.[keyField]) === String(selectedRow)) { rowIndex = page_data[p][r]?._rownumber > 0 ? page_data[p][r]?._rownumber : idx > 0 ? idx : -1; break; } } if (rowIndex > 0) { break; } } if (rowIndex > 0) { ref.scrollTo(0, rowIndex); } else if (typeof askAPIRowNumber === 'function') { askAPIRowNumber(String(selectedRow)) .then((r) => { if (r >= 0) { ref.scrollTo(0, r); getState('_events').dispatchEvent( new CustomEvent('selectedRowFound', { detail: { rowNumber: r, selectedRow: selectedRow }, }) ); } }) .catch((e) => { console.warn('Error in askAPIRowNumber', e); }); } } }, [props.selectedRow]); getState('_events').addEventListener('reload', (_e: Event) => { getState('reload')?.(); }); return { ...props, hideMenu: props.hideMenu ?? menus.hide, showMenu: props.showMenu ?? menus.show, total_rows: props.total_rows ?? getState('total_rows') ?? 0, }; } ); export type useStoreReturnType = ReturnType; export { Provider, useGridlerStore };