From 9c11b609dc7d7224c463aa58fbddef8e24cd4a0d Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 15 Oct 2025 09:57:40 +0200 Subject: [PATCH] Fixed the selection and rendering issues of the syncstore --- package.json | 4 +- pnpm-lock.yaml | 10 +- src/Gridler/Gridler.module.css | 4 +- src/Gridler/GridlerDataGrid.tsx | 246 +++++++++++++------------ src/Gridler/components/BottomBar.tsx | 14 ++ src/Gridler/components/Column.tsx | 6 +- src/Gridler/components/Computer.tsx | 40 ++-- src/Gridler/components/Store.tsx | 74 +++++--- src/Gridler/stories/Examples.goapi.tsx | 12 +- 9 files changed, 225 insertions(+), 185 deletions(-) create mode 100644 src/Gridler/components/BottomBar.tsx diff --git a/package.json b/package.json index 0a405fc..6cf263e 100644 --- a/package.json +++ b/package.json @@ -84,9 +84,9 @@ "@mantine/core": "^8.3.1", "@mantine/hooks": "^8.3.1", "@warkypublic/artemis-kit": "^1.0.10", - "@warkypublic/zustandsyncstore": "^0.0.2", + "@warkypublic/zustandsyncstore": "^0.0.4", "react": ">= 19.0.0", "use-sync-external-store": ">= 1.4.0", "zustand": ">= 5.0.0" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b6942..4b4d0fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.0.10 version: 1.0.10 '@warkypublic/zustandsyncstore': - specifier: ^0.0.2 - version: 0.0.2(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))(zustand@5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))) + specifier: ^0.0.4 + version: 0.0.4(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))(zustand@5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))) immer: specifier: ^10.1.3 version: 10.1.3 @@ -1180,8 +1180,8 @@ packages: resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==} engines: {node: '>=14.16'} - '@warkypublic/zustandsyncstore@0.0.2': - resolution: {integrity: sha512-QHeOjBcO5K5T9O50BCxQZSTkagiUBUi8WnxG2JBXtwTJL8SIPKqM4E21OxjH7sC9e8FyQdCyGWiAl/5GYxaWow==} + '@warkypublic/zustandsyncstore@0.0.4': + resolution: {integrity: sha512-LJ+/rxnPeAybcRSVWHzl3dHC35IsqZH1n++g6Xv3fMXX41XPF/bkCMd3lKatqLmQWPwtMPriBSmG4ukm47vaAQ==} peerDependencies: react: '>= 19.0.0' use-sync-external-store: '>= 1.4.0' @@ -4493,7 +4493,7 @@ snapshots: semver: 7.7.2 uuid: 11.1.0 - '@warkypublic/zustandsyncstore@0.0.2(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))(zustand@5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)))': + '@warkypublic/zustandsyncstore@0.0.4(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1))(zustand@5.0.8(@types/react@19.1.13)(immer@10.1.3)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)))': dependencies: '@warkypublic/artemis-kit': 1.0.10 react: 19.1.1 diff --git a/src/Gridler/Gridler.module.css b/src/Gridler/Gridler.module.css index 76e5892..3e83763 100644 --- a/src/Gridler/Gridler.module.css +++ b/src/Gridler/Gridler.module.css @@ -4,8 +4,8 @@ 0 1px 0 #00000030, -1px 0 0 #00000030; display: flex; - min-height: 50px; - min-width: 50px; + min-height: 40px; + min-width: 40px; height: 100%; width: 100%; diff --git a/src/Gridler/GridlerDataGrid.tsx b/src/Gridler/GridlerDataGrid.tsx index 7e9dffd..30dc5a1 100644 --- a/src/Gridler/GridlerDataGrid.tsx +++ b/src/Gridler/GridlerDataGrid.tsx @@ -5,11 +5,12 @@ import { type DataEditorRef, type GridColumn, } from '@glideapps/glide-data-grid'; -import { ActionIcon } from '@mantine/core'; +import { ActionIcon, Stack } from '@mantine/core'; import { useElementSize, useMergedRef } from '@mantine/hooks'; import { IconMenu2 } from '@tabler/icons-react'; import React from 'react'; +import { BottomBar } from './components/BottomBar'; import { Computer } from './components/Computer'; import { Pager } from './components/Pager'; import { SortSprite } from './components/sprites/Sort'; @@ -41,9 +42,12 @@ export const GridlerDataGrid = () => { onContextClick, onHeaderClicked, onHeaderMenuClick, + onItemHovered, onVisibleRegionChanged, renderColumns, rowHeight, + sections, + selectMode, setState, setStateFN, total_rows, @@ -64,9 +68,12 @@ export const GridlerDataGrid = () => { onContextClick: s.onContextClick, onHeaderClicked: s.onHeaderClicked, onHeaderMenuClick: s.onHeaderMenuClick, + onItemHovered: s.onItemHovered, onVisibleRegionChanged: s.onVisibleRegionChanged, renderColumns: s.renderColumns, rowHeight: s.rowHeight, + sections: s.sections, + selectMode: s.selectMode, setState: s.setState, setStateFN: s.setStateFN, total_rows: s.total_rows, @@ -78,145 +85,142 @@ export const GridlerDataGrid = () => { }); }); - // const args = useAsyncDataSource( - // pageSize ?? 50, - // maxConcurrency ?? 1, - // getRowData, - // toCell, - // onEdited, - // ref - // ); - const theme = useGridTheme(); if (!mounted) { return ( <> - Loadings... + Loading... ); } return ( -
{ - e.preventDefault(); - //Yes this is a litle hacky, but it works to prevent double context menu - if (!refContextActivated.current) { - refContextActivated.current = true; - onContextClick('other', e); - setTimeout(() => { - refContextActivated.current = false; - }, 100); - } - }} - ref={refWrapper} - > - ) ?? []} - columnSelect="none" - drawFocusRing - getCellContent={getCellContent} - getCellsForSelection={getCellsForSelection} - getRowThemeOverride={theme.getRowThemeOverride} - gridSelection={_gridSelection} - headerHeight={headerHeight ?? 32} - headerIcons={{ sort: SortSprite, sortdown: SortDownSprite, sortup: SortUpSprite }} - height={width} - onCellContextMenu={(cell, event) => { - event.preventDefault(); + + {sections?.top} +
{ + e.preventDefault(); + //Yes this is a litle hacky, but it works to prevent double context menu if (!refContextActivated.current) { refContextActivated.current = true; - onContextClick('cell', event, cell[0], cell[1]); + onContextClick('other', e); setTimeout(() => { refContextActivated.current = false; }, 100); } }} - onCellEdited={onCellEdited} - onColumnMoved={onColumnMoved} - onColumnProposeMove={onColumnProposeMove} - onColumnResize={onColumnResize} - onGridSelectionChange={(selection) => { - let rows = CompactSelection.empty(); - const currentSelection = getState('_gridSelection'); - // const currentRowSelection = getState('_gridSelectionRows') ?? CompactSelection.empty(); - // for (const r of currentRowSelection) { - // rows = rows.hasIndex(r) ? rows : rows.add(r); - // } - for (const r of selection.rows) { - rows = rows.hasIndex(r) ? rows : rows.add(r); - } - - if ( - JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) || - JSON.stringify(currentSelection?.rows) !== JSON.stringify(selection.rows) || - JSON.stringify(currentSelection?.current) !== JSON.stringify(selection.current) - ) { - setState('_gridSelection', { ...selection, rows }); - if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(selection.rows)) { - setState('_gridSelectionRows', rows); + ref={refWrapper} + > + {sections?.left} + ) ?? []} + columnSelect="none" + drawFocusRing + getCellContent={getCellContent} + getCellsForSelection={getCellsForSelection} + getRowThemeOverride={theme.getRowThemeOverride} + gridSelection={_gridSelection} + headerHeight={headerHeight ?? 32} + headerIcons={{ sort: SortSprite, sortdown: SortDownSprite, sortup: SortUpSprite }} + height={width} + onCellContextMenu={(cell, event) => { + event.preventDefault(); + if (!refContextActivated.current) { + refContextActivated.current = true; + onContextClick('cell', event, cell[0], cell[1]); + setTimeout(() => { + refContextActivated.current = false; + }, 100); + } + }} + onCellEdited={onCellEdited} + onColumnMoved={onColumnMoved} + onColumnProposeMove={onColumnProposeMove} + onColumnResize={onColumnResize} + onGridSelectionChange={(selection) => { + let rows = CompactSelection.empty(); + const currentSelection = getState('_gridSelection'); + for (const r of selection.rows) { + rows = rows.hasIndex(r) ? rows : rows.add(r); + } + if (selectMode === 'row' && selection.current?.range) { + for ( + let y = selection.current.range.y; + y < selection.current.range.y + selection.current.range.height; + y++ + ) { + rows = rows.hasIndex(y) ? rows : rows.add(y); + } } - } - //console.log('Selection', selection); - }} - onHeaderClicked={onHeaderClicked} - onHeaderContextMenu={(col, event) => { - event.preventDefault(); - if (!refContextActivated.current) { - refContextActivated.current = true; - onContextClick('header', event, col); - setTimeout(() => { - refContextActivated.current = false; - }, 100); - } - }} - onHeaderMenuClick={onHeaderMenuClick} - onVisibleRegionChanged={onVisibleRegionChanged} - rangeSelect="multi-rect" - ref={refMerged as any} - rightElement={ - onContextClick('menu', e)} variant="subtle"> - - - } - rowHeight={rowHeight ?? 22} - //rowMarkersCheckboxStyle='square' - //rowMarkersKind='both' - rowMarkers={{ - checkboxStyle: 'square', - kind: 'both', - }} - rows={total_rows} - rowSelect="multi" - // onGridSelectionChange={(sel) => { - // console.log("Selection",sel); - // }} - // onItemHovered={(item) => { - // console.log('Hovered', item); - // }} - //showSearch={true} - // rowMarkers={{ - // kind: 'clickable-number', - // width: 30 - // }} + if ( + JSON.stringify(currentSelection?.columns) !== JSON.stringify(selection.columns) || + JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows) || + JSON.stringify(currentSelection?.current) !== JSON.stringify(selection.current) + ) { + setState('_gridSelection', { ...selection, rows }); + if (JSON.stringify(currentSelection?.rows) !== JSON.stringify(rows)) { + setState('_gridSelectionRows', rows); + } + } - rowSelectionMode="auto" - spanRangeBehavior="default" - theme={theme.gridTheme} - width="100%" - {...glideProps} - /> - {/* */} -
- {/* */} - - {!hasLocalData && } -
+ //console.log('Selection', selection); + }} + onHeaderClicked={onHeaderClicked} + onHeaderContextMenu={(col, event) => { + event.preventDefault(); + if (!refContextActivated.current) { + refContextActivated.current = true; + onContextClick('header', event, col); + setTimeout(() => { + refContextActivated.current = false; + }, 100); + } + }} + onHeaderMenuClick={onHeaderMenuClick} + onItemHovered={onItemHovered} + onVisibleRegionChanged={onVisibleRegionChanged} + rangeSelect="multi-rect" + ref={refMerged as any} + rightElement={ + onContextClick('menu', e)} + variant="subtle" + > + + + } + rowHeight={rowHeight ?? 22} + //rowMarkersCheckboxStyle='square' + //rowMarkersKind='both' + rowMarkers={{ + checkboxStyle: 'square', + kind: 'both', + }} + rows={total_rows} + rowSelect="multi" + rowSelectionMode="auto" + spanRangeBehavior="default" + theme={theme.gridTheme} + width="100%" + {...glideProps} + /> + {/* */} +
+ {/* */} + + {!hasLocalData && } + {sections?.right} +
+ + {sections?.bottom} + ); }; diff --git a/src/Gridler/components/BottomBar.tsx b/src/Gridler/components/BottomBar.tsx new file mode 100644 index 0000000..0d7692f --- /dev/null +++ b/src/Gridler/components/BottomBar.tsx @@ -0,0 +1,14 @@ +import { useGridlerStore } from './Store'; + +export function BottomBar() { + const { _activeTooltip, tooltipBarProps } = useGridlerStore((s) => ({ + _activeTooltip: s._activeTooltip, + tooltipBarProps: s.tooltipBarProps, + })); + + return ( +
+ {_activeTooltip} +
+ ); +} diff --git a/src/Gridler/components/Column.tsx b/src/Gridler/components/Column.tsx index 0c05bbe..b46843a 100644 --- a/src/Gridler/components/Column.tsx +++ b/src/Gridler/components/Column.tsx @@ -3,7 +3,7 @@ import { type BaseGridColumn, type GridCell, GridCellKind } from '@glideapps/gli import { ActionIcon, Select, Stack, TextInput } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { IconX } from '@tabler/icons-react'; -import { useEffect, useState } from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; import type { FilterOption, FilterOptionOperator, GridlerStoreState } from './Store'; @@ -34,6 +34,7 @@ export interface GridlerColumn extends Partial { id: string; maxWidth?: number; minWidth?: number; + tooltip?: ((buffer: any, row: number, col: number) => ReactNode) | string; width?: number; } @@ -84,7 +85,6 @@ export const ColumnFilterInput = (props: ColumnFilterSetProps) => { }); } - return filters; }); }, [defferedFilterValue, props.column.id, props.options, props.storeState]); @@ -136,8 +136,6 @@ export const ColumnFilterInputOperator = (props: ColumnFilterSetProps) => { }); } - - return filters; }); }, [defferedFilterValue, props.column.id, props.options, props.storeState]); diff --git a/src/Gridler/components/Computer.tsx b/src/Gridler/components/Computer.tsx index 68031d6..8cdcedd 100644 --- a/src/Gridler/components/Computer.tsx +++ b/src/Gridler/components/Computer.tsx @@ -77,48 +77,32 @@ export const Computer = React.memo(() => { rowIndexes.push(idx); } } - //console.log('Setting SSS', { key, rowIndex }); } - //console.log('Setting selection', { rowIndexes, values }); + return rowIndexes; }; if (values) { searchSelection().then((rowIndexes) => { - // const newObj : GridSelection = { - // ...cur, + let rows = CompactSelection.empty(); + rowIndexes.forEach((r) => { + rows = rows.add(r); + }); - // rows: { - // items: rowIndexes.map((r) => [r - 1, r]) ?? [], - // }, - // }; - // console.log('Setting selection', { - // rowIndexes, - // values, - // newObj, - // }); setStateFN('_gridSelectionRows', () => { - let rows = CompactSelection.empty(); - rowIndexes.forEach((r) => { - rows = rows.add(r); - }); - // for (const r of cur ?? CompactSelection.empty()) { - // rows = rows.add(r); - // } - setStateFN('_gridSelection', (c) => ({ - columns: c?.columns ?? CompactSelection.empty(), - ...c, - rows, - })); - return rows; }); + + setStateFN('_gridSelection', (c) => ({ + columns: c?.columns ?? CompactSelection.empty(), + ...c, + rows, + })); }); } }, [values]); useEffect(() => { - //console.log('Gridler:Computer: Selection changed', _gridSelectionRows?.toArray()); const onChange = getState('onChange'); if (onChange && typeof onChange === 'function') { const page_data = getState('_page_data'); @@ -152,7 +136,7 @@ export const Computer = React.memo(() => { } } } - //console.log('Calling onChange with buffers', buffers, _gridSelectionRows?.toArray()); + const _values = getState('values'); if (JSON.stringify(_values) !== JSON.stringify(buffers)) { diff --git a/src/Gridler/components/Store.tsx b/src/Gridler/components/Store.tsx index a0a2d19..6a1d5b3 100644 --- a/src/Gridler/components/Store.tsx +++ b/src/Gridler/components/Store.tsx @@ -10,6 +10,7 @@ import { type GridCell, GridCellKind, type GridColumn, + type GridMouseEventArgs, type GridSelection, type HeaderClickedEventArgs, type Item, @@ -19,7 +20,7 @@ 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, useEffect } from 'react'; +import { type PropsWithChildren, type ReactNode, useEffect } from 'react'; import { type MantineBetterMenuInstance, useMantineBetterMenus } from '../../MantineBetterMenu'; import { type TRequest } from '../utils/types'; @@ -78,8 +79,16 @@ export interface GridlerProps extends PropsWithChildren { ) => GridCell; request?: TRequest; rowHeight?: number; + sections?: { + bottom?: React.ReactNode; + left?: React.ReactNode; + right?: React.ReactNode; + top?: React.ReactNode; + }; selectedRow?: number; + selectMode?: 'cell' | 'row'; showMenu?: (id: string, options?: Partial) => void; + tooltipBarProps?: React.HTMLAttributes; uniqueid: string; useAPIQuery?: (index: number) => Promise>>; values?: Array>; @@ -87,6 +96,7 @@ export interface GridlerProps extends PropsWithChildren { export interface GridlerState { _active_requests?: Array<{ controller: AbortController; page: number }>; + _activeTooltip?: ReactNode; _glideref?: DataEditorRef; _gridSelection?: GridSelection; _gridSelectionRows?: GridSelection['rows']; @@ -110,6 +120,7 @@ export interface GridlerState { selection: Rectangle, abortSignal: AbortSignal ) => CellArray | GetCellsThunk; + getRowBuffer: (row: number) => any; getState: (key: K) => GridlerStoreState[K]; hasLocalData: boolean; @@ -127,6 +138,7 @@ export interface GridlerState { 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, @@ -179,28 +191,10 @@ const { Provider, useStore: useGridlerStore } = createSyncStore 0) { - if (state.data[row] === undefined) { - return { - allowOverlay: false, - kind: GridCellKind.Loading, - }; - } - return state.toCell(state.data[row], col); - } + const buffer = state.getRowBuffer(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]; - //console.log('getCellContent', row, firstPage, upperPage, index, rowData); - //This is empty, that is why the grid renders nothing - - if (rowData !== undefined) { - return state.toCell(rowData, col); + if (buffer !== undefined) { + return state.toCell(buffer, col); } return { allowOverlay: false, @@ -233,6 +227,28 @@ const { Provider, useStore: useGridlerStore } = createSyncStore { + 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]; }, @@ -499,6 +515,20 @@ const { Provider, useStore: useGridlerStore } = createSyncStore { + 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, diff --git a/src/Gridler/stories/Examples.goapi.tsx b/src/Gridler/stories/Examples.goapi.tsx index 8c46418..517deaa 100644 --- a/src/Gridler/stories/Examples.goapi.tsx +++ b/src/Gridler/stories/Examples.goapi.tsx @@ -25,6 +25,9 @@ export const GridlerGoAPIExampleEventlog = () => { { id: 'process', title: 'Process', + tooltip: (buffer) => { + return `Process: ${buffer?.process}\nType: ${buffer?.processtype}\nStatus: ${buffer?.status}`; + }, width: 200, }, { @@ -62,10 +65,17 @@ export const GridlerGoAPIExampleEventlog = () => { }} keyField="id_process" onChange={(v) => { - console.log('GridlerGoAPIExampleEventlog onChange', v); + //console.log('GridlerGoAPIExampleEventlog onChange', v); setValues(v); }} + sections={{ + bottom:
bottom
, + left:
L
, + right:
R
, + top:
top
, + }} selectedRow={selectRow ? parseInt(selectRow, 10) : undefined} + selectMode="row" uniqueid="gridtest" values={values} >