From 4186219c501e050695acff2785a99f370fb1f80e Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 20 Oct 2025 16:00:21 +0200 Subject: [PATCH] Grid selection --- .../components/GridlerFormInterface.tsx | 94 ++++++++++++--- src/Gridler/components/Store.tsx | 64 +++++++++- src/Gridler/stories/Examples.goapi.tsx | 1 + src/MantineBetterMenu/MenuRenderer.tsx | 59 +++++++++- src/MantineBetterMenu/Store.tsx | 109 +++++++++--------- 5 files changed, 246 insertions(+), 81 deletions(-) diff --git a/src/Gridler/components/GridlerFormInterface.tsx b/src/Gridler/components/GridlerFormInterface.tsx index c78c4fb..e9f1ac1 100644 --- a/src/Gridler/components/GridlerFormInterface.tsx +++ b/src/Gridler/components/GridlerFormInterface.tsx @@ -1,3 +1,10 @@ +import { + IconEdit, + IconExclamationMark, + IconRefresh, + IconSquarePlus, + IconTrashX, +} from '@tabler/icons-react'; import { useCallback, useEffect } from 'react'; import type { MantineBetterMenuInstanceItem } from '../../MantineBetterMenu'; @@ -7,9 +14,14 @@ import type { GridlerColumn } from './Column'; import { type GridlerProps, type GridlerState, useGridlerStore } from './Store'; export function GlidlerFormInterface(props: { + descriptionField?: ((data: Record) => string) | string; getMenuItems?: GridlerProps['getMenuItems']; onReload?: () => void; - onRequestForm: (request: FormRequestType, data: Record) => void; + onRequestForm: ( + request: FormRequestType, + data: Array> | Record + ) => void; + showDescriptionInMenu?: boolean; }) { const [getState, mounted, setState, reload] = useGridlerStore((s) => [ s.getState, @@ -22,7 +34,7 @@ export function GlidlerFormInterface(props: { ( id: string, storeState: GridlerState, - row?: unknown, + row?: Record, col?: GridlerColumn, defaultItems?: Array ) => { @@ -33,13 +45,26 @@ export function GlidlerFormInterface(props: { } const items = [] as Array; + if (defaultItems && id === 'cell') { + items.push(...(defaultItems as Array)); + } + const rows = getState('_gridSelection')?.rows.toArray() ?? []; + const manyRows = rows.length > 1; + if (!row) { - const firstRow = getState('_gridSelection')?.rows?.first(); + const firstRow = rows[0]; if (firstRow !== undefined) { row = storeState.getRowBuffer(firstRow); } } + const desc = + typeof props.descriptionField === 'string' + ? row?.[props.descriptionField] + : typeof props.descriptionField === 'function' && row + ? props.descriptionField(row) + : undefined; + if (id === 'other') { items.push({ c: 'blue', @@ -51,35 +76,70 @@ export function GlidlerFormInterface(props: { } if ((id === 'cell' && row) || (id === 'menu' && row)) { items.push({ - c: 'blue', + c: 'teal', label: 'Add', + leftSection: , onClick: () => { props.onRequestForm('insert', row as Record); }, }); + if (!manyRows) { + items.push({ + c: 'green', + label: `Modify${desc && props.showDescriptionInMenu ? ` (${desc})` : ''}`, + leftSection: , + onClick: () => { + props.onRequestForm('change', row as Record); + }, + }); + items.push({ + c: 'red', + label: `Remove${desc && props.showDescriptionInMenu ? ` (${desc})` : ''}`, + leftSection: , + onClick: () => { + props.onRequestForm('delete', row as Record); + }, + }); + } else { + items.push({ + c: 'green', + label: `Modify All Selected (${rows.length})`, + leftSection: , + onClick: () => { + props.onRequestForm( + 'change', + rows.map((r) => storeState.getRowBuffer(r)) as Array> + ); + }, + }); + items.push({ + c: 'red', + label: `Remove All Selected (${rows.length})`, + leftSection: , + onClick: () => { + props.onRequestForm( + 'delete', + rows.map((r) => storeState.getRowBuffer(r)) as Array> + ); + }, + }); + } + items.push({ - c: 'green', - label: 'Change', - onClick: () => { - props.onRequestForm('change', row as Record); - }, + isDivider: true, }); + } else if ((id === 'cell' && !row) || (id === 'menu' && !row)) { items.push({ c: 'red', - label: 'Delete', - onClick: () => { - props.onRequestForm('delete', row as Record); - }, + label: `Nothing Selected`, + leftSection: , }); } - items.push({ - isDivider: true, - }); - items.push({ c: 'orange', label: 'Refresh', + leftSection: , onClick: () => { reload?.(); }, diff --git a/src/Gridler/components/Store.tsx b/src/Gridler/components/Store.tsx index e545adb..bca04f8 100644 --- a/src/Gridler/components/Store.tsx +++ b/src/Gridler/components/Store.tsx @@ -32,6 +32,7 @@ import { ColumnFilterSet, type GridlerColumn, type GridlerColumns } from './Colu import { SortDownSprite } from './sprites/SortDown'; import { SortUpSprite } from './sprites/SortUp'; import { SpriteImage } from './sprites/SpriteImage'; +import { IconGrid4x4 } from '@tabler/icons-react'; export type FilterOption = { datatype?: 'array' | 'boolean' | 'date' | 'function' | 'number' | 'object' | 'string'; @@ -418,14 +419,65 @@ const { Provider, useStore: useGridlerStore } = createSyncStore, + onClick: () => { + s.setStateFN('colSort', (c) => { + const cols = [...(c ?? [])]; + const idx = cols.findIndex((search) => search.id === coldef.id); + const dir = 'asc'; + if (idx < 0) { + const newSort: SortOption = { + direction: dir, + id: coldef.id, + order: cols?.length, + }; + cols.push(newSort); + } else if (idx >= 0) { + cols[idx].direction = dir; + } + + return cols; + }); + }, + }, + { + label: 'Sort Descending', + leftSection: , + onClick: () => { + s.setStateFN('colSort', (c) => { + const cols = [...(c ?? [])]; + const idx = cols.findIndex((search) => search.id === coldef.id); + const dir = 'desc'; + if (idx < 0) { + const newSort: SortOption = { + direction: dir, + id: coldef.id, + order: cols?.length, + }; + cols.push(newSort); + } else if (idx >= 0) { + cols[idx].direction = dir; + } + + return cols; + }); + }, + }, + { + label: `Filter ${coldef?.title ?? coldef?.id}`, + }, + { + renderer: , + }, + ], + label: `Column Settings for ${coldef?.title ?? coldef?.id}`, + leftSection: , }, ] - : [ - { - label: `No Column ${area}`, - }, - ]; + : []; s.hideMenu?.(area); s.showMenu?.(area, { diff --git a/src/Gridler/stories/Examples.goapi.tsx b/src/Gridler/stories/Examples.goapi.tsx index acd6a8c..7ca7403 100644 --- a/src/Gridler/stories/Examples.goapi.tsx +++ b/src/Gridler/stories/Examples.goapi.tsx @@ -94,6 +94,7 @@ export const GridlerGoAPIExampleEventlog = () => { > { console.log('Form requested', request, data); }} diff --git a/src/MantineBetterMenu/MenuRenderer.tsx b/src/MantineBetterMenu/MenuRenderer.tsx index 962bca6..2115e6d 100644 --- a/src/MantineBetterMenu/MenuRenderer.tsx +++ b/src/MantineBetterMenu/MenuRenderer.tsx @@ -1,14 +1,15 @@ -import { Menu, Portal } from '@mantine/core'; +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) => ({ + const { menus, providerID, setInstanceState, width } = useMantineBetterMenus((s) => ({ menus: s.menus, providerID: s.providerID, setInstanceState: s.setInstanceState, - setState: s.setState + setState: s.setState, + width: s.width, })); return ( @@ -18,7 +19,7 @@ export function MenuRenderer() { return ( { @@ -68,8 +69,47 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan return ; } + if (props.items && props.items.length > 0) { + return ( + + + { + props.onClick?.(e); + if (props.onClickAsync) { + setLoading(true); + props.onClickAsync().finally(() => setLoading(false)); + } + }} + styles={{ + itemLabel: { + overflow: 'auto', + wordWrap: 'break-word', + }, + ...props.styles, + }} + > + {children ?? label} + + + + {React.Children.toArray( + props.items.map((subitem, subitemIndex) => ( + + )) + )} + + + ); + } + if (!props.onClick && !props.onClickAsync) { - return )}> {children ?? label}; + return )}> {children ?? label}; } return ( @@ -77,12 +117,19 @@ const MenuItemRenderer = ({ children, label, ...props }: MantineBetterMenuInstan {...props} disabled={loading} onClick={(e) => { - props.onClick?.(e ); + props.onClick?.(e); if (props.onClickAsync) { setLoading(true); props.onClickAsync().finally(() => setLoading(false)); } }} + styles={{ + itemLabel: { + overflow: 'auto', + wordWrap: 'break-word', + }, + ...props.styles, + }} > {children ?? label} diff --git a/src/MantineBetterMenu/Store.tsx b/src/MantineBetterMenu/Store.tsx index 20a5948..16e7662 100644 --- a/src/MantineBetterMenu/Store.tsx +++ b/src/MantineBetterMenu/Store.tsx @@ -11,12 +11,15 @@ export interface MantineBetterMenuInstance { menuProps?: MenuProps; renderer?: ReactNode; visible: boolean; + x: number; y: number; } export interface MantineBetterMenuInstanceItem extends Partial { + id?: string; isDivider?: boolean; + items?: Array; label?: string; onClick?: (e?: React.MouseEvent) => void; onClickAsync?: () => Promise; @@ -28,6 +31,7 @@ export interface MantineBetterMenuInstanceItem extends Partial { export interface MenuStoreProps { menus?: Array; providerID?: string; + width?: number; } export type MenuStoreState = MenuStoreProps & MenuStoreStateOnly; @@ -44,61 +48,62 @@ export interface MenuStoreStateOnly { show: (id: string, options?: Partial) => void; } -const { Provider:MantineBetterMenusStoreProvider, useStore:useMantineBetterMenus } = createSyncStore( - (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>) => { - const s = get(); - const menuIndex = s.menus.findIndex((m) => m.id === id); - const menu: Partial = 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 { +const { Provider: MantineBetterMenusStoreProvider, useStore: useMantineBetterMenus } = + createSyncStore( + (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) => { - if (!state.menus) { - state.menus = []; + const idx = state?.menus?.findIndex((m: MantineBetterMenuInstance) => m.id === id); + if (idx >= 0) { + state.menus[idx][key] = value; } - state.menus[menuIndex] = { ...state.menus[menuIndex], ...menu }; }) ); - } - } - }), - (props) => { - return { - providerID: props.providerID ?? `MenuStore-${getUUID()}` - }; - } -); + }, + setState: (key, value) => { + set( + produce((state) => { + state[key] = value; + }) + ); + }, + show: (id: string, options?: Partial>) => { + const s = get(); + const menuIndex = s.menus.findIndex((m) => m.id === id); + const menu: Partial = s.menus[menuIndex] + ? { ...s.menus[menuIndex] } + : {}; -export { MantineBetterMenusStoreProvider,useMantineBetterMenus }; + 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 };