react-app/src/components/table/DataTable.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import React, {
  MutableRefObject,
  PropsWithChildren,
  useEffect,
  useMemo,
  useRef,
  useState,
  useContext,
} from 'react';
import Icon from '@mdi/react';
import { mdiDotsHorizontal } from '@mdi/js';
import {
  Box,
  IconButton,
  ListItemIcon,
  ListSubheader,
  Menu,
  MenuItem,
  Select,
  SxProps,
  Theme,
  Tooltip,
  Typography,
  debounce,
  useTheme,
} from '@mui/material';
import {
  DataGrid,
  DataGridProps,
  GridFilterModel,
  GridOverlay,
  GridPaginationModel,
  GridRenderCellParams,
  GridSortDirection,
  GridSortModel,
  GridTreeNodeWithRender,
  gridFilteredSortedRowEntriesSelector,
  useGridApiRef,
} from '@mui/x-data-grid';
import { downloadExcelFile } from '@/utilities/downloadExcelFile';
import KeywordSearch from './KeywordSearch';
import FilterAltOffIcon from '@mui/icons-material/FilterAltOff';
import DownloadIcon from '@mui/icons-material/Download';
import AddIcon from '@mui/icons-material/Add';
import { GridApiCommunity } from '@mui/x-data-grid/internals';
import { GridInitialStateCommunity } from '@mui/x-data-grid/models/gridStateCommunity';
import CircularProgress from '@mui/material/CircularProgress';
import { CommonFiltering } from '@/interfaces/ICommonFiltering';
import { useSearchParams } from 'react-router-dom';
import { Roles } from '@/constants/roles';
import { UserContext } from '@/contexts/userContext';
import { SnackBarContext } from '@/contexts/snackbarContext';

type RenderCellParams = GridRenderCellParams<any, any, any, GridTreeNodeWithRender>;

const NoRowsOverlay = (): JSX.Element => {
  return (
    <GridOverlay sx={{ height: '100%' }}>
      <Typography>No rows to display.</Typography>
    </GridOverlay>
  );
};

interface IDataGridFloatingMenuAction {
  label: string;
  iconPath: string;
  action: (cellParams: RenderCellParams) => void;
}

interface IDataGridFloatingMenuProps {
  menuActions: IDataGridFloatingMenuAction[];
  cellParams: RenderCellParams;
}

export const DataGridFloatingMenu = (props: IDataGridFloatingMenuProps) => {
  const [anchorEl, setAnchorEl] = useState<Element | null>(null);
  const open = Boolean(anchorEl);
  const handleClick = (event: React.MouseEvent) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  return (
    <>
      <IconButton
        aria-label="data-grid-actions"
        onClick={handleClick}
        data-testid="data-grid-actions"
        tabIndex={0}
      >
        <Icon path={mdiDotsHorizontal} size={1} />
      </IconButton>
      <Menu
        sx={{ boxShadow: '3px' }}
        anchorOrigin={{
          vertical: 'top',
          horizontal: 'right',
        }}
        transformOrigin={{
          vertical: 'top',
          horizontal: 'right',
        }}
        id="data-grid-menu-container"
        anchorEl={anchorEl}
        open={open}
        onClose={handleClose}
        MenuListProps={{
          'aria-labelledby': 'basic-button',
        }}
      >
        {props.menuActions.map((action) => (
          <MenuItem
            key={`menu-action-${action.label}`}
            onClick={() => {
              handleClose();
              action.action(props.cellParams);
            }}
          >
            <ListItemIcon>
              <Icon path={action.iconPath} size={1} />
            </ListItemIcon>
            <Typography variant="inherit">{action.label}</Typography>
          </MenuItem>
        ))}
      </Menu>
    </>
  );
};

export const CustomDataGrid = (props: DataGridProps) => {
  return (
    <DataGrid
      {...props}
      slotProps={{
        loadingOverlay: {
          variant: 'linear-progress',
          noRowsVariant: 'skeleton',
        },
      }}
    />
  );
};

