oranguru/src/Gridler/components/GridlerStore.tsx
2025-10-21 14:24:28 +02:00

863 lines
27 KiB
TypeScript

/* 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<number>;
columns?: GridlerColumns;
defaultSort?: Array<SortOption>;
enableOddEvenRowColor?: boolean;
getMenuItems?: (
id: string,
storeState: GridlerState,
row?: any,
col?: GridlerColumn,
defaultItems?: Array<MantineBetterMenuInstanceItem>
) => Array<MantineBetterMenuInstanceItem>;
glideProps?: Partial<DataEditorProps>;
headerHeight?: number;
height?: number | string;
hideMenu?: (id: string) => void;
keyField?: string;
maxConcurrency?: number;
onChange?: (values: Array<Record<string, any>>) => void;
onMounted?: (getState: GridlerState['getState'], setState: GridlerState['setState']) => void;
onUnMounted?: () => void;
pageSize?: number;
progressiveScroll?: boolean;
RenderCell?: <TRowType extends Record<string, string>>(
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<MantineBetterMenuInstance>) => void;
tooltipBarProps?: React.HTMLAttributes<HTMLDivElement>;
total_rows?: number;
uniqueid: string;
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
values?: Array<Record<string, any>>;
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<number, Array<any>>;
_scrollTimeout?: any | number;
_visibleArea: Rectangle;
_visiblePages: Rectangle;
addError: (err: string, ...args: Array<any>) => void;
colFilters?: Array<FilterOption>;
colOrder?: Record<string, number>;
colSize?: Record<string, number>;
colSort?: Array<SortOption>;
data?: Array<any>;
errors: Array<string>;
focused?: boolean;
get: () => GridlerState;
getCellContent: (cell: Item) => GridCell;
getCellsForSelection: (
selection: Rectangle,
abortSignal: AbortSignal
) => CellArray | GetCellsThunk;
getRowBuffer: (row: number) => any;
getState: <K extends keyof GridlerStoreState>(key: K) => GridlerStoreState[K];
hasLocalData: boolean;
loadingData?: boolean;
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
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<void>;
renderColumns?: GridlerColumns;
setState: <K extends keyof GridlerStoreState>(
key: K,
value: Partial<GridlerStoreState[K]>
) => void;
setStateFN: <K extends keyof GridlerStoreState>(
key: K,
value: (current: GridlerStoreState[K]) => Partial<GridlerStoreState[K]>
) => Promise<void>;
toCell: <TRowType extends Record<string, string>>(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<GridlerStoreState, GridlerProps>(
(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<any>) => {
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: <SpriteImage sprite={SortUpSprite} />,
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: <SpriteImage sprite={SortDownSprite} />,
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: <ColumnFilterSet column={coldef} storeState={get()} />,
},
],
label: `Column Settings for ${coldef?.title ?? coldef?.id}`,
leftSection: <IconGrid4x4 size={16} />,
},
]
: [];
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: <SpriteImage sprite={SortUpSprite} />,
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: <SpriteImage sprite={SortDownSprite} />,
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: <ColumnFilterSet column={coldef} storeState={get()} />,
},
{
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<void>((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: <T extends Record<string, string>>(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<typeof useGridlerStore>;
export { Provider, useGridlerStore };