Skip to content

Commit

Permalink
Context menus
Browse files Browse the repository at this point in the history
  • Loading branch information
matttdawson committed Jun 30, 2023
1 parent 7198bfb commit 88946a5
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 1 deletion.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,28 @@ const GridDemo = () => {
[],
);

const contextMenu = useCallback(
(selectedRows: IFormTestRow[]): GridContextMenuItem[] => [
{
label: "Clear cell...",
onSelect: async ({ colDef }) => {
// eslint-disable-next-line no-console
selectedRows.forEach((row) => {
switch (colDef.field) {
case "name":
row.name = "";
break;
case "position":
row.position = "";
break;
}
});
},
},
],
[],
);

const rowData: ITestRow[] = useMemo(
() => [
{ id: 1000, name: "Tom", position: "Tester" },
Expand Down Expand Up @@ -155,6 +177,7 @@ const GridDemo = () => {
<Grid selectable={true}
columnDefs={columnDefs}
rowData={rowData}
contextMenu={contextMenu}
onContentSize={({ width }) => setPanelSize(width)} />
</GridWrapper>
</GridContextProvider>
Expand Down
29 changes: 28 additions & 1 deletion src/components/Grid.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { CellClickedEvent, ColDef, ColumnResizedEvent, ModelUpdatedEvent } from "ag-grid-community";
import { CellClassParams, EditableCallback, EditableCallbackParams } from "ag-grid-community/dist/lib/entities/colDef";
import { GridOptions } from "ag-grid-community/dist/lib/entities/gridOptions";
import { CellEvent, GridReadyEvent, SelectionChangedEvent } from "ag-grid-community/dist/lib/events";
import {
CellContextMenuEvent,
CellEvent,
GridReadyEvent,
SelectionChangedEvent,
} from "ag-grid-community/dist/lib/events";
import { AgGridReact } from "ag-grid-react";
import clsx from "clsx";
import { defer, difference, isEmpty, last, omit, xorBy } from "lodash-es";
Expand All @@ -11,6 +16,7 @@ import { GridContext } from "../contexts/GridContext";
import { GridUpdatingContext } from "../contexts/GridUpdatingContext";
import { useIntervalHook } from "../lui/timeoutHook";
import { fnOrVar, isNotEmpty } from "../utils/util";
import { GridContextMenuItem, useGridContextMenu } from "./GridContextMenu";
import { GridNoRowsOverlay } from "./GridNoRowsOverlay";
import { usePostSortRowsHook } from "./PostSortRowsHook";
import { GridHeaderSelect } from "./gridHeader";
Expand Down Expand Up @@ -67,6 +73,11 @@ export interface GridProps {
* Once the last cell to edit closes this callback is called.
*/
onCellEditingComplete?: () => void;

/**
* Context menu definition if required.
*/
contextMenu?: (selectedRows: any[]) => GridContextMenuItem[] | null;
}

/**
Expand Down Expand Up @@ -551,6 +562,19 @@ export const Grid = ({
}
}, []);

const gridContextMenu = useGridContextMenu(params.contextMenu);

const cellContextMenu = useCallback(
(event: CellContextMenuEvent) => {
if (!event.node.isSelected()) {
event.node.setSelected(true, true);
}
gridContextMenu.cellContextMenu(event);
},
[gridContextMenu],
);

// This is setting a ref in the GridContext so won't be triggering an update loop
setOnCellEditingComplete(params.onCellEditingComplete);

return (
Expand All @@ -563,6 +587,7 @@ export const Grid = ({
gridReady && params.rowData && "Grid-ready",
)}
>
{gridContextMenu.component}
<div style={{ flex: 1 }} ref={gridDivRef}>
<AgGridReact
rowHeight={params.rowHeight}
Expand Down Expand Up @@ -603,6 +628,8 @@ export const Grid = ({
isExternalFilterPresent={isExternalFilterPresent}
doesExternalFilterPass={doesExternalFilterPass}
maintainColumnOrder={true}
preventDefaultOnContextMenu={true}
onCellContextMenu={cellContextMenu}
/>
</div>
</div>
Expand Down
82 changes: 82 additions & 0 deletions src/components/GridContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ColDef } from "ag-grid-community";
import { CellContextMenuEvent } from "ag-grid-community/dist/lib/events";
import { ReactElement, useCallback, useContext, useRef, useState } from "react";

import { GridContext } from "../contexts/GridContext";
import { ControlledMenu, MenuItem } from "../react-menu3";

export interface GridContextMenuItem {
label: ReactElement | string | number;
onSelect: (props: { colDef: ColDef }) => Promise<void> | void;
disabled?: boolean;
visible?: boolean;
}

export const useGridContextMenu = (contextMenu?: (selectedRows: any[]) => GridContextMenuItem[] | null) => {
const { getSelectedRows, redrawRows, prePopupOps, postPopupOps } = useContext(GridContext);

const [isOpen, setOpen] = useState(false);
const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
const [contextMenuItems, setContextMenuItems] = useState<GridContextMenuItem[] | null>(null);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const clickedColDef = useRef<ColDef>(null!);

const openMenu = useCallback(
(e: PointerEvent | null | undefined) => {
if (!e || !contextMenu) return;
prePopupOps();
setAnchorPoint({ x: e.clientX, y: e.clientY });
setContextMenuItems(contextMenu(getSelectedRows()));
setOpen(true);
},
[contextMenu, getSelectedRows, prePopupOps],
);

const closeMenu = useCallback(() => {
setOpen(false);
setContextMenuItems(null);
postPopupOps();
}, [postPopupOps]);

const cellContextMenu = useCallback(
(event: CellContextMenuEvent) => {
clickedColDef.current = event.colDef;
// This is actually a pointer event
openMenu(event.event as PointerEvent);
},
[openMenu],
);

// global onclick
return {
openMenu,
cellContextMenu,
component: contextMenuItems ? (
<>
<ControlledMenu
anchorPoint={anchorPoint}
state={isOpen ? "open" : "closed"}
direction="right"
onClose={() => closeMenu()}
>
{contextMenuItems.map(
(row, i) =>
row.visible !== false && (
<MenuItem
key={`${i}`}
onClick={async () => {
await row.onSelect({ colDef: clickedColDef.current });
redrawRows();
}}
disabled={row.disabled}
>
{row.label}
</MenuItem>
),
)}
</ControlledMenu>
</>
) : null,
};
};
4 changes: 4 additions & 0 deletions src/contexts/GridContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface GridContextType<RowType extends GridBaseRow> {
getColumns: () => ColDefT<RowType>[];
setApis: (gridApi: GridApi | undefined, columnApi: ColumnApi | undefined, dataTestId?: string) => void;
prePopupOps: () => void;
postPopupOps: () => void;
setQuickFilter: (quickFilter: string) => void;
editingCells: () => boolean;
getSelectedRows: <T extends GridBaseRow>() => T[];
Expand Down Expand Up @@ -78,6 +79,9 @@ export const GridContext = createContext<GridContextType<any>>({
prePopupOps: () => {
console.error("no context provider for prePopupOps");
},
postPopupOps: () => {
console.error("no context provider for prePopupOps");
},
externallySelectedItemsAreInSync: false,
setApis: () => {
console.error("no context provider for setApis");
Expand Down
11 changes: 11 additions & 0 deletions src/contexts/GridContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ export const GridContextProvider = <RowType extends GridBaseRow>(props: GridCont
prePopupFocusedCell.current = gridApi?.getFocusedCell() ?? undefined;
}, [gridApi]);

/**
* After a popup refocus the cell.
*/
const postPopupOps = useCallback(() => {
if (!gridApi) return;
if (prePopupFocusedCell.current) {
gridApi?.setFocusedCell(prePopupFocusedCell.current.rowIndex, prePopupFocusedCell.current.column);
}
}, [gridApi]);

/**
* Get all row id's in grid.
*/
Expand Down Expand Up @@ -635,6 +645,7 @@ export const GridContextProvider = <RowType extends GridBaseRow>(props: GridCont
setInvisibleColumnIds,
gridReady,
prePopupOps,
postPopupOps,
setApis,
setQuickFilter,
selectRowsById,
Expand Down
138 changes: 138 additions & 0 deletions src/stories/grid/GridPopoutContextMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ComponentMeta, ComponentStory } from "@storybook/react/dist/ts3.9/client/preview/types-6-3";
import { useCallback, useContext, useMemo, useState } from "react";

import "@linzjs/lui/dist/fonts";
import "@linzjs/lui/dist/scss/base.scss";

import {
ActionButton,
ColDefT,
Grid,
GridCell,
GridContext,
GridContextProvider,
GridProps,
GridUpdatingContextProvider,
wait,
} from "../..";
import { GridContextMenuItem } from "../../components/GridContextMenu";
import "../../styles/GridTheme.scss";
import "../../styles/index.scss";
import { IFormTestRow } from "./FormTest";

export default {
title: "Components / Grids",
component: Grid,
args: {
quickFilterValue: "",
selectable: true,
},
decorators: [
(Story) => (
<div style={{ width: 1024, height: 400 }}>
<GridUpdatingContextProvider>
<GridContextProvider>
<Story />
</GridContextProvider>
</GridUpdatingContextProvider>
</div>
),
],
} as ComponentMeta<typeof Grid>;

const GridPopoutContextMenuTemplate: ComponentStory<typeof Grid> = (props: GridProps) => {
const { selectRowsWithFlashDiff } = useContext(GridContext);
const [externalSelectedItems, setExternalSelectedItems] = useState<any[]>([]);
const [rowData, setRowData] = useState([
{ id: 1000, name: "IS IS DP12345", nameType: "IS", numba: "IX", plan: "DP 12345", distance: 10 },
{ id: 1001, name: "PEG V SD523", nameType: "PEG", numba: "V", plan: "SD 523", distance: null },
] as IFormTestRow[]);

const columnDefs: ColDefT<IFormTestRow>[] = useMemo(
() => [
GridCell({
field: "id",
headerName: "Id",
}),
GridCell({
field: "name",
headerName: "Name",
}),
GridCell({
field: "distance",
headerName: "Number input",
valueFormatter: (params) => {
const v = params.data.distance;
return v != null ? `${v}m` : "–";
},
}),
],
[],
);

const addRowAction = useCallback(async () => {
await wait(1000);

const lastRow = rowData[rowData.length - 1];
await selectRowsWithFlashDiff(async () => {
setRowData([
...rowData,
{
id: (lastRow?.id ?? 0) + 1,
name: "?",
nameType: "?",
numba: "?",
plan: "",
distance: null,
},
]);
});
}, [rowData, selectRowsWithFlashDiff]);

const contextMenu = useCallback(
(selectedRows: IFormTestRow[]): GridContextMenuItem[] => [
{
label: "Clear cell...",
onSelect: async ({ colDef }) => {
// eslint-disable-next-line no-console
selectedRows.forEach((row) => {
switch (colDef.field) {
case "name":
row.name = "";
break;
case "distance":
row.distance = null;
break;
}
});
},
},
{ label: "Should be invisible", visible: false, onSelect: () => {} },
{ label: "Disabled", disabled: true, onSelect: () => {} },
],
[],
);

return (
<>
<Grid
{...props}
externalSelectedItems={externalSelectedItems}
setExternalSelectedItems={setExternalSelectedItems}
columnDefs={columnDefs}
rowData={rowData}
domLayout={"autoHeight"}
defaultColDef={{ minWidth: 70 }}
sizeColumns={"auto"}
onCellEditingComplete={() => {
/* eslint-disable-next-line no-console */
console.log("Cell editing complete");
}}
contextMenu={contextMenu}
/>
<ActionButton icon={"ic_add"} name={"Add new row"} inProgressName={"Adding..."} onClick={addRowAction} />
</>
);
};

export const EditContextMenu = GridPopoutContextMenuTemplate.bind({});

0 comments on commit 88946a5

Please sign in to comment.