export const CustomMenuItem = (
  props: PropsWithChildren & { value: string; sx?: SxProps<Theme> },
) => {
  const theme = useTheme();
  return (
    <MenuItem
      sx={{
        fontSize: theme.typography.fontSize,
        fontWeight: theme.typography.fontWeightMedium,
        height: '2.3em',
      }}
      {...props}
    >
      {props.children}
    </MenuItem>
  );
};

export const CustomListSubheader = (props: PropsWithChildren) => {
  const theme = useTheme();
  return (
    <ListSubheader
      sx={{
        fontSize: theme.typography.fontSize,
        fontWeight: theme.typography.fontWeightBold,
        color: 'rgba(0, 0, 0, 1)',
        height: '2.3em',
        marginBottom: '5px',
      }}
      {...props}
    >
      {props.children}
    </ListSubheader>
  );
};

type FilterSearchDataGridProps = {
  dataSource?: (filter: CommonFiltering, signal: AbortSignal) => Promise<any[]>;
  excelDataSource?: (filter: CommonFiltering, signal: AbortSignal) => Promise<any[]>;
  tableOperationMode: 'client' | 'server';
  onPresetFilterChange: (value: string, ref: MutableRefObject<GridApiCommunity>) => void;
  onAddButtonClick?: React.MouseEventHandler<HTMLButtonElement>;
  rowCountProp?: number;
  defaultFilter: string;
  presetFilterSelectOptions: JSX.Element[];
  tableHeader: string;
  excelTitle: string;
  customExcelMap?: (data: unknown[]) => Record<string, unknown>[];
  addTooltip: string;
  name: string;
  initialState?: GridInitialStateCommunity;
} & DataGridProps;

