This commit is contained in:
Hein
2025-09-19 14:06:53 +02:00
commit 46dabed765
55 changed files with 9856 additions and 0 deletions

View 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
View 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>
);
};

View 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>
);
};

View 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 <></>;
});

View 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>
);
};

View 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 <></>;
});

View 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 <></>;
});

View 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 };

View 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>`;
};

View 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>
`;
};

View 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>
`;
};

View 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)}`} />
);
};

View 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 };
};

View 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>
);
};

File diff suppressed because one or more lines are too long

View 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',
},
};

View 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',
},
};

View 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
}

View File

View 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 };

View 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' ;