diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx index 5b55585e..792b975c 100644 --- a/src/components/Grid.tsx +++ b/src/components/Grid.tsx @@ -4,7 +4,7 @@ import { GridOptions } from "ag-grid-community/dist/lib/entities/gridOptions"; import { CellEvent, GridReadyEvent, SelectionChangedEvent } from "ag-grid-community/dist/lib/events"; import { AgGridReact } from "ag-grid-react"; import clsx from "clsx"; -import { difference, isEmpty, last, omit, xorBy } from "lodash-es"; +import { defer, difference, isEmpty, last, omit, xorBy } from "lodash-es"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { GridContext } from "../contexts/GridContext"; @@ -156,13 +156,13 @@ export const Grid = ({ timeoutMs: 1000, }); - const previousGridReady = useRef(gridReady); + /*const previousGridReady = useRef(gridReady); useEffect(() => { if (!previousGridReady.current && gridReady) { previousGridReady.current = true; setInitialContentSize(); } - }, [gridReady, setInitialContentSize]); + }, [gridReady, setInitialContentSize]);*/ /** * On data load select the first row of the grid if required. @@ -491,11 +491,13 @@ export const Grid = ({ if (!isEmpty(colIdsEdited.current)) { const skipHeader = sizeColumns === "auto-skip-headers"; if (sizeColumns === "auto" || skipHeader) { - autoSizeColumns({ - skipHeader, - userSizedColIds: userSizedColIds.current, - colIds: colIdsEdited.current, - }); + defer(() => + autoSizeColumns({ + skipHeader, + userSizedColIds: userSizedColIds.current, + colIds: new Set(colIdsEdited.current), + }), + ); } colIdsEdited.current.clear(); } @@ -565,6 +567,7 @@ export const Grid = ({ onColumnVisible={() => { setInitialContentSize(); }} + onFirstDataRendered={setInitialContentSize} onRowDataChanged={onRowDataChanged} onCellKeyPress={onCellKeyPress} onCellClicked={onCellClicked} diff --git a/src/components/gridForm/GridFormMultiSelectGrid.tsx b/src/components/gridForm/GridFormMultiSelectGrid.tsx new file mode 100644 index 00000000..e385efa1 --- /dev/null +++ b/src/components/gridForm/GridFormMultiSelectGrid.tsx @@ -0,0 +1,170 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { LuiCheckboxInput } from "@linzjs/lui"; + +import { useGridPopoverContext } from "../../contexts/GridPopoverContext"; +import { MenuItem } from "../../react-menu3"; +import { ClickEvent } from "../../react-menu3/types"; +import { wait } from "../../utils/util"; +import { GridBaseRow } from "../Grid"; +import { CellEditorCommon } from "../GridCell"; +import { GridIcon } from "../GridIcon"; +import { useGridPopoverHook } from "../GridPopoverHook"; + +export interface MultiSelectGridOption { + value: any; + label?: string; + checked?: boolean | "partial"; + canSelectPartial?: boolean; + warning?: string | undefined; +} + +export interface GridFormMultiSelectGridSaveProps { + selectedRows: RowType[]; + addValues: any[]; + removeValues: any[]; +} + +export interface GridFormMultiSelectGridProps extends CellEditorCommon { + className?: string | undefined; + noOptionsMessage?: string; + onSave?: (props: GridFormMultiSelectGridSaveProps) => Promise; + options: + | MultiSelectGridOption[] + | ((selectedRows: RowType[]) => Promise | MultiSelectGridOption[]); + invalid?: (props: GridFormMultiSelectGridSaveProps) => boolean; +} + +export const GridFormMultiSelectGrid = ( + props: GridFormMultiSelectGridProps, +): JSX.Element => { + const { selectedRows } = useGridPopoverContext(); + const optionsInitialising = useRef(false); + + const [initialValues, setInitialValues] = useState(); + const [options, setOptions] = useState(); + + const genSave = useCallback( + (selectedRows: RowType[]): GridFormMultiSelectGridSaveProps => { + if (!options) return { selectedRows, addValues: [], removeValues: [] }; + + const preChecked = (initialValues ?? []).filter((o) => o.checked).map((o) => o.value); + const postNotChecked = options.filter((o) => o.checked === false).map((o) => o.value); + const removeValues = preChecked.filter((p) => postNotChecked.some((c) => c === p)); + + const preNotChecked = (initialValues ?? []).filter((o) => o.checked !== true).map((o) => o.value); + const postChecked = options.filter((o) => o.checked === true).map((o) => o.value); + const addValues = preNotChecked.filter((p) => postChecked.some((c) => c === p)); + + return { selectedRows, addValues, removeValues }; + }, + [initialValues, options], + ); + + const save = useCallback( + async (selectedRows: RowType[]): Promise => { + if (!options || !props.onSave) return true; + + // Any changes to save? + if (JSON.stringify(initialValues) === JSON.stringify(options)) return true; + + if (!props.onSave) return true; + await wait(1); + return await props.onSave(genSave(selectedRows)); + }, + [genSave, initialValues, options, props], + ); + + const invalid = useCallback(() => { + if (!options) return true; + return props.invalid && props.invalid(genSave(selectedRows)); + }, [genSave, options, props, selectedRows]); + + const { popoverWrapper } = useGridPopoverHook({ + className: props.className, + save, + invalid, + }); + + // Load up options list if it's async function + useEffect(() => { + if (options || optionsInitialising.current) return; + optionsInitialising.current = true; + + (async () => { + const optionsList = typeof props.options === "function" ? await props.options(selectedRows) : props.options; + setInitialValues(JSON.parse(JSON.stringify(optionsList))); + setOptions(optionsList); + optionsInitialising.current = false; + })(); + }, [options, props, selectedRows]); + + const toggleValue = useCallback( + (item: MultiSelectGridOption) => { + if (options) { + item.checked = item.checked === true && item.canSelectPartial ? "partial" : !item.checked; + setOptions([...options]); + } + }, + [options, setOptions], + ); + + return popoverWrapper( + <> +
+
+ {options && + options.map((o) => ( + <> + { + // Global react-menu MenuItem handler handles tabs + if (e.key !== "Tab" && e.key !== "Enter") { + e.keepOpen = true; + toggleValue(o); + } + }} + > + + {o.warning && } + + {o.label ?? (o.value == null ? `<${o.value}>` : `${o.value}`)} + + + } + inputProps={{ + onClick: (e) => { + // Click is handled by MenuItem onClick + e.preventDefault(); + e.stopPropagation(); + }, + }} + onChange={() => { + /*Do nothing, change handled by menuItem*/ + }} + /> + + + ))} +
+
+ , + ); +}; diff --git a/src/components/gridForm/index.ts b/src/components/gridForm/index.ts index b31106d6..5cb664eb 100644 --- a/src/components/gridForm/index.ts +++ b/src/components/gridForm/index.ts @@ -2,6 +2,7 @@ export * from "./GridFormDropDown"; export * from "./GridFormEditBearing"; export * from "./GridFormMessage"; export * from "./GridFormMultiSelect"; +export * from "./GridFormMultiSelectGrid"; export * from "./GridFormPopoverMenu"; export * from "./GridFormSubComponentTextArea"; export * from "./GridFormSubComponentTextInput"; diff --git a/src/components/gridPopoverEdit/GridPopoutEditMultiSelectGrid.ts b/src/components/gridPopoverEdit/GridPopoutEditMultiSelectGrid.ts new file mode 100644 index 00000000..e2348a09 --- /dev/null +++ b/src/components/gridPopoverEdit/GridPopoutEditMultiSelectGrid.ts @@ -0,0 +1,17 @@ +import { GridBaseRow } from "../Grid"; +import { ColDefT, GenericCellEditorProps, GridCell } from "../GridCell"; +import { GridFormMultiSelectGrid, GridFormMultiSelectGridProps } from "../gridForm/GridFormMultiSelectGrid"; +import { GenericCellColDef } from "../gridRender/GridRenderGenericCell"; + +export const GridPopoutEditMultiSelectGrid = ( + colDef: GenericCellColDef, + props: GenericCellEditorProps>, +): ColDefT => + GridCell(colDef, { + editor: GridFormMultiSelectGrid, + ...props, + editorParams: { + className: "GridMultiSelect-containerMedium", + ...(props.editorParams as GridFormMultiSelectGridProps), + }, + }); diff --git a/src/components/gridPopoverEdit/index.ts b/src/components/gridPopoverEdit/index.ts index 46224556..cd4f26aa 100644 --- a/src/components/gridPopoverEdit/index.ts +++ b/src/components/gridPopoverEdit/index.ts @@ -1,4 +1,5 @@ export * from "./GridPopoutEditMultiSelect"; +export * from "./GridPopoutEditMultiSelectGrid"; export * from "./GridPopoverMenu"; export * from "./GridPopoverEditBearing"; export * from "./GridPopoverEditBearing"; diff --git a/src/react-menu3/components/MenuList.tsx b/src/react-menu3/components/MenuList.tsx index 11555f49..82c05ea5 100644 --- a/src/react-menu3/components/MenuList.tsx +++ b/src/react-menu3/components/MenuList.tsx @@ -143,11 +143,15 @@ export const MenuList = ({ break; case Keys.LEFT: - if (!focusSideways(elementTarget, -1)) return; + if (!focusSideways(elementTarget, -1)) { + return; // Unhandled + } break; case Keys.RIGHT: - if (!focusSideways(elementTarget, 1)) return; + if (!focusSideways(elementTarget, 1)) { + return; // Unhandled + } break; // prevent browser from scrolling the page when SPACE is pressed diff --git a/src/react-menu3/hooks/useItems.ts b/src/react-menu3/hooks/useItems.ts index 69b6ad26..4ca7d7d7 100644 --- a/src/react-menu3/hooks/useItems.ts +++ b/src/react-menu3/hooks/useItems.ts @@ -85,7 +85,9 @@ export const useItems = (menuRef: MutableRefObject, focusRef: MutableRefObj sortItems(); index = nextIndex; newItem = items[index]; - defer(() => (newItem as HTMLElement).scrollIntoView({ behavior: "smooth", inline: "nearest" })); + defer(() => + (newItem as HTMLElement).scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }), + ); break; case HoverActionTypes.INCREASE: diff --git a/src/stories/grid/GridPopoverEditMultiSelectGrid.stories.tsx b/src/stories/grid/GridPopoverEditMultiSelectGrid.stories.tsx new file mode 100644 index 00000000..2488751a --- /dev/null +++ b/src/stories/grid/GridPopoverEditMultiSelectGrid.stories.tsx @@ -0,0 +1,119 @@ +import { ComponentMeta, ComponentStory } from "@storybook/react/dist/ts3.9/client/preview/types-6-3"; +import { countBy, mergeWith, partition, pull, range, remove, union } from "lodash-es"; +import { useMemo, useState } from "react"; + +import "@linzjs/lui/dist/fonts"; +import "@linzjs/lui/dist/scss/base.scss"; + +import { ColDefT, Grid, GridCell, GridContextProvider, GridProps, GridUpdatingContextProvider, wait } from "../.."; +import { MultiSelectGridOption } from "../../components/gridForm/GridFormMultiSelectGrid"; +import { GridPopoutEditMultiSelectGrid } from "../../components/gridPopoverEdit/GridPopoutEditMultiSelectGrid"; +import "../../styles/GridTheme.scss"; +import "../../styles/index.scss"; + +export default { + title: "Components / Grids", + component: Grid, + args: { + quickFilterValue: "", + selectable: true, + }, + decorators: [ + (Story) => ( +
+ + + + + +
+ ), + ], +} as ComponentMeta; + +interface ITestRow { + id: number; + position: number[] | null; + position2: string | null; +} + +const GridEditMultiSelectGridTemplate: ComponentStory = (props: GridProps) => { + const [externalSelectedItems, setExternalSelectedItems] = useState([]); + + const columnDefs: ColDefT[] = useMemo(() => { + return [ + GridCell({ + field: "id", + headerName: "Id", + }), + GridPopoutEditMultiSelectGrid( + { + field: "position", + headerName: "Position", + valueFormatter: ({ value }) => { + if (value == null) return ""; + return value.join(", "); + }, + }, + { + multiEdit: true, + editorParams: { + className: "GridMultiSelect-containerUnlimited", + options: (selectedRows) => { + const counts: Record = mergeWith( + {}, + ...selectedRows.map((row) => countBy(row.position)), + (a: number | undefined, b: number | undefined) => (a ?? 0) + (b ?? 0), + ); + return range(50024, 50067).map((value): MultiSelectGridOption => { + const checked = counts[value] == selectedRows.length ? true : counts[value] > 0 ? "partial" : false; + return { + value: value, + label: `${value}`, + checked, + canSelectPartial: checked === "partial", + }; + }); + }, + onSave: async ({ selectedRows, addValues, removeValues }) => { + selectedRows.forEach((row) => { + row.position = union(pull(row.position ?? [], ...removeValues), addValues).sort(); + }); + + return true; + }, + }, + }, + ), + ]; + }, []); + /** + * { value: "50024", label: "50024" }, + * { value: "50025", label: "50025" }, + * { value: "50026", label: "50026" }, + * { value: "50027", label: "50027", checked: true }, + * { value: "50028", label: "50028", checked: "partial" }, + * { value: "50029", label: "50029", checked: "partial", canSelectPartial: true }, + * { value: "50030", label: "50030", warning: "there" }, + * { value: "50031", label: "50031", warning: "Hello" }, + */ + + const [rowData] = useState([ + { id: 1000, position: [50024, 50025], position2: "lot1" }, + { id: 1001, position: [50025, 50026], position2: "lot2" }, + ] as ITestRow[]); + + return ( + + ); +}; + +export const EditMultiSelectGrid = GridEditMultiSelectGridTemplate.bind({}); diff --git a/src/styles/GridFormMultiSelectGrid.scss b/src/styles/GridFormMultiSelectGrid.scss new file mode 100644 index 00000000..07a9f3cb --- /dev/null +++ b/src/styles/GridFormMultiSelectGrid.scss @@ -0,0 +1,9 @@ +.GridMultiSelect-noOptions { + justify-content: center; +} + +.GridMultiSelectGrid-Label { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} \ No newline at end of file diff --git a/src/styles/index.scss b/src/styles/index.scss index 484e6ee4..72db4096 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -6,6 +6,7 @@ @use "./GridFormDropDown"; @use "./GridFormEditBearing"; @use "./GridFormMultiSelect"; +@use "./GridFormMultiSelectGrid"; @use "./GridFormSubComponentTextInput"; @use "./GridIcon"; @use "./GridLoadableCell";