Init
This commit is contained in:
21
src/Gridler/Gridler.module.css
Normal file
21
src/Gridler/Gridler.module.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.container {
|
||||
box-shadow:
|
||||
1px 0 0 #00000030,
|
||||
0 1px 0 #00000030,
|
||||
-1px 0 0 #00000030;
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
min-width: 50px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&[data-focused='true'] {
|
||||
box-shadow:
|
||||
-1px 0px 0px 0px
|
||||
light-dark(alpha(var(--mantine-color-blue-9), 0.6), alpha(var(--mantine-color-blue-9), 0.3)),
|
||||
1px 0px 0px 0px
|
||||
light-dark(alpha(var(--mantine-color-blue-9), 0.6), alpha(var(--mantine-color-blue-9), 0.3)),
|
||||
0px 1px 0px 0px
|
||||
light-dark(alpha(var(--mantine-color-blue-9), 0.6), alpha(var(--mantine-color-blue-9), 0.3));
|
||||
}
|
||||
}
|
||||
25
src/Gridler/Gridler.tsx
Normal file
25
src/Gridler/Gridler.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import '@glideapps/glide-data-grid/dist/index.css';
|
||||
import React from 'react';
|
||||
|
||||
import { MantineBetterMenusProvider } from '../MantineBetterMenu';
|
||||
import { type GridlerProps, Provider } from './components/Store';
|
||||
import { GridlerDataGrid } from './GridlerDataGrid';
|
||||
|
||||
export const Gridler = (props: GridlerProps) => {
|
||||
return (
|
||||
<MantineBetterMenusProvider>
|
||||
<Provider
|
||||
{...props}
|
||||
persist={{
|
||||
name: `Gridler_${props.uniqueid}`,
|
||||
partialize: (s) => ({ colOrder: s.colOrder, colSize: s.colSize }),
|
||||
|
||||
version: 1,
|
||||
}}
|
||||
>
|
||||
<GridlerDataGrid />
|
||||
{props.children}
|
||||
</Provider>
|
||||
</MantineBetterMenusProvider>
|
||||
);
|
||||
};
|
||||
172
src/Gridler/GridlerDataGrid.tsx
Normal file
172
src/Gridler/GridlerDataGrid.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import '@glideapps/glide-data-grid/dist/index.css';
|
||||
import { DataEditor, type DataEditorRef, type GridColumn } from '@glideapps/glide-data-grid';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useElementSize, useMergedRef } from '@mantine/hooks';
|
||||
import { IconMenu2 } from '@tabler/icons-react';
|
||||
import React from 'react';
|
||||
|
||||
import { Computer } from './components/Computer';
|
||||
import { Pager } from './components/Pager';
|
||||
import { SortSprite } from './components/sprites/Sort';
|
||||
import { SortDownSprite } from './components/sprites/SortDown';
|
||||
import { SortUpSprite } from './components/sprites/SortUp';
|
||||
import { useStore } from './components/Store';
|
||||
import classes from './Gridler.module.css';
|
||||
import { useGridTheme } from './hooks/use-grid-theme';
|
||||
|
||||
export const GridlerDataGrid = () => {
|
||||
const ref = React.useRef<DataEditorRef | null>(null);
|
||||
const refContextActivated = React.useRef<boolean>(false);
|
||||
const { ref: refWrapper, width } = useElementSize();
|
||||
|
||||
const {
|
||||
focused,
|
||||
getCellContent,
|
||||
getCellsForSelection,
|
||||
hasLocalData,
|
||||
headerHeight,
|
||||
mounted,
|
||||
onCellEdited,
|
||||
onColumnMoved,
|
||||
onColumnProposeMove,
|
||||
onColumnResize,
|
||||
onContextClick,
|
||||
onHeaderClicked,
|
||||
onHeaderMenuClick,
|
||||
onVisibleRegionChanged,
|
||||
renderColumns,
|
||||
rowHeight,
|
||||
setStateFN,
|
||||
total_rows,
|
||||
} = useStore((s) => ({
|
||||
focused: s.focused,
|
||||
getCellContent: s.getCellContent,
|
||||
getCellsForSelection: s.getCellsForSelection,
|
||||
hasLocalData: s.hasLocalData,
|
||||
headerHeight: s.headerHeight,
|
||||
mounted: s.mounted,
|
||||
onCellEdited: s.onCellEdited,
|
||||
onColumnMoved: s.onColumnMoved,
|
||||
onColumnProposeMove: s.onColumnProposeMove,
|
||||
onColumnResize: s.onColumnResize,
|
||||
onContextClick: s.onContextClick,
|
||||
onHeaderClicked: s.onHeaderClicked,
|
||||
onHeaderMenuClick: s.onHeaderMenuClick,
|
||||
onVisibleRegionChanged: s.onVisibleRegionChanged,
|
||||
renderColumns: s.renderColumns,
|
||||
rowHeight: s.rowHeight,
|
||||
setStateFN: s.setStateFN,
|
||||
total_rows: s.total_rows,
|
||||
}));
|
||||
|
||||
const refMerged = useMergedRef(ref, (r) => {
|
||||
|
||||
setStateFN('_glideref', () => {
|
||||
return r ?? undefined;
|
||||
});
|
||||
});
|
||||
|
||||
// const args = useAsyncDataSource<string[]>(
|
||||
// pageSize ?? 50,
|
||||
// maxConcurrency ?? 1,
|
||||
// getRowData,
|
||||
// toCell,
|
||||
// onEdited,
|
||||
// ref
|
||||
// );
|
||||
|
||||
const theme = useGridTheme();
|
||||
|
||||
if (!mounted) {
|
||||
return <>Loadings...<Computer /></>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={classes.container}
|
||||
data-focused={focused}
|
||||
onContextMenu={(e) => {
|
||||
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}
|
||||
>
|
||||
<DataEditor
|
||||
cellActivationBehavior="double-click"
|
||||
//getCelrefMergedlContent={getCellContent}
|
||||
columns={(renderColumns as Array<GridColumn>) ?? []}
|
||||
columnSelect="none"
|
||||
drawFocusRing
|
||||
getCellContent={getCellContent}
|
||||
getCellsForSelection={getCellsForSelection}
|
||||
getRowThemeOverride={theme.getRowThemeOverride}
|
||||
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}
|
||||
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="none"
|
||||
ref={refMerged as any}
|
||||
rightElement={
|
||||
<ActionIcon mr="xs" mt="2px" onClick={(e) => onContextClick('menu', e)} variant="subtle">
|
||||
<IconMenu2 />
|
||||
</ActionIcon>
|
||||
}
|
||||
rowHeight={rowHeight ?? 22}
|
||||
rows={total_rows}
|
||||
// onGridSelectionChange={(sel) => {
|
||||
// console.log("Selection",sel);
|
||||
// }}
|
||||
// onItemHovered={(item) => {
|
||||
// console.log('Hovered', item);
|
||||
// }}
|
||||
//showSearch={true}
|
||||
// rowMarkers={{
|
||||
// kind: 'clickable-number',
|
||||
// width: 30
|
||||
// }}
|
||||
|
||||
rowSelect="multi"
|
||||
spanRangeBehavior="default"
|
||||
theme={theme.gridTheme}
|
||||
width="100%"
|
||||
/>
|
||||
{/* <Portal> */}
|
||||
<div id="portal" />
|
||||
{/* </Portal> */}
|
||||
<Computer />
|
||||
{!hasLocalData && <Pager />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
src/Gridler/components/APIAdaptorGoLangv2.tsx
Normal file
86
src/Gridler/components/APIAdaptorGoLangv2.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { APIOptions } from '../../utils/types';
|
||||
import { useStore } from './Store';
|
||||
|
||||
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
||||
export const APIAdaptorGoLangv2 = React.memo((props: APIOptions) => {
|
||||
const [setStateFN, setState, getState, addError, mounted] = useStore((s) => [
|
||||
s.setStateFN,
|
||||
s.setState,
|
||||
s.getState,
|
||||
s.addError,
|
||||
s.mounted
|
||||
]);
|
||||
|
||||
const useAPIQuery: (index: number) => Promise<any> = async (index: number) => {
|
||||
const colSort = getState('colSort');
|
||||
const pageSize = getState('pageSize');
|
||||
const _active_requests = getState('_active_requests');
|
||||
|
||||
if (props && props.url) {
|
||||
const head = new Headers();
|
||||
head.set('x-limit', String(pageSize ?? 50));
|
||||
head.set('x-offset', String((pageSize ?? 50) * index));
|
||||
head.set('x-fieldfilter-tablename', 'scriptcode');
|
||||
head.set('Authorization', `Token ${props.authtoken}`);
|
||||
|
||||
if (colSort?.length && colSort.length > 0) {
|
||||
head.set(
|
||||
'x-sort',
|
||||
colSort
|
||||
?.map((sort: any) => `${sort.id} ${sort.direction}`)
|
||||
.reduce((acc: any, val: any) => `${acc},${val}`)
|
||||
);
|
||||
}
|
||||
|
||||
const currentRequestIndex = _active_requests?.findIndex((f) => f.page === index) ?? -1;
|
||||
_active_requests?.forEach((r) => {
|
||||
if ((r.page >= 0 && r.page < index - 2) || (index >= 0 && r.page > index + 2)) {
|
||||
r.controller?.abort?.();
|
||||
}
|
||||
});
|
||||
if (_active_requests && currentRequestIndex >= 0 && _active_requests[currentRequestIndex]) {
|
||||
//console.log(`Already queued ${index}`, index, s._active_requests);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
await setStateFN('_active_requests', (cv) => [
|
||||
...(cv ?? []),
|
||||
{ controller, page: index }
|
||||
]);
|
||||
|
||||
const res = await fetch(
|
||||
`${props.url}?x-limit=${String(pageSize ?? 50)}&x-offset=${String((pageSize ?? 50) * index)}`,
|
||||
{
|
||||
headers: head,
|
||||
method: 'GET',
|
||||
signal: controller?.signal
|
||||
}
|
||||
);
|
||||
|
||||
if (res.ok) {
|
||||
const cr = res.headers.get('Content-Range')?.split('/');
|
||||
if (cr?.[1] && parseInt(cr[1], 10) > 0) {
|
||||
setState('total_rows', parseInt(cr[1], 10));
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
return data ?? [];
|
||||
}
|
||||
addError(`${res.status} ${res.statusText}`, 'api', props.url);
|
||||
|
||||
|
||||
await setStateFN('_active_requests', (cv) => [...(cv ?? []).filter((f) => f.page !== index)]);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setState('useAPIQuery', useAPIQuery);
|
||||
}, [props.url, props.authtoken, mounted]);
|
||||
|
||||
return <></>;
|
||||
});
|
||||
163
src/Gridler/components/Column.tsx
Normal file
163
src/Gridler/components/Column.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { type BaseGridColumn, type GridCell, GridCellKind } from '@glideapps/glide-data-grid';
|
||||
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 { FilterOption, FilterOptionOperator, GridlerStoreState } from './Store';
|
||||
|
||||
export type GridCellLoose = {
|
||||
kind: GridCellKind | string;
|
||||
} & Omit<GridCell, 'allowOverlay' | 'kind'>;
|
||||
|
||||
export interface GridlerColumn extends Partial<BaseGridColumn> {
|
||||
Cell?: (
|
||||
row: any,
|
||||
col: number,
|
||||
colIndx: string,
|
||||
value: any,
|
||||
storeState: GridlerStoreState
|
||||
) => GridCellLoose;
|
||||
defaultIcon?: string;
|
||||
disableFilter?: boolean;
|
||||
disableMove?: boolean;
|
||||
disableResize?: boolean;
|
||||
disableSort?: boolean;
|
||||
getMenuItems?: (
|
||||
id: string,
|
||||
storeState: any,
|
||||
row?: any,
|
||||
col?: GridlerColumn,
|
||||
defaultItems?: Array<any>
|
||||
) => Array<any>;
|
||||
id: string;
|
||||
maxWidth?: number;
|
||||
minWidth?: number;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const FilterOperators: Array<{ label: string; value: FilterOptionOperator }> = [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Equal', value: 'eq' },
|
||||
{ label: 'Not Equal', value: 'neq' },
|
||||
{ label: 'Greater Than', value: 'gt' },
|
||||
{ label: 'Greater Than or Equal', value: 'gte' },
|
||||
{ label: 'Less Than', value: 'lt' },
|
||||
{ label: 'Less Than or Equal', value: 'lte' },
|
||||
];
|
||||
|
||||
export interface ColumnFilterSetProps {
|
||||
column: GridlerColumn;
|
||||
options?: Partial<FilterOption>;
|
||||
storeState: GridlerStoreState;
|
||||
}
|
||||
|
||||
export type GridlerColumns = Array<GridlerColumn>;
|
||||
|
||||
export const ColumnFilterInput = (props: ColumnFilterSetProps) => {
|
||||
const filterIndex =
|
||||
props.storeState?.colFilters?.findIndex((f) => f.id === props.column.id) ?? -1;
|
||||
const filter = props.storeState?.colFilters?.[filterIndex] ?? { id: props.column.id, value: '' };
|
||||
|
||||
const [filterValue, setFilterValue] = useState<string>(filter?.value ?? '');
|
||||
const [defferedFilterValue] = useDebouncedValue(filterValue, 900);
|
||||
|
||||
useEffect(() => {
|
||||
props.storeState.setStateFN('colFilters', (state) => {
|
||||
const idx = state?.findIndex((f) => f.id === props.column.id) ?? -1;
|
||||
|
||||
const filters = state ?? [];
|
||||
if (idx >= 0) {
|
||||
filters[idx] = {
|
||||
...filters[idx],
|
||||
...props.options,
|
||||
id: props.column.id,
|
||||
value: defferedFilterValue,
|
||||
};
|
||||
} else {
|
||||
filters.push({
|
||||
operator: 'contains',
|
||||
...props.options,
|
||||
id: props.column.id,
|
||||
value: defferedFilterValue,
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
}, [defferedFilterValue]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
onChange={(e) => setFilterValue(e.target.value)}
|
||||
rightSection={
|
||||
<ActionIcon color="gray" onClick={() => setFilterValue('')} variant="filled">
|
||||
<IconX color="red" />
|
||||
</ActionIcon>
|
||||
}
|
||||
value={filterValue ?? filter?.value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnFilterInputOperator = (props: ColumnFilterSetProps) => {
|
||||
const filterIndex =
|
||||
props.storeState?.colFilters?.findIndex((f) => f.id === props.column.id) ?? -1;
|
||||
const filter = props.storeState?.colFilters?.[filterIndex] ?? {
|
||||
id: props.column.id,
|
||||
operator: 'contains',
|
||||
};
|
||||
|
||||
const [filterValue, setFilterValue] = useState<FilterOptionOperator>(
|
||||
filter?.operator ?? 'contains'
|
||||
);
|
||||
const [defferedFilterValue] = useDebouncedValue(filterValue, 900);
|
||||
|
||||
useEffect(() => {
|
||||
props.storeState.setStateFN('colFilters', (state) => {
|
||||
const idx = state?.findIndex((f) => f.id === props.column.id) ?? -1;
|
||||
|
||||
const filters = state ?? [];
|
||||
if (idx >= 0) {
|
||||
filters[idx] = {
|
||||
...filters[idx],
|
||||
...props.options,
|
||||
id: props.column.id,
|
||||
operator: defferedFilterValue,
|
||||
};
|
||||
} else {
|
||||
filters.push({
|
||||
operator: 'contains',
|
||||
...props.options,
|
||||
id: props.column.id,
|
||||
value: defferedFilterValue,
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
});
|
||||
}, [defferedFilterValue]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
data={FilterOperators}
|
||||
maxDropdownHeight={150}
|
||||
onChange={(value) => setFilterValue(value as any)}
|
||||
placeholder="Operator"
|
||||
searchable
|
||||
value={filterValue ?? filter?.operator}
|
||||
withScrollArea
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnFilterSet = (props: ColumnFilterSetProps) => {
|
||||
return (
|
||||
<Stack onClick={(e) => e.stopPropagation()}>
|
||||
<ColumnFilterInputOperator {...props} />
|
||||
<ColumnFilterInput {...props} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
111
src/Gridler/components/Computer.tsx
Normal file
111
src/Gridler/components/Computer.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { useStore } from './Store';
|
||||
|
||||
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
||||
export const Computer = React.memo(() => {
|
||||
const refFirstRun = useRef(0);
|
||||
const {
|
||||
_glideref,
|
||||
|
||||
colOrder,
|
||||
colSize,
|
||||
colSort,
|
||||
columns,
|
||||
loadPage,
|
||||
setState,
|
||||
setStateFN,
|
||||
} = useStore((s) => ({
|
||||
_glideref: s._glideref,
|
||||
colFilters: s.colFilters,
|
||||
colOrder: s.colOrder,
|
||||
colSize: s.colSize,
|
||||
colSort: s.colSort,
|
||||
columns: s.columns,
|
||||
loadPage: s.loadPage,
|
||||
setState: s.setState,
|
||||
setStateFN: s.setStateFN,
|
||||
|
||||
uniqueid: s.uniqueid,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setState(
|
||||
'renderColumns',
|
||||
columns?.map((c) => ({
|
||||
...c,
|
||||
hasMenu: c?.hasMenu ?? true,
|
||||
icon: 'sort',
|
||||
}))
|
||||
);
|
||||
}, [columns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colSort) {
|
||||
return;
|
||||
}
|
||||
setStateFN('renderColumns', (cols) => {
|
||||
return cols?.map((c) => ({
|
||||
...c,
|
||||
icon:
|
||||
c.id && colSort?.find((col) => col.id === c.id)?.direction
|
||||
? colSort?.find((col) => col.id === c.id)?.direction === 'asc'
|
||||
? 'sortup'
|
||||
: 'sortdown'
|
||||
: (c.defaultIcon ?? 'sort'),
|
||||
}));
|
||||
}).then(() => {
|
||||
loadPage(0, 'all');
|
||||
});
|
||||
}, [colSort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colSize) {
|
||||
return;
|
||||
}
|
||||
setStateFN('renderColumns', (cols) => {
|
||||
return cols?.map((c) => ({
|
||||
...c,
|
||||
width: c.id && colSize?.[c.id] ? colSize?.[c.id] : c.width,
|
||||
}));
|
||||
});
|
||||
}, [colSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colOrder) {
|
||||
return;
|
||||
}
|
||||
setStateFN('renderColumns', (cols) => {
|
||||
const result = cols?.sort((a, b) => {
|
||||
if (colOrder[a.id] > colOrder[b.id]) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}, [colOrder]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!_glideref) {
|
||||
return;
|
||||
}
|
||||
if (refFirstRun.current > 0) {
|
||||
return;
|
||||
}
|
||||
refFirstRun.current = 1;
|
||||
loadPage(0);
|
||||
}, [_glideref]);
|
||||
|
||||
// console.log('Gridler:Debug:Computer', {
|
||||
// colFilters,
|
||||
// colOrder,
|
||||
// colSize,
|
||||
// colSort,
|
||||
// columns,
|
||||
// uniqueid
|
||||
// });
|
||||
|
||||
return <></>;
|
||||
});
|
||||
62
src/Gridler/components/Pager.tsx
Normal file
62
src/Gridler/components/Pager.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { range } from '../utils/range';
|
||||
import { useStore } from './Store';
|
||||
|
||||
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
||||
export const Pager = React.memo(() => {
|
||||
const [
|
||||
setState,
|
||||
glideref,
|
||||
visiblePages,
|
||||
//_visibleArea,
|
||||
pageSize,
|
||||
loadPage,
|
||||
_loadingList,
|
||||
hasLocalData
|
||||
] = useStore((s) => [
|
||||
s.setState,
|
||||
s._glideref,
|
||||
s._visiblePages,
|
||||
//s._visibleArea,
|
||||
s.pageSize,
|
||||
s.loadPage,
|
||||
|
||||
s._loadingList,
|
||||
s.hasLocalData
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!glideref) {return;}
|
||||
setState('mounted', true);
|
||||
}, [setState]);
|
||||
|
||||
//Maybe move this into a computer component.
|
||||
useEffect(() => {
|
||||
if (!glideref) {return;}
|
||||
if (hasLocalData) {
|
||||
//using local data, no need to load pages
|
||||
return;
|
||||
}
|
||||
const firstPage = Math.max(0, Math.floor(visiblePages.y / pageSize));
|
||||
const lastPage = Math.floor((visiblePages.y + visiblePages.height) / pageSize);
|
||||
//const upperPage = pageSize * firstPage;
|
||||
|
||||
// console.log(
|
||||
// 'Render1',
|
||||
// { firstPage, lastPage, upperPage },
|
||||
// { pageSize, _visibleArea, visiblePages },
|
||||
// glideref,
|
||||
|
||||
// _loadingList,
|
||||
// _editData
|
||||
// );
|
||||
|
||||
for (const page of range(firstPage, lastPage + 1, 1)) {
|
||||
loadPage(page);
|
||||
}
|
||||
}, [loadPage, pageSize, visiblePages, glideref, _loadingList,hasLocalData]);
|
||||
|
||||
return <></>;
|
||||
});
|
||||
643
src/Gridler/components/Store.tsx
Normal file
643
src/Gridler/components/Store.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import {
|
||||
type CellArray,
|
||||
CompactSelection,
|
||||
type DataEditorRef,
|
||||
type EditableGridCell,
|
||||
type GetCellsThunk,
|
||||
type GridCell,
|
||||
GridCellKind,
|
||||
type GridColumn,
|
||||
type HeaderClickedEventArgs,
|
||||
type Item,
|
||||
type Rectangle,
|
||||
} from '@glideapps/glide-data-grid';
|
||||
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 MantineBetterMenuInstance, useMantineBetterMenus } from '../../MantineBetterMenu';
|
||||
import { type TRequest } from '../utils/types';
|
||||
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 {
|
||||
columns?: GridlerColumns;
|
||||
data?: Array<any>;
|
||||
defaultSort?: Array<SortOption>;
|
||||
enableOddEvenRowColor?: boolean;
|
||||
getMenuItems?: (
|
||||
id: string,
|
||||
storeState: GridlerState,
|
||||
row?: any,
|
||||
col?: GridlerColumn,
|
||||
defaultItems?: Array<any>
|
||||
) => Array<any>;
|
||||
headerHeight?: number;
|
||||
|
||||
hideMenu?: (id: string) => void;
|
||||
maxConcurrency?: number;
|
||||
pageSize?: number;
|
||||
progressiveScroll?: boolean;
|
||||
RenderCell?: <TRowType extends Record<string, string>>(
|
||||
row: any,
|
||||
colindex: number,
|
||||
colid: string,
|
||||
value: any,
|
||||
store: GridlerState
|
||||
) => GridCell;
|
||||
request?: TRequest;
|
||||
rowHeight?: number;
|
||||
showMenu?: (id: string, options?: Partial<MantineBetterMenuInstance>) => void;
|
||||
uniqueid: string;
|
||||
}
|
||||
|
||||
export interface GridlerState {
|
||||
_active_requests?: Array<{ controller: AbortController; page: number }>;
|
||||
_glideref?: DataEditorRef;
|
||||
_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;
|
||||
getState: <K extends keyof GridlerStoreState>(key: K) => GridlerStoreState[K];
|
||||
|
||||
hasLocalData: 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;
|
||||
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: any, col: number) => GridCell;
|
||||
total_rows: number;
|
||||
useAPIQuery?: (index: number) => Promise<Array<Record<string, any>>>;
|
||||
}
|
||||
|
||||
export type GridlerStoreState = GridlerProps & GridlerState;
|
||||
|
||||
export type SortOption = { direction: 'asc' | 'desc'; id: string; order?: number };
|
||||
|
||||
const { Provider, useStore } = createSyncStore<GridlerStoreState, GridlerProps>(
|
||||
(set, get) => ({
|
||||
_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;
|
||||
|
||||
//Handle local data
|
||||
if (state.data && state.data.length > 0) {
|
||||
if (state.data[row] === undefined) {
|
||||
return {
|
||||
allowOverlay: false,
|
||||
kind: GridCellKind.Loading,
|
||||
};
|
||||
}
|
||||
return state.toCell(state.data[row], col);
|
||||
}
|
||||
|
||||
//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);
|
||||
}
|
||||
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);
|
||||
//console.log('Gridler:Debug:getCellsForSelection', selection, firstPage, lastPage);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// for (const pageChunk of chunk(
|
||||
// range(firstPage, lastPage + 1).filter((i) => !state._loadingList.hasIndex(i)),
|
||||
// state.maxConcurrency
|
||||
// )) {
|
||||
// await Promise.all(pageChunk.map(state.loadPage));
|
||||
// }
|
||||
|
||||
// for (const page of range(firstPage - 1, lastPage + 1, 1)) {
|
||||
// state.loadPage(page);
|
||||
// }
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return result as CellArray;
|
||||
};
|
||||
},
|
||||
getState: (key) => {
|
||||
return get()[key];
|
||||
},
|
||||
hasLocalData: false,
|
||||
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
||||
const state = get();
|
||||
const page = pPage < 0 ? 0 : pPage;
|
||||
|
||||
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);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('loadPage Error: ', page, e);
|
||||
});
|
||||
}
|
||||
},
|
||||
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
|
||||
},
|
||||
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
|
||||
? [
|
||||
{
|
||||
label: `Column ${coldef?.title ?? coldef?.id} Row ${row}`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: `No Column ${area}`,
|
||||
},
|
||||
];
|
||||
s.hideMenu?.(area);
|
||||
s.showMenu?.(area, {
|
||||
items:
|
||||
coldef?.getMenuItems?.(
|
||||
area,
|
||||
s,
|
||||
col && row ? s.getCellContent([col, row]) : undefined,
|
||||
coldef,
|
||||
items
|
||||
) ??
|
||||
s.getMenuItems?.(
|
||||
area,
|
||||
s,
|
||||
col && row ? s.getCellContent([col, 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 items = [
|
||||
{
|
||||
label: `Sort ${coldef?.title ?? coldef?.id}`,
|
||||
},
|
||||
{
|
||||
label: 'Sort Ascending',
|
||||
leftSection: <SpriteImage sprite={SortUpSprite} />,
|
||||
onClick: () => {
|
||||
console.log('Sort Ascending');
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Sort Descending',
|
||||
leftSection: <SpriteImage sprite={SortDownSprite} />,
|
||||
onClick: () => {
|
||||
console.log('Sort Descending');
|
||||
},
|
||||
},
|
||||
{
|
||||
isDivider: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
});
|
||||
},
|
||||
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, glideref, getState] = props.useStore((s) => [
|
||||
s.setState,
|
||||
s._glideref,
|
||||
s.getState,
|
||||
]);
|
||||
|
||||
const menus = useMantineBetterMenus();
|
||||
|
||||
useEffect(() => {
|
||||
setState('mounted', true);
|
||||
}, [setState]);
|
||||
|
||||
return {
|
||||
...props,
|
||||
hasLocalData: props.data && props.data.length > 0,
|
||||
hideMenu: props.hideMenu ?? menus.hide,
|
||||
showMenu: props.showMenu ?? menus.show,
|
||||
total_rows: props.data?.length ?? getState('total_rows'),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type useStoreReturnType = ReturnType<typeof useStore>;
|
||||
|
||||
export { Provider, useStore };
|
||||
13
src/Gridler/components/sprites/Sort.ts
Normal file
13
src/Gridler/components/sprites/Sort.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Sprite, SpriteProps } from '@glideapps/glide-data-grid';
|
||||
|
||||
|
||||
|
||||
export const SortSprite: Sprite = (props: Partial<SpriteProps>) => {
|
||||
const fg = props.fgColor ?? 'currentColor';
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none">
|
||||
<path
|
||||
fill="${fg}"
|
||||
d="M2.22 9.967L7.97 4.22l.085-.074l.058-.038l.072-.039l.105-.04l.105-.022l.052-.005L8.5 4l.057.002l.092.013l.107.03l.085.037l.054.03l.063.044l.072.064l5.75 5.747a.75.75 0 0 1-.976 1.133l-.084-.072L9.25 6.56v16.69a.75.75 0 0 1-1.493.102l-.007-.102V6.56l-4.47 4.468a.75.75 0 0 1-.976.072l-.084-.072a.75.75 0 0 1-.073-.977zM19.5 4a.75.75 0 0 1 .743.648l.007.102v16.687l4.47-4.467l.084-.073a.75.75 0 0 1 1.049 1.05l-.073.083l-5.728 5.727a.75.75 0 0 1-1.031.07l-.073-.07l-5.728-5.727l-.073-.084a.75.75 0 0 1-.007-.882l.08-.094l.084-.073a.75.75 0 0 1 .882-.007l.094.08l4.47 4.469V4.75l.007-.102A.75.75 0 0 1 19.5 4"
|
||||
></path>
|
||||
</svg>`;
|
||||
};
|
||||
12
src/Gridler/components/sprites/SortDown.tsx
Normal file
12
src/Gridler/components/sprites/SortDown.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Sprite, SpriteProps } from '@glideapps/glide-data-grid';
|
||||
|
||||
export const SortDownSprite: Sprite = (props: Partial<SpriteProps>) => {
|
||||
const fg = props.fgColor ?? 'currentColor';
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none">
|
||||
<path
|
||||
fill="${fg}"
|
||||
d="M15 2.5a.5.5 0 0 0-1 0v13.793l-2.146-2.147a.5.5 0 0 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L15 16.293zM2.5 4a.5.5 0 0 0 0 1h9a.5.5 0 0 0 0-1zM5 7.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5M8.5 10a.5.5 0 0 0 0 1h3a.5.5 0 0 0 0-1z"
|
||||
></path>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
12
src/Gridler/components/sprites/SortUp.tsx
Normal file
12
src/Gridler/components/sprites/SortUp.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Sprite, SpriteProps } from '@glideapps/glide-data-grid';
|
||||
|
||||
export const SortUpSprite: Sprite = (props: Partial<SpriteProps>) => {
|
||||
const fg = props.fgColor ?? 'currentColor';
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none">
|
||||
<path
|
||||
fill="${fg}"
|
||||
d="M15 17.5a.5.5 0 0 1-1 0V3.707l-2.146 2.147a.5.5 0 0 1-.708-.708l3-3a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L15 3.707zM2.5 16a.5.5 0 0 1 0-1h9a.5.5 0 0 1 0 1zM5 12.5a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 0-1h-6a.5.5 0 0 0-.5.5M8.5 10a.5.5 0 0 1 0-1h3a.5.5 0 0 1 0 1z"
|
||||
></path>
|
||||
</svg>
|
||||
`;
|
||||
};
|
||||
7
src/Gridler/components/sprites/SpriteImage.tsx
Normal file
7
src/Gridler/components/sprites/SpriteImage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Sprite, SpriteProps } from '@glideapps/glide-data-grid';
|
||||
|
||||
export const SpriteImage = (props: { alt?:string; sprite: Sprite, } & Partial<SpriteProps>) => {
|
||||
return (
|
||||
<img alt={props.alt ?? "Sprite Image"} src={`data:image/svg+xml;utf8,${props.sprite({ ...props, sprite: undefined } as any)}`} />
|
||||
);
|
||||
};
|
||||
117
src/Gridler/hooks/use-grid-theme.ts
Normal file
117
src/Gridler/hooks/use-grid-theme.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { GetRowThemeCallback } from '@glideapps/glide-data-grid';
|
||||
|
||||
import { darken, lighten, useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useStore } from '../components/Store';
|
||||
|
||||
export const offsetRows = (colorScheme: any) => (i: number) =>
|
||||
i % 2 === 0
|
||||
? undefined
|
||||
: {
|
||||
bgCell: colorScheme === 'dark' ? '#303030' : '#e1effc'
|
||||
};
|
||||
|
||||
export const useGridTheme = () => {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { enableOddEvenRowColor, focused } = useStore((state) => ({
|
||||
enableOddEvenRowColor: state.enableOddEvenRowColor,
|
||||
focused: state.focused
|
||||
}));
|
||||
|
||||
const cellColor = {
|
||||
auto: focused ? lighten(theme.colors.blue[4], 0.9) : '#ffffff',
|
||||
dark: focused ? darken('#bacfe0', 0.2) : theme.colors.dark[7],
|
||||
light: focused ? lighten(theme.colors.blue[4], 0.8) : '#ffffff'
|
||||
}[colorScheme];
|
||||
|
||||
// if (noFocusHighlight) {
|
||||
// cellColor = {
|
||||
// dark: theme.colors.dark[8],
|
||||
// light: '#ffffff'
|
||||
// }[colorScheme];
|
||||
// }
|
||||
|
||||
const gridThemeDark = {
|
||||
accentColor: 'none',
|
||||
|
||||
accentLight: '#2824ab3c',
|
||||
|
||||
baseFontStyle: '13px',
|
||||
|
||||
bgBubble: '#212121',
|
||||
bgBubbleSelected: '#000000',
|
||||
bgCell: cellColor,
|
||||
bgCellMedium: '#202027',
|
||||
|
||||
bgHeader: '#2A2A2A',
|
||||
bgHeaderHasFocus: '#181818',
|
||||
bgHeaderHovered: '#404040',
|
||||
bgIconHeader: '#b8b8b8',
|
||||
|
||||
bgSearchResult: '#423c24',
|
||||
borderColor: '#eee1',
|
||||
drilldownBorder: 'rgba(225,225,225,0.4)',
|
||||
fgIconHeader: '#000000',
|
||||
fontFamily:
|
||||
'Inter, Roboto, -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Ubuntu, noto, arial, sans-serif',
|
||||
|
||||
headerFontStyle: '500 13px',
|
||||
linkColor: '#4F5DFF',
|
||||
|
||||
textBubble: '#ffffff',
|
||||
|
||||
textDark: '#dedede',
|
||||
textGroupHeader: '#dedede',
|
||||
|
||||
textHeader: '#a1a1a1',
|
||||
|
||||
textHeaderSelected: '#000000',
|
||||
textLight: '#a0a0a0',
|
||||
textMedium: '#b8b8b8'
|
||||
};
|
||||
|
||||
const gridThemeLight = {
|
||||
bgCell: cellColor,
|
||||
textGroupHeader: '#2c5491'
|
||||
};
|
||||
|
||||
const commonTheme = colorScheme === 'dark' ? gridThemeDark : gridThemeLight;
|
||||
|
||||
const [gridTheme, setGridTheme] = useState(commonTheme);
|
||||
|
||||
useEffect(() => {
|
||||
setGridTheme(commonTheme);
|
||||
}, [colorScheme, focused]);
|
||||
|
||||
const getRowThemeOverride: GetRowThemeCallback = (row) => {
|
||||
// const rowColor: any = {
|
||||
// auto: {
|
||||
// bgCell: '#d9e8ff',
|
||||
// bgCellMedium: '#d9e8ff'
|
||||
// },
|
||||
// dark: {
|
||||
// bgCell: '#1864ab3c',
|
||||
// bgCellMedium: '#1864ab3c'
|
||||
// },
|
||||
// light: {
|
||||
// bgCell: '#d9e8ff',
|
||||
// bgCellMedium: '#d9e8ff'
|
||||
// }
|
||||
// }[colorScheme];
|
||||
|
||||
|
||||
// for (const selectedRow of gridSelection?.rows) {
|
||||
// if (selectedRow === row) {
|
||||
// return {
|
||||
// bgCell: rowColor.bgCell,
|
||||
// bgCellMedium: rowColor.bgCellMedium
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
return enableOddEvenRowColor ? offsetRows(colorScheme)(row) : undefined;
|
||||
};
|
||||
|
||||
return { getRowThemeOverride, gridTheme };
|
||||
};
|
||||
79
src/Gridler/stories/Examples.goapi.tsx
Normal file
79
src/Gridler/stories/Examples.goapi.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Divider, Stack, TextInput } from '@mantine/core';
|
||||
import { useLocalStorage } from '@mantine/hooks';
|
||||
|
||||
import type { GridlerColumns } from '../components/Column';
|
||||
|
||||
import { APIAdaptorGoLangv2 } from '../components/APIAdaptorGoLangv2';
|
||||
import { Gridler } from '../Gridler';
|
||||
|
||||
export const GridlerGoAPIExampleEventlog = () => {
|
||||
const [apiUrl, setApiUrl] = useLocalStorage({
|
||||
defaultValue: 'http://localhost:8080/api',
|
||||
key: 'apiurl',
|
||||
});
|
||||
const [apiKey, setApiKey] = useLocalStorage({ defaultValue: '', key: 'apikey' });
|
||||
|
||||
const columns: GridlerColumns = [
|
||||
{
|
||||
id: 'rid_atevent',
|
||||
title: 'RID',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
Cell: (_row, _col, _colindex, value) => {
|
||||
return {
|
||||
cursor: 'crosshair',
|
||||
data: value,
|
||||
displayData: `- ${value}`,
|
||||
kind: 'text',
|
||||
};
|
||||
},
|
||||
grow: 1,
|
||||
id: 'changedate',
|
||||
title: 'Date',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
id: 'changetime',
|
||||
title: 'Time',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
id: 'changeuser',
|
||||
title: 'User',
|
||||
},
|
||||
|
||||
{
|
||||
id: 'actionx',
|
||||
title: 'Action',
|
||||
width: 100,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack h="80vh">
|
||||
<h2>Demo Using Go API Adaptor</h2>
|
||||
<TextInput label="API Url" onChange={(e) => setApiUrl(e.target.value)} value={apiUrl} />
|
||||
<TextInput label="API Key" onChange={(e) => setApiKey(e.target.value)} value={apiKey} />
|
||||
<Divider />
|
||||
<Gridler
|
||||
columns={columns}
|
||||
getMenuItems={(id, _state, row, col, defaultItems) => {
|
||||
return [
|
||||
...(defaultItems ?? []),
|
||||
{
|
||||
id: 'test',
|
||||
label: `Test ${id}`,
|
||||
onClick: () => {
|
||||
console.log('Test clicked', row, col);
|
||||
},
|
||||
},
|
||||
];
|
||||
}}
|
||||
uniqueid="gridtest"
|
||||
>
|
||||
<APIAdaptorGoLangv2 authtoken={apiKey} url={`${apiUrl}/core/atevent`} />
|
||||
</Gridler>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
63
src/Gridler/stories/Examples.localdata.tsx
Normal file
63
src/Gridler/stories/Examples.localdata.tsx
Normal file
File diff suppressed because one or more lines are too long
37
src/Gridler/stories/Gridler.goapi.stories.tsx
Normal file
37
src/Gridler/stories/Gridler.goapi.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Box } from '@mantine/core';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { GridlerGoAPIExampleEventlog } from './Examples.goapi';
|
||||
|
||||
const Renderable = (props: any) => {
|
||||
return <Box h="100%" mih="400px" miw="400px" w='100%' > <GridlerGoAPIExampleEventlog {...props} /></Box>;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
title: 'Grid/Gridler API',
|
||||
} satisfies Meta<typeof Renderable>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const BasicExample: Story = {
|
||||
args: {
|
||||
label: 'Test',
|
||||
},
|
||||
};
|
||||
37
src/Gridler/stories/Gridler.localdata.stories.tsx
Normal file
37
src/Gridler/stories/Gridler.localdata.stories.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Box } from '@mantine/core';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { GridlerLocaldataExampleEventlog } from './Examples.localdata';
|
||||
|
||||
const Renderable = (props: any) => {
|
||||
return <Box h="100%" mih="400px" miw="400px" w='100%' > <GridlerLocaldataExampleEventlog {...props} /></Box>;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
title: 'Grid/Gridler Local',
|
||||
} satisfies Meta<typeof Renderable>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const BasicExample: Story = {
|
||||
args: {
|
||||
label: 'Test',
|
||||
},
|
||||
};
|
||||
81
src/Gridler/utils/golang-restapi-v2/types.ts
Normal file
81
src/Gridler/utils/golang-restapi-v2/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
export type APIOptionsType = {
|
||||
autocreate?: boolean
|
||||
autoref?: boolean
|
||||
baseurl?: string
|
||||
getAPIProvider?: () => { provider: string; providerKey: string }
|
||||
getAuthToken?: () => string
|
||||
operations?: Array<FetchAPIOperation>
|
||||
postfix?: string
|
||||
prefix?: string
|
||||
requestTimeoutSec?: number
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface APIResponse {
|
||||
errmsg: string
|
||||
payload?: any
|
||||
retval: number
|
||||
}
|
||||
export interface FetchAPIOperation {
|
||||
name?: string
|
||||
op?: string
|
||||
type: FetchOpTypes //x-fieldfilter
|
||||
value: string
|
||||
}
|
||||
export type FetchOpTypes = GoAPIEnum & string
|
||||
|
||||
|
||||
/**
|
||||
* @description Types for the Go Rest API headers
|
||||
* @typedef {String} GoAPIEnum
|
||||
*/
|
||||
export type GoAPIEnum = 'advsql'
|
||||
| 'api-key'
|
||||
| 'api-range-from'
|
||||
| 'api-range-size'
|
||||
| 'api-range-total'
|
||||
| 'api-src'
|
||||
| 'api'
|
||||
| 'association_autocreate'
|
||||
| 'association_autoupdate'
|
||||
| 'association-update'
|
||||
| 'cql-sel'
|
||||
| 'custom-sql-join'
|
||||
| 'custom-sql-or'
|
||||
| 'custom-sql-w'
|
||||
| 'detailapi'
|
||||
| 'distinct'
|
||||
| 'expand'
|
||||
| 'fetch-rownumber'
|
||||
| 'fieldfilter'
|
||||
| 'fieldfilter'
|
||||
| 'func'
|
||||
| 'limit'
|
||||
| 'no-return'
|
||||
| 'not-select-fields'
|
||||
| 'offset'
|
||||
| 'parm'
|
||||
| 'pkrow'
|
||||
| 'preload'
|
||||
| 'searchfilter'
|
||||
| 'searchfilter'
|
||||
| 'searchop'
|
||||
| 'searchop'
|
||||
| 'select-fields'
|
||||
| 'simpleapi'
|
||||
| 'skipcache'
|
||||
| 'skipcount'
|
||||
| 'sort'
|
||||
|
||||
|
||||
export type GoAPIHeaderKeys = `x-${GoAPIEnum}`
|
||||
|
||||
|
||||
export type MetaCallback = (data: MetaData) => void
|
||||
|
||||
export interface MetaData {
|
||||
limit?: number
|
||||
offset?: number
|
||||
total?: number
|
||||
}
|
||||
0
src/Gridler/utils/index.ts
Normal file
0
src/Gridler/utils/index.ts
Normal file
37
src/Gridler/utils/range.ts
Normal file
37
src/Gridler/utils/range.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
function range(end: number): number[];
|
||||
function range(start: number, end: number): number[];
|
||||
function range(start: number, end: number, step: number): number[];
|
||||
function range(startOrEnd: number, end?: number, step: number = 1): number[] {
|
||||
let start: number;
|
||||
|
||||
// Handle single argument case: range(4) -> [0, 1, 2, 3]
|
||||
if (end === undefined) {
|
||||
start = 0;
|
||||
|
||||
end = startOrEnd;
|
||||
} else {
|
||||
start = startOrEnd;
|
||||
}
|
||||
|
||||
// Validate step
|
||||
if (step === 0) {
|
||||
throw new Error('Step cannot be zero');
|
||||
}
|
||||
|
||||
const result: number[] = [];
|
||||
|
||||
if (step > 0) {
|
||||
for (let i = start; i < end; i += step) {
|
||||
result.push(i);
|
||||
}
|
||||
} else {
|
||||
for (let i = start; i > end; i += step) {
|
||||
result.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export { range };
|
||||
18
src/Gridler/utils/types.ts
Normal file
18
src/Gridler/utils/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type APIType = 'gorest' |'gorest2'| 'resolvespec';
|
||||
|
||||
export const APITypes: Record<string, APIType> = {
|
||||
GoRest: 'gorest',
|
||||
GoRest2: 'gorest2',
|
||||
ResolveSpec: 'resolvespec'
|
||||
} as const;
|
||||
|
||||
export interface APIOptions {
|
||||
authtoken?: string;
|
||||
primaryKey?: string;
|
||||
schemaName?: string;
|
||||
tableName?: string;
|
||||
type?: APIType;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export type TRequest = 'change' | 'delete' | 'insert' | 'select' ;
|
||||
2
src/MantineBetterMenu/MantineBetterMenu.d.ts
vendored
Normal file
2
src/MantineBetterMenu/MantineBetterMenu.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { type MenuStoreProps } from './Store';
|
||||
export declare function MantineBetterMenusProvider(props: React.PropsWithChildren<MenuStoreProps>): import("react/jsx-runtime").JSX.Element;
|
||||
51
src/MantineBetterMenu/MantineBetterMenu.stories.tsx
Normal file
51
src/MantineBetterMenu/MantineBetterMenu.stories.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Button } from '@mantine/core';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { MantineBetterMenusProvider, useMantineBetterMenus } from './';
|
||||
|
||||
|
||||
const Renderable = (props: Record<string,unknown>) => {
|
||||
return (
|
||||
<MantineBetterMenusProvider providerID='test' {...props} >
|
||||
<Menu/>
|
||||
</MantineBetterMenusProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const Menu = () => {
|
||||
const menus = useMantineBetterMenus();
|
||||
//menus.setState("menus",[{id:"test",items:[{id:"1",label:"Test",onClick:()=>{console.log("Clicked")}}]}])
|
||||
|
||||
return <Button onClick={()=> menus.show("test",{})}>Menu</Button>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||
argTypes: {
|
||||
backgroundColor: { control: 'color' },
|
||||
},
|
||||
component: Renderable,
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: 'centered',
|
||||
},
|
||||
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||
tags: ['autodocs'],
|
||||
title: 'UI/Mantine Better Menu',
|
||||
} satisfies Meta<typeof Renderable>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const BasicExample: Story = {
|
||||
args: {
|
||||
|
||||
label: 'Test',
|
||||
},
|
||||
};
|
||||
|
||||
11
src/MantineBetterMenu/MantineBetterMenu.tsx
Normal file
11
src/MantineBetterMenu/MantineBetterMenu.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { MenuRenderer } from './MenuRenderer';
|
||||
import { MantineBetterMenusStoreProvider, type MenuStoreProps } from './Store';
|
||||
|
||||
export function MantineBetterMenusProvider(props: React.PropsWithChildren<MenuStoreProps>) {
|
||||
return (
|
||||
<MantineBetterMenusStoreProvider {...props}>
|
||||
<MenuRenderer />
|
||||
{props.children}
|
||||
</MantineBetterMenusStoreProvider>
|
||||
);
|
||||
}
|
||||
1
src/MantineBetterMenu/MenuRenderer.d.ts
vendored
Normal file
1
src/MantineBetterMenu/MenuRenderer.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare function MenuRenderer(): import("react/jsx-runtime").JSX.Element;
|
||||
90
src/MantineBetterMenu/MenuRenderer.tsx
Normal file
90
src/MantineBetterMenu/MenuRenderer.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Menu, Portal } from '@mantine/core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { type MantineBetterMenuInstanceItem, useMantineBetterMenus } from './Store';
|
||||
|
||||
export function MenuRenderer() {
|
||||
const { menus, providerID, setInstanceState } = useMantineBetterMenus((s) => ({
|
||||
menus: s.menus,
|
||||
providerID: s.providerID,
|
||||
setInstanceState: s.setInstanceState,
|
||||
setState: s.setState
|
||||
}));
|
||||
|
||||
return (
|
||||
<Portal id={`bmm_portal_${providerID}`} key={`bmm_portal_${providerID}`}>
|
||||
{React.Children.toArray(
|
||||
menus?.map((m, menuIndex) => {
|
||||
return (
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={200}
|
||||
{...m.menuProps}
|
||||
key={`bmm_menu_${providerID}_${menuIndex}`}
|
||||
onClose={() => {
|
||||
setInstanceState(m.id, 'visible', false);
|
||||
m.menuProps?.onClose?.();
|
||||
}}
|
||||
opened={m.visible}
|
||||
>
|
||||
<Menu.Target>
|
||||
<div
|
||||
id={`bmm_${providerID}_${menuIndex}_target`}
|
||||
style={{ left: m.x, position: 'fixed', top: m.y, visibility: 'hidden' }}
|
||||
/>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
{m.renderer
|
||||
? m.renderer
|
||||
: React.Children.toArray(
|
||||
m.items?.map((item, itemIndex) => (
|
||||
<MenuItemRenderer
|
||||
key={`bmm_${providerID}_${menuIndex}_item${itemIndex}`}
|
||||
{...item}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstanceItem) => {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
if (typeof props.renderer === 'function') {
|
||||
return props.renderer({ ...props, loading, renderer: undefined, setLoading });
|
||||
}
|
||||
if (typeof props.renderer === 'object') {
|
||||
return props.renderer;
|
||||
}
|
||||
|
||||
if (props.isDivider) {
|
||||
return <Menu.Divider />;
|
||||
}
|
||||
|
||||
if (!props.onClick && !props.onClickAsync) {
|
||||
return <Menu.Label {...(props as Record<string,unknown>)}> {children ?? label}</Menu.Label>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
{...props}
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
props.onClick?.(e );
|
||||
if (props.onClickAsync) {
|
||||
setLoading(true);
|
||||
props.onClickAsync().finally(() => setLoading(false));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children ?? label}
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
43
src/MantineBetterMenu/Store.d.ts
vendored
Normal file
43
src/MantineBetterMenu/Store.d.ts
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type MenuItemProps, type MenuProps } from '@mantine/core';
|
||||
import { type ReactNode } from 'react';
|
||||
export interface MantineBetterMenuInstance {
|
||||
id: string;
|
||||
items?: Array<MantineBetterMenuInstanceItem>;
|
||||
menuProps?: MenuProps;
|
||||
renderer?: ReactNode;
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
export interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
|
||||
isDivider?: boolean;
|
||||
label?: string;
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onClickAsync?: () => Promise<void>;
|
||||
renderer?: ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode) | ReactNode;
|
||||
}
|
||||
export interface MenuStoreProps {
|
||||
providerID?: string;
|
||||
}
|
||||
export type MenuStoreState = MenuStoreProps & MenuStoreStateOnly;
|
||||
export interface MenuStoreStateOnly {
|
||||
hide: (id: string) => void;
|
||||
menus: Array<MantineBetterMenuInstance>;
|
||||
setInstanceState: <K extends keyof MantineBetterMenuInstance>(instanceID: string, key: K, value: MantineBetterMenuInstance[K]) => void;
|
||||
setState: <K extends keyof MenuStoreState>(key: K, value: Partial<MenuStoreState[K]>) => void;
|
||||
show: (id: string, options?: Partial<MantineBetterMenuInstance>) => void;
|
||||
}
|
||||
declare const MantineBetterMenusStoreProvider: (props: {
|
||||
children: ReactNode;
|
||||
} & {
|
||||
firstSyncProps?: string[];
|
||||
persist?: import("zustand/middleware").PersistOptions<Partial<MenuStoreProps & MenuStoreStateOnly>, Partial<MenuStoreProps & MenuStoreStateOnly>, unknown> | undefined;
|
||||
} & MenuStoreProps) => React.ReactNode, useMantineBetterMenus: {
|
||||
(): {
|
||||
$sync?: ((props: MenuStoreProps) => void) | undefined;
|
||||
} & MenuStoreProps & MenuStoreStateOnly;
|
||||
<U>(selector: (state: {
|
||||
$sync?: ((props: MenuStoreProps) => void) | undefined;
|
||||
} & MenuStoreProps & MenuStoreStateOnly) => U, equalityFn?: ((a: U, b: U) => boolean) | undefined): U;
|
||||
};
|
||||
export { MantineBetterMenusStoreProvider, useMantineBetterMenus };
|
||||
104
src/MantineBetterMenu/Store.tsx
Normal file
104
src/MantineBetterMenu/Store.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { type MenuItemProps, type MenuProps } from '@mantine/core';
|
||||
import { getUUID } from '@warkypublic/artemis-kit';
|
||||
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||
import { produce } from 'immer';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export interface MantineBetterMenuInstance {
|
||||
id: string;
|
||||
items?: Array<MantineBetterMenuInstanceItem>;
|
||||
menuProps?: MenuProps;
|
||||
renderer?: ReactNode;
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface MantineBetterMenuInstanceItem extends Partial<MenuItemProps> {
|
||||
isDivider?: boolean;
|
||||
label?: string;
|
||||
onClick?: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onClickAsync?: () => Promise<void>;
|
||||
renderer?:
|
||||
| ((props: MantineBetterMenuInstanceItem & Record<string, unknown>) => ReactNode)
|
||||
| ReactNode;
|
||||
}
|
||||
|
||||
export interface MenuStoreProps {
|
||||
menus?: Array<MantineBetterMenuInstance>;
|
||||
providerID?: string;
|
||||
}
|
||||
|
||||
export type MenuStoreState = MenuStoreProps & MenuStoreStateOnly;
|
||||
|
||||
export interface MenuStoreStateOnly {
|
||||
hide: (id: string) => void;
|
||||
menus: Array<MantineBetterMenuInstance>;
|
||||
setInstanceState: <K extends keyof MantineBetterMenuInstance>(
|
||||
instanceID: string,
|
||||
key: K,
|
||||
value: MantineBetterMenuInstance[K]
|
||||
) => void;
|
||||
setState: <K extends keyof MenuStoreState>(key: K, value: Partial<MenuStoreState[K]>) => void;
|
||||
show: (id: string, options?: Partial<MantineBetterMenuInstance>) => void;
|
||||
}
|
||||
|
||||
const { Provider:MantineBetterMenusStoreProvider, useStore:useMantineBetterMenus } = createSyncStore<MenuStoreState, MenuStoreProps>(
|
||||
(set, get) => ({
|
||||
hide: (id: string) => {
|
||||
const s = get();
|
||||
s.setInstanceState(id, 'visible', false);
|
||||
},
|
||||
menus: [],
|
||||
setInstanceState: (id, key, value) => {
|
||||
//@ts-expect-error Type instantiation is excessively deep and possibly infinite.
|
||||
set(
|
||||
produce((state: MenuStoreState) => {
|
||||
const idx = state?.menus?.findIndex((m: MantineBetterMenuInstance) => m.id === id);
|
||||
if (idx >= 0) {
|
||||
state.menus[idx][key] = value;
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
setState: (key, value) => {
|
||||
set(
|
||||
produce((state) => {
|
||||
state[key] = value;
|
||||
})
|
||||
);
|
||||
},
|
||||
show: (id: string, options?: Partial<Omit<MantineBetterMenuInstance, 'id'>>) => {
|
||||
const s = get();
|
||||
const menuIndex = s.menus.findIndex((m) => m.id === id);
|
||||
const menu: Partial<MantineBetterMenuInstance> = s.menus[menuIndex]
|
||||
? { ...s.menus[menuIndex] }
|
||||
: {};
|
||||
|
||||
Object.assign(menu, options);
|
||||
menu.id = menu.id ?? id;
|
||||
menu.visible = !(menu.visible ?? false);
|
||||
|
||||
if (menuIndex < 0) {
|
||||
s.setState('menus', [...s.menus, menu as MantineBetterMenuInstance]);
|
||||
} else {
|
||||
set(
|
||||
produce((state: MenuStoreState) => {
|
||||
if (!state.menus) {
|
||||
state.menus = [];
|
||||
}
|
||||
state.menus[menuIndex] = { ...state.menus[menuIndex], ...menu };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
(props) => {
|
||||
return {
|
||||
providerID: props.providerID ?? `MenuStore-${getUUID()}`
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export { MantineBetterMenusStoreProvider,useMantineBetterMenus };
|
||||
3
src/MantineBetterMenu/index.d.ts
vendored
Normal file
3
src/MantineBetterMenu/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
export { MantineBetterMenusProvider } from './MantineBetterMenu';
|
||||
export { useMantineBetterMenus } from './Store';
|
||||
export type { MantineBetterMenuInstance, MantineBetterMenuInstanceItem, MenuStoreState } from './Store';
|
||||
7
src/MantineBetterMenu/index.ts
Normal file
7
src/MantineBetterMenu/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { MantineBetterMenusProvider } from './MantineBetterMenu';
|
||||
export { useMantineBetterMenus } from './Store';
|
||||
export type {
|
||||
MantineBetterMenuInstance,
|
||||
MantineBetterMenuInstanceItem,
|
||||
MenuStoreState
|
||||
} from './Store';
|
||||
1
src/globals.d.ts
vendored
Normal file
1
src/globals.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.css';
|
||||
1
src/lib.d.ts
vendored
Normal file
1
src/lib.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { type MantineBetterMenuInstance, type MantineBetterMenuInstanceItem, MantineBetterMenusProvider, type MenuStoreState, useMantineBetterMenus, } from "./MantineBetterMenu";
|
||||
7
src/lib.ts
Normal file
7
src/lib.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type MantineBetterMenuInstance,
|
||||
type MantineBetterMenuInstanceItem,
|
||||
MantineBetterMenusProvider,
|
||||
type MenuStoreState,
|
||||
useMantineBetterMenus,
|
||||
} from "./MantineBetterMenu";
|
||||
Reference in New Issue
Block a user