export const FilterSearchDataGrid = (props: FilterSearchDataGridProps) => {
  const DEFAULT_PAGE = 0;
  const DEFAULT_PAGESIZE = 100;

  const [searchParams, setSearchParams] = useSearchParams();
  const [dataSourceRows, setDataSourceRows] = useState([]);
  const [rowCount, setRowCount] = useState<number>(0);
  const [keywordSearchContents, setKeywordSearchContents] = useState<string>('');
  const [gridFilterItems, setGridFilterItems] = useState([]);
  const [selectValue, setSelectValue] = useState<string>(props.defaultFilter);
  const [isExporting, setIsExporting] = useState<boolean>(false);
  const [dataSourceLoading, setDataSourceLoading] = useState<boolean>(false);
  const tableApiRef = useGridApiRef(); // Ref to MUI DataGrid
  const previousController = useRef<AbortController>();
  const snackbar = useContext(SnackBarContext);

  interface ITableModelCollection {
    pagination?: GridPaginationModel;
    sort?: GridSortModel;
    filter?: GridFilterModel;
    quickFilter?: string[];
  }

  type DataTableSearchParamKeys =
    | 'keywordFilter'
    | 'quickSelectFilter'
    | 'columnFilterName'
    | 'columnFilterValue'
    | 'columnFilterMode'
    | 'columnSortName'
    | 'columnSortValue'
    | 'page'
    | 'pageSize';

  // Some thin wrappers around searchParams hook manipulation to try and provide some type safety, otherwise it's possible to accidentally
  // manipulate keys that aren't recognized by the rest of the DataTable features.
  const getSearchParamsKey = (key: DataTableSearchParamKeys) => searchParams.get(key);
  const setSearchParamsKey = (keyValuePairs: Partial<Record<DataTableSearchParamKeys, string>>) => {
    setSearchParams((params) => {
      for (const [key, val] of Object.entries(keyValuePairs)) {
        params.set(key, val);
      }
      return params;
    });
  };
  const deleteSearchParamsKey = (keys: DataTableSearchParamKeys[]) => {
    setSearchParams((params) => {
      for (const key of keys) {
        params.delete(key);
      }
      return params;
    });
  };

  const formatHeaderToFilterKey = (headerName: string) => {
    switch (headerName) {
      case 'PID':
      case 'PIN':
        return headerName.toLowerCase();
      default:
        return headerName.charAt(0).toLowerCase() + headerName.slice(1);
    }
  };

  const createFilterObject = (filter: GridFilterModel, quickFilter: string[]) => {
    const filterObj = {};
    if (filter?.items) {
      for (const f of filter.items) {
        if (f.value == '') continue; // Skip empty fields
        const asCamelCase = formatHeaderToFilterKey(f.field);
        if (f.value != undefined && String(f.value) !== 'Invalid Date') {
          filterObj[asCamelCase] = `${f.operator},${f.value}`;
        } else if (f.operator === 'isNotEmpty' || f.operator === 'isEmpty') {
          filterObj[asCamelCase] = f.operator;
        }
      }
    }
    if (quickFilter) {
      const keyword = quickFilter[0];
      if (keyword) filterObj['quickFilter'] = `contains,${keyword}`;
    }
    return filterObj;
  };

  const createSortObj = (sort: GridSortModel) => {
    let sortObj: { sortKey?: string; sortOrder?: string; sortRelation?: string } = {};
    if (sort?.length) {
      sortObj = { sortKey: sort[0].field, sortOrder: sort[0].sort };
    }
    return sortObj;
  };

  const dataSourceUpdate = (models: ITableModelCollection) => {
    const { pagination, sort, filter, quickFilter } = models;
    if (previousController.current) {
      previousController.current.abort();
    }
    //We use this AbortController to cancel requests that haven't finished yet everytime we start a new one.
    const controller = new AbortController();
    const signal = controller.signal;
    previousController.current = controller;

    const sortObj = createSortObj(sort);
    const filterObj = createFilterObject(filter, quickFilter);
    setDataSourceLoading(true);
    props
      .dataSource(
        {
          quantity: pagination.pageSize,
          page: pagination.page,
          ...sortObj,
          ...filterObj,
        },
        signal,
      )
      .then((resolved) => {
        setDataSourceRows(resolved);
      })
      .catch((e) => {
        if (!(e instanceof DOMException)) {
          //Represses DOMException which is the expected result of aborting the connection.
          //If something else happens though, we may want to rethrow that.
          throw e;
        }
      })
      .finally(() => {
        setDataSourceLoading(false);
      });
  };

  useEffect(() => {
    const model: ITableModelCollection = {
      pagination: {
        pageSize: getSearchParamsKey('pageSize')
          ? Number(getSearchParamsKey('pageSize'))
          : DEFAULT_PAGESIZE,
        page: getSearchParamsKey('page') ? Number(getSearchParamsKey('page')) : DEFAULT_PAGE,
      },
      sort: [],
      filter: { items: [] },
      quickFilter: [],
    };
    if (
      getSearchParamsKey('columnFilterName') &&
      getSearchParamsKey('columnFilterValue') &&
      getSearchParamsKey('columnFilterMode') &&
      getSearchParamsKey('columnFilterValue') !== 'undefined'
    ) {
      model.filter.items = [
        {
          value: getSearchParamsKey('columnFilterValue'),
          operator: getSearchParamsKey('columnFilterMode'),
          field: getSearchParamsKey('columnFilterName'),
        },
      ];
    }
    if (getSearchParamsKey('keywordFilter')) {
      model.quickFilter = getSearchParamsKey('keywordFilter').split(' ');
    }
    if (getSearchParamsKey('columnSortName') && getSearchParamsKey('columnSortValue')) {
      model.sort = [
        {
          field: getSearchParamsKey('columnSortName'),
          sort: getSearchParamsKey('columnSortValue') as GridSortDirection,
        },
      ];
    }
    if (props.dataSource) {
      dataSourceUpdate(model);
    }
  }, [searchParams]);

  /**
   * @description Hook that runs after render. Looks to query strings to set filter. If none are found, then looks to state cookie.
   */
  useEffect(() => {
    if (Boolean(searchParams.size)) {
      if (getSearchParamsKey('keywordFilter')) {
        setKeywordSearchContents(getSearchParamsKey('keywordFilter'));
        updateSearchValue(getSearchParamsKey('keywordFilter'));
      }
      // Set quick select filter
      if (getSearchParamsKey('quickSelectFilter')) {
        setSelectValue(getSearchParamsKey('quickSelectFilter'));
        props.onPresetFilterChange(getSearchParamsKey('quickSelectFilter'), tableApiRef);
      }
      // Set other column filter
      if (
        getSearchParamsKey('columnFilterName') &&
        getSearchParamsKey('columnFilterValue') &&
        getSearchParamsKey('columnFilterMode')
      ) {
        const modelObj: GridFilterModel = {
          items: [],
          quickFilterValues: undefined,
        };
        modelObj.items = [
          {
            value: getSearchParamsKey('columnFilterValue'),
            operator: getSearchParamsKey('columnFilterMode'),
            field: getSearchParamsKey('columnFilterName'),
          },
        ];
        tableApiRef.current.setFilterModel(modelObj);
      }
      // Set sorting options
      if (getSearchParamsKey('columnSortName') && getSearchParamsKey('columnSortValue')) {
        tableApiRef.current.setSortModel([
          {
            field: getSearchParamsKey('columnSortName'),
            sort: getSearchParamsKey('columnSortValue') as GridSortDirection,
          },
        ]);
      }
      //Set pagination
      if (getSearchParamsKey('page') != undefined && getSearchParamsKey('pageSize') != undefined) {
        tableApiRef.current.setPaginationModel({
          page: Number(getSearchParamsKey('page')),
          pageSize: Number(getSearchParamsKey('pageSize')),
        });
      } else {
        //This should always get set to something even if there is no query param to load from, pagination may not work at all otherwise.
        tableApiRef.current.setPaginationModel({ page: DEFAULT_PAGE, pageSize: DEFAULT_PAGESIZE });
      }
    }
  }, [tableApiRef]);

  // Sets quickfilter value of DataGrid. newValue is a string input.
  const updateSearchValue = useMemo(() => {
    return debounce((newValue) => {
      tableApiRef.current.setQuickFilterValues(newValue.split(' ').filter((word) => word !== ''));
      const defaultpagesize = { page: 0, pageSize: DEFAULT_PAGESIZE };
      tableApiRef.current.setPaginationModel(defaultpagesize);
    }, 300);
  }, [tableApiRef]);

  const tableHeaderRowCount = useMemo(() => {
    return props.tableOperationMode === 'client'
      ? `(${rowCount ?? 0} rows)`
      : `(${props.rowCountProp ?? 0} rows)`;
  }, [props.tableOperationMode, rowCount, props.rowCountProp]);

  const { pimsUser } = useContext(UserContext);
  const isAuditor = pimsUser.hasOneOfRoles([Roles.AUDITOR]);

  return (
    <>
      <Box
        sx={{
          display: 'flex',
          justifyContent: 'space-between',
          marginBottom: '1em',
        }}
      >
        <Box display={'flex'}>
          <Typography variant="h4" alignSelf={'center'} marginRight={'1em'}>
            {`${props.tableHeader} ${tableHeaderRowCount}`}
          </Typography>
          {keywordSearchContents || gridFilterItems.length > 0 ? (
            <Tooltip title="Clear Filter">
              <IconButton
                onClick={() => {
                  // Set both DataGrid and Keyword search back to blanks
                  tableApiRef.current.setFilterModel({ items: [] });
                  setKeywordSearchContents('');
                  // Set select field back to default
                  setSelectValue(props.defaultFilter);
                  //Clear search params related to filtering
                  deleteSearchParamsKey([
                    'keywordFilter',
                    'quickSelectFilter',
                    'columnFilterMode',
                    'columnFilterName',
                    'columnFilterValue',
                  ]);
                }}
              >
                <FilterAltOffIcon />
              </IconButton>
            </Tooltip>
          ) : (
            <></>
          )}
        </Box>
        <Box
          display={'flex'}
          maxHeight={'2.5em'}
          sx={{
            '> *': {
              // Applies to all children
              margin: '0 2px',
            },
          }}
        >
          <KeywordSearch
            onChange={(e) => {
              updateSearchValue(e);
            }}
            optionalExternalState={[keywordSearchContents, setKeywordSearchContents]}
          />
          <Tooltip title={props.addTooltip}>
            <span>
              {!isAuditor && (
                <IconButton onClick={props.onAddButtonClick} disabled={!props.onAddButtonClick}>
                  <AddIcon />
                </IconButton>
              )}
            </span>
          </Tooltip>
          <Tooltip title="Export to Excel">
            <IconButton
              onClick={async () => {
                setIsExporting(true);
                let rows = [];
                if (props.tableOperationMode === 'server') {
                  const controller = new AbortController();
                  const signal = controller.signal;
                  const filterModel = {
                    filter: { items: [] },
                    quickFilter: [],
                  };
                  if (
                    getSearchParamsKey('columnFilterName') &&
                    getSearchParamsKey('columnFilterValue') &&
                    getSearchParamsKey('columnFilterMode')
                  ) {
                    filterModel.filter.items = [
                      {
                        value: getSearchParamsKey('columnFilterValue'),
                        operator: getSearchParamsKey('columnFilterMode'),
                        field: getSearchParamsKey('columnFilterName'),
                      },
                    ];
                  }
                  if (getSearchParamsKey('keywordFilter')) {
                    filterModel.quickFilter = getSearchParamsKey('keywordFilter').split(' ');
                  }
                  const sortFilterObj = {
                    ...createSortObj(tableApiRef.current.getSortModel()),
                    ...createFilterObject(filterModel.filter, filterModel.quickFilter),
                  };
                  rows = props.excelDataSource
                    ? await props.excelDataSource(sortFilterObj, signal)
                    : await props.dataSource(sortFilterObj, signal);
                } else {
                  // Client-side tables
                  rows = gridFilteredSortedRowEntriesSelector(tableApiRef).map((row) => row.model);
                }
                if (rows) {
                  if (props.customExcelMap) rows = props.customExcelMap(rows);
                  // Convert back to MUI table model
                  rows = rows.map((r, i) => ({
                    model: r,
                    id: i,
                  }));
                  downloadExcelFile({
                    data: rows,
                    tableName: props.excelTitle,
                    filterName: selectValue,
                    includeDate: true,
                  });
                } else {
                  snackbar.setMessageState({
                    style: snackbar.styles.warning,
                    text: 'Table failed to export.',
                    open: true,
                  });
                }
                setIsExporting(false);
              }}
            >
              {isExporting ? <CircularProgress size={24} /> : <DownloadIcon />}
            </IconButton>
          </Tooltip>
          <Select
            onChange={(e) => {
              setKeywordSearchContents('');
              setSelectValue(e.target.value);
              setSearchParams((params) => {
                params.set('quickSelectFilter', e.target.value);
                params.delete('keywordFilter');
                return params;
              });
              props.onPresetFilterChange(`${e.target.value}`, tableApiRef);
            }}
            sx={{ width: '10em', marginLeft: '0.5em' }}
            value={selectValue}
          >
            {props.presetFilterSelectOptions}
          </Select>
        </Box>
      </Box>
      <CustomDataGrid
        onStateChange={(e) => {
          // Keep track of row count separately
          if (!props.dataSource) {
            setRowCount(Object.values(e.filter.filteredRowsLookup).filter((value) => value).length);
          }
        }}
        onFilterModelChange={(e) => {
          // Can only filter by 1 at a time without DataGrid Pro
          const model: ITableModelCollection = {};
          if (e.items.length > 0) {
            const item = e.items.at(0);
            model.filter = e;
            setSearchParamsKey({
              columnFilterName: item.field,
              columnFilterValue: item.value,
              columnFilterMode: item.operator,
            });
          } else {
            model.filter = e;
            deleteSearchParamsKey(['columnFilterName', 'columnFilterValue', 'columnFilterMode']);
          }

          if (e.quickFilterValues) {
            model.quickFilter = e.quickFilterValues;
            setSearchParamsKey({ keywordFilter: e.quickFilterValues.join(' ') });
          } else {
            model.quickFilter = undefined;
            deleteSearchParamsKey(['keywordFilter']);
          }
          // Get the filter items from MUI, filter out blanks, set state
          setGridFilterItems(e.items.filter((item) => item.value));
        }}
        onSortModelChange={(e) => {
          // Can only sort by 1 at a time without DataGrid Pro
          if (e.length > 0) {
            const item = e.at(0);
            setSearchParamsKey({ columnSortName: item.field, columnSortValue: item.sort });
          } else {
            deleteSearchParamsKey(['columnSortName', 'columnSortValue']);
          }
        }}
        paginationMode={props.tableOperationMode}
        sortingMode={props.tableOperationMode}
        filterMode={props.tableOperationMode}
        rowCount={props.dataSource ? -1 : undefined}
        paginationMeta={props.dataSource ? { hasNextPage: false } : undefined}
        onPaginationModelChange={(model) => {
          setSearchParamsKey({ page: String(model.page), pageSize: String(model.pageSize) });
        }}
        apiRef={tableApiRef}
        // initialState={{
        //   pagination: {
        //     paginationModel: {
        //       pageSize: getSearchParamsKey('pageSize')
        //         ? Number(getSearchParamsKey('pageSize'))
        //         : DEFAULT_PAGE,
        //       page: getSearchParamsKey('page') ? Number(getSearchParamsKey('page')) : DEFAULT_PAGE,
        //     },
        //   },
        //   ...props.initialState,
        // }}
        pageSizeOptions={[10, 20, 30, 100]} // DataGrid max is 100
        disableRowSelectionOnClick
        sx={{
          width: '100%',
          minHeight: '200px',
          // Neutralize the hover colour (causing a flash)
          '& .MuiDataGrid-row.Mui-hovered': {
            backgroundColor: 'transparent',
          },
          // We want hover colour and pointer cursor
          '& .MuiDataGrid-row:hover': {
            cursor: 'pointer',
          },
          '& .MuiDataGrid-cell:focus-within': {
            outline: 'none',
          },
        }}
        loading={dataSourceLoading}
        slots={{ noRowsOverlay: NoRowsOverlay }}
        {...props}
        rows={dataSourceRows && props.dataSource ? dataSourceRows : props.rows}
      />
    </>
  );
};

