From 8bafdf8d89f8d47afb3bcf02abcf115b952b1006 Mon Sep 17 00:00:00 2001 From: Kevin Ghadyani Date: Tue, 13 Jun 2023 15:52:23 -0500 Subject: [PATCH] fix: split DataGrid into 2 components --- .../src/InfinitelyScrolledDataGrid.tsx | 208 +++++++ .../odyssey-react-mui/src/StaticDataGrid.tsx | 143 +++++ packages/odyssey-react-mui/src/index.ts | 7 +- .../properties/odyssey-react-mui.properties | 7 +- ...=> InfinitelyScrolledDataGrid.stories.tsx} | 83 +-- .../DataGrid/StaticDataGrid.stories.tsx | 537 ++++++++++++++++++ 6 files changed, 944 insertions(+), 41 deletions(-) create mode 100644 packages/odyssey-react-mui/src/InfinitelyScrolledDataGrid.tsx create mode 100644 packages/odyssey-react-mui/src/StaticDataGrid.tsx rename packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/{DataGrid.stories.tsx => InfinitelyScrolledDataGrid.stories.tsx} (90%) create mode 100644 packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/StaticDataGrid.stories.tsx diff --git a/packages/odyssey-react-mui/src/InfinitelyScrolledDataGrid.tsx b/packages/odyssey-react-mui/src/InfinitelyScrolledDataGrid.tsx new file mode 100644 index 0000000000..ab32ca0d19 --- /dev/null +++ b/packages/odyssey-react-mui/src/InfinitelyScrolledDataGrid.tsx @@ -0,0 +1,208 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import { Typography } from "@mui/material"; +import MaterialReactTable, { + type MRT_ColumnFiltersState, + type MRT_RowSelectionState, + type MRT_TableInstance, + type MRT_Virtualizer, +} from "material-react-table"; +import { + FunctionComponent, + memo, + UIEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +export type DefaultMaterialReactTableData = Record; + +export type MaterialReactTableProps< + TData extends DefaultMaterialReactTableData +> = Parameters>[0]; + +export type InfinitelyScrolledDataGridProps< + TData extends DefaultMaterialReactTableData +> = { + columns: MaterialReactTableProps["columns"]; + data: MaterialReactTableProps["data"]; + fetchMoreData?: () => void; + getRowId?: MaterialReactTableProps["getRowId"]; + hasError?: boolean; + hasRowSelection?: boolean; + initialState?: MaterialReactTableProps["initialState"]; + isFetching?: boolean; + onGlobalFilterChange?: MaterialReactTableProps["onGlobalFilterChange"]; + onRowSelectionChange?: MaterialReactTableProps["onRowSelectionChange"]; + // rowsPerPageOptions?: MaterialReactTableProps["muiTablePaginationProps"]['rowsPerPageOptions']; + state?: MaterialReactTableProps["state"]; + ToolbarButtons?: FunctionComponent< + { table: MRT_TableInstance } & unknown + >; +}; + +// Once the user has scrolled within this many pixels of the bottom of the table, fetch more data if we can. +const scrollAmountBeforeFetchingData = 400; + +const InfinitelyScrolledDataGrid = < + TData extends DefaultMaterialReactTableData +>({ + columns, + data, + fetchMoreData, + getRowId, + hasError, + hasRowSelection, + initialState, + isFetching, + onGlobalFilterChange, + onRowSelectionChange: onRowSelectionChangeProp, + state, + ToolbarButtons, +}: InfinitelyScrolledDataGridProps) => { + const { t } = useTranslation(); + + const tableContainerRef = useRef(null); + + const rowVirtualizerInstanceRef = + useRef>(null); + + const [columnFilters, setColumnFilters] = useState( + [] + ); + + const [globalFilter, setGlobalFilter] = useState(); + + useEffect(() => { + if (globalFilter) { + onGlobalFilterChange?.(globalFilter); + } + }, [globalFilter, onGlobalFilterChange]); + + const totalFetchedRows = data.length ?? 0; + + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + + if ( + scrollHeight - scrollTop - clientHeight < + scrollAmountBeforeFetchingData && + !isFetching + ) { + fetchMoreData?.(); + } + } + }, + [fetchMoreData, isFetching] + ); + + useEffect(() => { + try { + // Scroll to top of table when sorting or filters change. + rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); + } catch (error) { + console.error(error); + } + }, [columnFilters, globalFilter]); + + // Check on mount to see if the table is already scrolled to the bottom and immediately needs to fetch more data. + useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached]); + + const renderBottomToolbarCustomActions = useCallback( + () => + fetchMoreData ? ( + + {t("datagrid.fetchedrows.text", String(totalFetchedRows))} + + ) : ( + + {t("datagrid.rows.text", String(totalFetchedRows))} rows + + ), + [fetchMoreData, totalFetchedRows] + ); + + const renderTopToolbarCustomActions = useCallback< + Exclude< + MaterialReactTableProps["renderTopToolbarCustomActions"], + undefined + > + >( + ({ table }) => <>{ToolbarButtons && }, + [ToolbarButtons] + ); + + const [rowSelection, setRowSelection] = useState({}); + + useEffect(() => { + onRowSelectionChangeProp?.(rowSelection); + }, [onRowSelectionChangeProp, rowSelection]); + + const modifiedState = useMemo( + () => ({ + ...state, + rowSelection, + }), + [rowSelection, state] + ); + + return ( + 50} + enableSorting={false} + getRowId={getRowId} + initialState={initialState} + muiTableContainerProps={{ + onScroll: (event: UIEvent) => + fetchMoreOnBottomReached(event.target as HTMLDivElement), + ref: tableContainerRef, + sx: { maxHeight: String(500 / 14).concat("rem") }, + }} + muiToolbarAlertBannerProps={ + hasError + ? { + children: t("datagrid.error"), + color: "error", + } + : undefined + } + onColumnFiltersChange={setColumnFilters} + onGlobalFilterChange={setGlobalFilter} + onRowSelectionChange={setRowSelection} + renderBottomToolbarCustomActions={renderBottomToolbarCustomActions} + renderTopToolbarCustomActions={renderTopToolbarCustomActions} + rowVirtualizerInstanceRef={rowVirtualizerInstanceRef} + rowVirtualizerProps={{ overscan: 4 }} + state={modifiedState} + /> + ); +}; + +const MemoizedInfinitelyScrolledDataGrid = memo( + InfinitelyScrolledDataGrid +) as typeof InfinitelyScrolledDataGrid; + +export { MemoizedInfinitelyScrolledDataGrid as InfinitelyScrolledDataGrid }; diff --git a/packages/odyssey-react-mui/src/StaticDataGrid.tsx b/packages/odyssey-react-mui/src/StaticDataGrid.tsx new file mode 100644 index 0000000000..ab58a1548a --- /dev/null +++ b/packages/odyssey-react-mui/src/StaticDataGrid.tsx @@ -0,0 +1,143 @@ +/*! + * Copyright (c) 2022-present, Okta, Inc. and/or its affiliates. All rights reserved. + * The Okta software accompanied by this notice is provided pursuant to the Apache License, Version 2.0 (the "License.") + * + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * + * See the License for the specific language governing permissions and limitations under the License. + */ + +import MaterialReactTable, { + type MRT_ColumnFiltersState, + type MRT_RowSelectionState, + type MRT_TableInstance, + type MRT_Virtualizer, +} from "material-react-table"; +import { + FunctionComponent, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; + +export type { MRT_ColumnDef as DataGridColumn } from "material-react-table"; + +type DefaultMaterialReactTableData = Record; + +type MaterialReactTableProps = + Parameters>[0]; + +export type StaticDataGridProps = { + columns: MaterialReactTableProps["columns"]; + data: MaterialReactTableProps["data"]; + getRowId?: MaterialReactTableProps["getRowId"]; + hasError?: boolean; + initialState?: MaterialReactTableProps["initialState"]; + onGlobalFilterChange?: MaterialReactTableProps["onGlobalFilterChange"]; + onRowSelectionChange?: MaterialReactTableProps["onRowSelectionChange"]; + state?: MaterialReactTableProps["state"]; + ToolbarButtons?: FunctionComponent< + { table: MRT_TableInstance } & unknown + >; +}; + +const StaticDataGrid = ({ + columns, + data, + getRowId, + hasError, + initialState, + onGlobalFilterChange, + onRowSelectionChange: onRowSelectionChangeProp, + state, + ToolbarButtons, +}: StaticDataGridProps) => { + const { t } = useTranslation(); + + const rowVirtualizerInstanceRef = + useRef>(null); + + const [columnFilters, setColumnFilters] = useState( + [] + ); + + const [globalFilter, setGlobalFilter] = useState(); + + useEffect(() => { + if (globalFilter) { + onGlobalFilterChange?.(globalFilter); + } + }, [globalFilter, onGlobalFilterChange]); + + const renderTopToolbarCustomActions = useCallback< + Exclude< + MaterialReactTableProps["renderTopToolbarCustomActions"], + undefined + > + >( + ({ table }) => <>{ToolbarButtons && }, + [ToolbarButtons] + ); + + useEffect(() => { + try { + // Scroll to top of table when sorting or filters change. + rowVirtualizerInstanceRef.current?.scrollToIndex?.(0); + } catch (error) { + console.error(error); + } + }, [columnFilters, globalFilter]); + + const [rowSelection, setRowSelection] = useState({}); + + useEffect(() => { + onRowSelectionChangeProp?.(rowSelection); + }, [onRowSelectionChangeProp, rowSelection]); + + const modifiedState = useMemo( + () => ({ + ...state, + rowSelection, + }), + [rowSelection, state] + ); + + return ( + 50} + enableSorting={false} + getRowId={getRowId} + initialState={initialState} + muiToolbarAlertBannerProps={ + hasError + ? { + children: t("datagrid.error"), + color: "error", + } + : undefined + } + onColumnFiltersChange={setColumnFilters} + onGlobalFilterChange={setGlobalFilter} + onRowSelectionChange={setRowSelection} + renderTopToolbarCustomActions={renderTopToolbarCustomActions} + rowVirtualizerInstanceRef={rowVirtualizerInstanceRef} + rowVirtualizerProps={{ overscan: 4 }} + state={modifiedState} + /> + ); +}; + +const MemoizedStaticDataGrid = memo(StaticDataGrid) as typeof StaticDataGrid; + +export { MemoizedStaticDataGrid as StaticDataGrid }; diff --git a/packages/odyssey-react-mui/src/index.ts b/packages/odyssey-react-mui/src/index.ts index d188b76b75..46ae1d59e3 100644 --- a/packages/odyssey-react-mui/src/index.ts +++ b/packages/odyssey-react-mui/src/index.ts @@ -101,6 +101,8 @@ export { default as FavoriteIcon } from "@mui/icons-material/Favorite"; export { deepmerge, visuallyHidden } from "@mui/utils"; +export type { MRT_ColumnDef as DataGridColumn } from "material-react-table"; + export * from "./Autocomplete"; export * from "./Banner"; export * from "./Button"; @@ -108,12 +110,12 @@ export * from "./Checkbox"; export * from "./CheckboxGroup"; export * from "./CircularProgress"; export * from "./createUniqueId"; -export * from "./DataGrid"; export * from "./Dialog"; export * from "./Fieldset"; export * from "./Form"; export * from "./Icon"; export * from "./iconDictionary"; +export * from "./InfinitelyScrolledDataGrid"; export * from "./Infobox"; export * from "./Link"; export * from "./MenuButton"; @@ -129,13 +131,14 @@ export * from "./RadioGroup"; export * from "./ScreenReaderText"; export * from "./SearchField"; export * from "./Select"; +export * from "./StaticDataGrid"; export * from "./Status"; export * from "./Tabs"; export * from "./Tag"; export * from "./TagList"; export * from "./TextField"; export * from "./theme"; -export * from "./Tooltip"; export * from "./Toast"; export * from "./ToastStack"; +export * from "./Tooltip"; export * from "./useUniqueId"; diff --git a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties index ccf2a75b0a..7c340c8818 100644 --- a/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties +++ b/packages/odyssey-react-mui/src/properties/odyssey-react-mui.properties @@ -1,8 +1,11 @@ +datagrid.error = Error loading data. +datagrid.fetchedrows.text = Fetched {0} total rows +datagrid.rows.text = {0} rows fielderror.screenreader.text = Error fieldlabel.optional.text = Optional fieldlabel.required.text = Required +severity.error = error +severity.info = info severity.success = success severity.warning = warning -severity.info = info -severity.error = error toast.close.text = close diff --git a/packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/DataGrid.stories.tsx b/packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/InfinitelyScrolledDataGrid.stories.tsx similarity index 90% rename from packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/DataGrid.stories.tsx rename to packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/InfinitelyScrolledDataGrid.stories.tsx index de28b02d40..d206e9f568 100644 --- a/packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/DataGrid.stories.tsx +++ b/packages/odyssey-storybook/src/components/odyssey-mui/DataGrid/InfinitelyScrolledDataGrid.stories.tsx @@ -13,18 +13,17 @@ import { Meta, StoryObj } from "@storybook/react"; import { Button, - DataGrid, DataGridColumn, - DataGridProps, - Link, + InfinitelyScrolledDataGrid, + InfinitelyScrolledDataGridProps, } from "@okta/odyssey-react-mui"; import { MuiThemeDecorator } from "../../../../.storybook/components"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; const storybookMeta: Meta = { - title: "MUI Components/Data Grid", - component: DataGrid, + title: "MUI Components/Infinitely Scrolled Data Grid", + component: InfinitelyScrolledDataGrid, argTypes: { columns: { control: "array", @@ -506,7 +505,7 @@ const data: Person[] = [ }, ]; -export const Basic: StoryObj> = { +export const BasicUsage: StoryObj> = { args: { columns, data, @@ -514,27 +513,36 @@ export const Basic: StoryObj> = { }, }; -export const InfiniteScroll: StoryObj> = { - args: { - columns, - data, - getRowId: ({ id }: { id: string }) => id, - }, - render: function C(args) { - const countRef = useRef(15); - const [data, setData] = useState(args.data.slice(0, countRef.current)); +export const InfiniteScroll: StoryObj> = + { + args: { + columns, + data, + getRowId: ({ id }: { id: string }) => id, + }, + render: function C(args) { + const countRef = useRef(15); + const [data, setData] = useState(args.data.slice(0, countRef.current)); - const fetchMoreData = useCallback(() => { - countRef.current = countRef.current + 5; + const fetchMoreData = useCallback(() => { + countRef.current = countRef.current + 5; - setData(args.data.slice(0, Math.min(countRef.current, args.data.length))); - }, [args.data]); + setData( + args.data.slice(0, Math.min(countRef.current, args.data.length)) + ); + }, [args.data]); - return ; - }, -}; + return ( + + ); + }, + }; -export const Selection: StoryObj> = { +export const Selection: StoryObj> = { args: { columns, data, @@ -543,19 +551,20 @@ export const Selection: StoryObj> = { }, }; -export const CustomToolbar: StoryObj> = { - args: { - columns, - data, - getRowId: ({ id }: { id: string }) => id, - ToolbarButtons: ({ table }) => ( -