type PinnedColumnDataGridProps = {
  pinnedFields: string[];
  pinnedSxProps?: SxProps;
  scrollableSxProps?: SxProps;
} & DataGridProps;

/**
 * This is a somewhat hacky workaround for pinned columns in the community version of mui-x.
 * If we ever get a Pro sub, we can just get rid of this entirely.
 * All the sorting and filtering options are disabled since this is just two data grids smushed together
 * and their states are not synced at all.
 */
export const PinnedColumnDataGrid = (props: PinnedColumnDataGridProps) => {
  const { columns, rows, pinnedFields, scrollableSxProps, pinnedSxProps, ...rest } = props;
  columns.forEach((col) => (col.sortable = false));
  const pinnedColumns = columns.filter((col) => pinnedFields.find((a) => a === col.field));
  const scrollableColumns = columns.filter((col) => !pinnedFields.find((a) => a === col.field));

  return (
    <Box style={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
      <Box
        display={'flex'}
        boxShadow={'2px 0px 5px 0px rgba(0, 0, 0, 0.12)'}
        style={{ flex: '0 0 auto' }}
        sx={{ clipPath: 'inset(0px -10px 0px 0px);' }}
        width={'auto'}
      >
        <DataGrid
          columns={pinnedColumns}
          rows={rows}
          disableColumnMenu
          disableColumnFilter
          disableRowSelectionOnClick
          autoHeight
          sx={pinnedSxProps}
          {...rest}
        />
      </Box>
      <Box display={'flex'} width={'auto'} style={{ flex: '1 1 auto', overflowX: 'auto' }}>
        <DataGrid
          columns={scrollableColumns}
          rows={rows}
          disableColumnMenu
          disableColumnFilter
          disableRowSelectionOnClick
          autoHeight
          sx={scrollableSxProps}
          {...rest}
        />
      </Box>
    </Box>
  );
};