Source

stories/PamLayoutGrid.jsx

/** @module PamLayoutGrid **/
import React, { useContext } from 'react';
import PropTypes from 'prop-types';

import { ButtonGroup } from '@mui/material';
import {
  DataGrid as MUIGrid,
  GridToolbar as MUIGridToolbar,
} from '@mui/x-data-grid';
import { Link } from 'react-router-dom';
import { useTheme } from '@mui/material/styles';

import Button from './Button';

import { processLayout, processGenericLayout, convertToLinkFormat } from '../helpers/helpers.js';

import { convertLayoutColumnToMuiColumn } from '../helpers/gridHelpers.js';
import RenderExpandableCell from './RenderExpandableCell';

const gridContext = React.createContext();

// Default options for a somewhat sane initial render of the grid
const defaultSX = {
  width: '100%',
  height: '100%',
  minHeight: '500px',
  minWidth: '700px',
  flexGrow: 1,
};

/**
 * This is our default renderer function for external links
 * @function
 * @param {Object} muiGridColumn - The column to render
 *
 * @returns {React.ReactElement}
 */
const addExternalLinkRendering = (muiGridColumn, columnConfig) => {
  muiGridColumn.renderCell = (params) => {
    return <LinkCellWrapper muiGridColumn={muiGridColumn} params={params} />;
  };
};

//jsdoc for action type
/**
 * This is our default renderer function for grid actions
 * @function
 * @param {Object} props
 * @param {ActionItem[]} props.actions - The actions to render
 * @param {Object} props.params - The params from the grid
 * @param {Object} props.themeGroup - The theme group to use for the grid actions
 * @param {Boolean} props.useTypeVariant - If true then use the type to determine the variant
 * @returns {React.ReactElement}
 */
const GridActions = ({ actions, params, themeGroup, useTypeVariant }) => {
  const theme = useTheme();

  const { gridActionItem } = theme;
  const gAI = themeGroup?.gridActionItem ||
    gridActionItem || { color: '#231100', paddingX: '0.75rem' };

  return (
    <ButtonGroup
      aria-label="action button group"
      className="grid-actions"
      size="small"
      sx={{
        margin: 'auto',
      }}
      variant="text"
    >
      {actions.map((action, index) => {
        let variant = 'text';
        let cssClass = 'grid-action-item';
        if (action.cssClass) {
          cssClass = `${cssClass} ${action.cssClass}`;
        }
        // If the useTypeVariant is true then check the types preferring themeGroup over gridActionItem
        // If themeGroup is not set then use the gridActionItem
        // If NO variant is set then use the default 'text'
        if (useTypeVariant) {
          const type = action.type || null;
          const rootGAIType = gridActionItem?.types?.[type];
          let tempVariant = null;
          if (gAI.types) {
            const gAIType = gAI.types[type];
            if (gAIType.variant) {
              tempVariant = gAIType.variant;
            }
          }
          if (!tempVariant && rootGAIType) {
            tempVariant = rootGAIType.variant;
          }

          variant = tempVariant || 'text';
          cssClass = `${cssClass} action-${type}`;
        }

        const extraProps = action.actionProps || {};
        if (action.clickHandler) {
          // Pass in the row data to the action - up to the caller to unpack
          extraProps.onClick = () => {
            action.clickHandler(params.row);
          };
        }

        return (
          <Button
            key={index}
            className={cssClass}
            size="small"
            sx={gAI}
            variant={variant}
            {...extraProps}
          >
            {action.label}
          </Button>
        );
      })}
    </ButtonGroup>
  );
};

GridActions.propTypes = {
  actions: PropTypes.array.isRequired,
  params: PropTypes.object.isRequired,
  themeGroup: PropTypes.object,
  useTypeVariant: PropTypes.bool,
};

/**
 * @typedef {Object} ActionItem
 * @property {string} label - The label for the action
 * @property {string} type - The type of action
 * @property {string} cssClass - The css class to add to the action
 * @property {number} order - The order to display the action
 * @property {function} clickHandler - The click handler for the action
 * @property {object} actionProps - Any extra props to pass to the action component
 */

/**
 * @typedef {Object} ActionData
 * @property {ActionItem[]} actionList - The list of actions
 */

/**
 * This takes a mui column and adds formatting to it to handle action button fields
 * @function
 * @param {object} muiGridColumn - The column used by the MUIGrid component
 * @param {ActionData} actionData - The action data from the layout
 */
const addActionButtonRendering = (muiGridColumn, actionData) => {
  // Get actions
  const actions = actionData?.actionList || [];
  actions.sort((a, b) => a.order - b.order);

  // Disable export
  muiGridColumn.disableExport = true;

  // Return the action wrapper component
  // This allows us to use hooks inside the component
  muiGridColumn.renderCell = (params) => {
    return (
      <ActionWrapper
        muiGridColumn={muiGridColumn}
        actions={actions}
        params={params}
      />
    );
  };
};

/**
 * Wraps the value in a component which determines if a tooltip should be displayed
 * @function
 * @param {object} muiGridColumn - The column used by the MUIGrid component
 */
const addExpandableRendering = (muiGridColumn) => {
  muiGridColumn.renderCell = (params) => {
    return <RenderExpandableCell muiGridColumn={muiGridColumn} {...params} />;
  };
};

const addNonObjectLinkRendering = (muiGridColumn) => {
  const { render } = muiGridColumn.source || {};
  const { linkFormat } = render || {};

  if (linkFormat) {
    muiGridColumn.renderCell = (params) => {
      const { value, row } = params || {};
      if (value) {
        const link = convertToLinkFormat(linkFormat, row);
        try {
          return <Link to={`${link}`}>{value || 'No Name'}</Link>;
        } catch (err) {
          return params?.value || 'N/A';
        }
      }
      return params?.value || 'N/A';
    };
  }
};

const addObjectReferenceLinkRendering = (muiGridColumn) => {
  const { render, path } = muiGridColumn.source || {};
  const { linkFormat } = render || {};

  if (linkFormat) {
    muiGridColumn.renderCell = (params) => {
      let path_parts = path.split('.');

      let value = params.row;

      for (const element of path_parts) {
        value = value != null ? value[element] : null;
      }

      if (params && value) {
        let link = linkFormat;
        link = link
          .replace('{id}', value.id)
          .replace('${streamID}', value.streamID)
          .replace('${name}', value.name);

        return <Link to={`${link}`}>{params.value.name || 'No Name'}</Link>;
      }
      return 'N/A';
    };
  }
};

const addRendering = (column) => {
  const { source } = column || {};
  switch (source.type) {
    case 0:
    case 1: {
      //Check for linkFormat
      if (source.render?.linkFormat) {
        addNonObjectLinkRendering(column);
      } else {
        addExpandableRendering(column);
      }
      break;
    }
    case 10: {
      //Check for linkFormat
      if (source.render?.linkFormat) {
        addObjectReferenceLinkRendering(column);
      } else {
        addExpandableRendering(column);
      }
      break;
    }
    case 99:
      addActionButtonRendering(column, source.render);
      break; // Action Buttons
    case 100:
      addExternalLinkRendering(column);
      break; // Link
    default:
      addExpandableRendering(column);
      break; // Expandable
  }

  return column;
};

/**
 * This is a wrapper call inside the renderCell function for the action column
 * @function ActionWrapper
 * @param {object} props
 * @param {ActionItem[]} props.actions - The actions to render
 * @param {object} props.params - The params from the grid
 * @param {object} props.muiGridColumn - The column used by the MUIGrid component
 * @returns {React.ReactElement}
 */
const ActionWrapper = (props) => {
  const { actionsComponent, themeGroup, useTypeVariant } = useContext(gridContext);
  // Default to the GridActions component if no custom component is passed in
  const Actions = actionsComponent || GridActions;
  return (
    <Actions
      {...props}
      themeGroup={themeGroup}
      useTypeVariant={useTypeVariant}
    />
  );
};

/**
 * Returns a base action object
 * @function
 * @param {Object} action - the action
 * @param {string} action.label - the label for the action
 * @param {ActionItem[]} action.actionList - the list of actions
 * @returns {Object} - the base action object
 */
const getBaseAction = (action) => {
  return {
    sortable: false, // TODO Mui grid aint respecting this
    render: {
      // Empty space if no label, else MUIGirdheader gets squanched
      label: action?.label || String.fromCharCode(160), // Non-breakable space is char 160
      name: action?.label?.toLowerCase() || 'action',
      actionList: action?.actionList || [],
    },
    type: 99, // TODO need to check with BO/NG if there is a better way to manage these type IDs
    width: action?.width || 200,
    actionProps: action?.actionProps || {},
  };
};

/**
 * This takes a mui column and adds formatting to it to handle external link fields
 * @function LinkCellWrapper
 * @param {object} props - The props
 * @param {object} props.params - The params from the grid
 * @param {object} props.muiGridColumn - The column used by the MUIGrid component
 * @returns {React.ReactElement} - The link cell wrapper
 */
const LinkCellWrapper = ({ params, muiGridColumn }) => {
  const { themeGroup, linkComponent } = useContext(gridContext);
  // Default to the Grid component if no custom component is passed in
  const Link = linkComponent || GridLink;
  return (
    <Link
      params={params}
      themeGroup={themeGroup}
      muiGridColumn={muiGridColumn}
    />
  );
};
LinkCellWrapper.propTypes = {
  params: PropTypes.object,
  muiGridColumn: PropTypes.object,
};

/**
 * @typedef GridLinkParams
 * @property {string | object} value - the value of the cell
 * @property {string} value.url - the url of the cell
 * @property {string} value.label - the label of the cell
 */

/**
 * @function GridLink
 * @param {object} props - The props
 * @param {GridLinkParams} props.params - The params from the grid
 * @param {object} props.muiGridColumn - The column used by the MUIGrid component
 * @returns {React.ReactElement} - The link cell wrapper
 */
const GridLink = ({ params, muiGridColumn }) => {
  if (typeof params.value === 'string') {
    if (params && params.value) {
      return (
        <a href={params.value} target="_blank" rel="noreferrer">
          {params.value}
        </a>
      );
    }
  } else if (params.value && params.value.url) {
    return (
      <a href={params.value.url} target="_blank" rel="noreferrer">
        {params.value?.label || params.value.url}
      </a>
    );
  }
  return muiGridColumn.nullValue;
};

GridLink.propTypes = {
  params: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
  muiGridColumn: PropTypes.object,
};

/**
 * Primary UI component for user interaction
 * @param {Object} props - The props for the component
 * @param {Object} props.data - The data for the grid
 * @param {Object} props.layout - The layout for the grid
 * @param {String} [props.initialSortColumn] - The initial sort column for the grid
 * @param {String} [props.initialSortDirection] - The initial sort direction for the grid
 * @param {Boolean} [props.showToolbar] - Whether to show the toolbar
 * @param {Object} [props.extraGridProps] - Any extra props to pass to the grid. Ideal for tinkering with toolbar options
 * @param {Array} [props.actions] - The actions column for the grid
 * @param {Object} [props.themeGroup] - The theme group for the grid use this to override the default theme group found in "pamGrid" of muiTheme.js
 * @param {Object} [props.actionsComponent] - The component to use for the actions column
 * @param {Object} [props.linkComponent] - The component to use for the link column
 * @param {Boolean} [props.useTypeVariant] - Whether to use the type variant for the grid
 * @param {Number} [props.initialRowCount] - The initial row count for the grid
 * @param {Array} [props.rowsPerPageOptions] - The rows per page options for the grid
 */
// eslint-disable-next-line
const PamLayoutGrid = ({
  data,
  layout,
  initialSortColumn,
  initialSortDirection,
  showToolbar,
  extraGridProps,
  actions,
  themeGroup,
  linkComponent,
  actionsComponent,
  useTypeVariant,
  rowsPerPageOptions,
  initialRowCount,
  ...props
}) => {
  // memo of shared values
  const sharedValues = React.useMemo(() => {
    return { themeGroup, actionsComponent, useTypeVariant, linkComponent };
  }, [themeGroup, actionsComponent, useTypeVariant, linkComponent]);


  const theme = useTheme();
  // Extract the 'pamGrid' theme group from the theme
  const { pamGrid } = theme;

  // If a theme group was passed in, use that instead of the default
  // const theming = themeGroup || pamGrid;
  let theming = themeGroup ? {...pamGrid, ...themeGroup} : {...pamGrid};

  // We add several safeguard values to the theme group, they will be overriden if they are defined in the theme group
  // Not setting these values will cause the grid to render in less than ideal ways
  const sxProps = {
    ...defaultSX,
    ...theming,
  };

  // TODO need to rework this so these are not re-run on every render but still accessible in other functions.
  let processedLayout = layout;
  const hasType = Object.prototype.hasOwnProperty.call(layout, 'type');

  //If the layout has a type property and that property is a number then we are using the new generic layout and should process it as such
  if (hasType && typeof layout.type === 'number') {
    processedLayout = processGenericLayout(layout);
  } else if (hasType && typeof layout.type === 'string') {
    // If the layout has a type property and that property is a string then we are using an already processed new layout and should use it as is
    processedLayout = layout;
  } else {
    //Otherwise use our own layout processing
    processedLayout = { name: 'Unknown', sections: processLayout(layout) };
  }

  const layoutColumns =
    processedLayout?.sections && processedLayout?.sections?.length
      ? processedLayout.sections[0].fields
      : [];

  // This converts the layout field into a list of columns that can be used by the MUIGrid component
  // This needs to be memoized so that it is not re-run every time the component renders other wise column state is lost between renders
  const renderColumns = React.useMemo(() => {
    const nullValue = processedLayout?.data?.source?.nullValue || 'N/A';
    const editable = processedLayout.editable || false;

    // Check for optional actions
    if (actions?.length) {
      actions.sort((a, b) => a.order - b.order);
      const actionsColumns = actions.map((action) => {
        return getBaseAction(action);
      });

      // Append the actions to the end of the columns
      layoutColumns.push(...actionsColumns);
    }
    let theCols = (layoutColumns || [])
      .map((item) => convertLayoutColumnToMuiColumn(item, nullValue, editable))
      .filter(Boolean); // Remove any columns that are not defined
    return theCols.map((column) => addRendering(column));
  }, [layout]);

  // let renderColumns = (layoutColumns || [])
  //   .map((item) => convertLayoutColumnToMuiColumn(item, nullValue, editable))
  //   .filter(Boolean); // Remove any columns that are not defined
  // renderColumns = renderColumns.map((column) => addRendering(column));

  // If we have showToolbar set to true add the Toolbar component to the grid and set other props
  const compThings = showToolbar
    ? {
      components: { Toolbar: MUIGridToolbar },
      // Four buttons appear on the MUI grid by default, we want to hide them
      disableColumnSelector: true,
      disableDensitySelector: true,
      componentsProps: {
        toolbar: {
          // Quick filter is a search box that appears in the toolbar
          showQuickFilter: true,
          quickFilterProps: { debounceMs: 500 },
          //Disable csv and print to completely remove the "Export" button
          csvOptions: { disableToolbarButton: false },
          printOptions: { disableToolbarButton: true },
        },
      },
    }
    : {};

  const rPPOpts = rowsPerPageOptions || [10, 25, 50, 100];
  const initialState = {
    pagination: {
      pageSize: initialRowCount || rPPOpts[0] || 10,
    },
  };

  // This is the start of our new generic layout processing
  if (processedLayout.data) {
    //Check if we have an idField and set the id column of the grid to that
    if (processedLayout.data.source?.idField) {
      props.getRowId = (row) => row[processedLayout.data.source.idField];
    }

    if (processedLayout.data.gridConfig) {
      if (processedLayout.data.gridConfig.sort) {
        // The sort property should have a field property and an order property
        // The field property should be the name of the column to sort by
        // The order property should be either 'asc' or 'desc'
        initialState.sorting = {
          sortModel: [
            {
              field: processedLayout.data.gridConfig.sort.field,
              sort:
                processedLayout.data.gridConfig.sort.order === 'desc'
                  ? 'desc'
                  : 'asc',
            },
          ],
        };
      }
    }
  }

  // Changed on 9.1.2023 to prefer the initialSortColumn and initialSortDirection props instead of the layout if they are set
  // If we have an initial sort column, then we set it in the initial state
  if (initialSortColumn) {
    initialState.sorting = {
      sortModel: [
        {
          field: initialSortColumn,
          sort: initialSortDirection === 'desc' ? 'desc' : 'asc',
        },
      ],
    };
  }

  // This is the handler for the filter model change event
  // It will bubble up the event to the parent component but after we have transformed the filter model into a format that is easier to work with
  const handlerFilterModelChange = (model) => {
    // If we have an onFilterModelChange prop then we call it with the mapped model
    if (props.onFilterModelChange) {
      // We need to map the items in the filter model to link back to the original layout field
      const mappedModel = model.items.map((item) => {
        const layoutField = layoutColumns.find(
          (field) => field.path === item.columnField
        );
        let ret = {
          ...item,
          field: layoutField.source,
        };

        return ret;
      });
      props.onFilterModelChange(mappedModel);
    }
  };

  const handleSortModelChange = (model) => {
    if (props.onSortModelChange) {
      // We need to map the items in the sort model to link back to the original layout field
      const mappedModel = model.map((item) => {
        const layoutField = layoutColumns.find(
          (field) => field.path === item.field
        );
        let ret = {
          ...item,
          field: layoutField.source,
        };

        return ret;
      });

      props.onSortModelChange(mappedModel);
    }
  };

  return (
    <gridContext.Provider value={sharedValues}>
      <MUIGrid
        {...props}
        {...compThings}
        rows={data}
        onFilterModelChange={handlerFilterModelChange}
        onSortModelChange={handleSortModelChange}
        columns={renderColumns}
        sx={sxProps}
        initialState={initialState}
        rowsPerPageOptions={rPPOpts}
        getRowClassName={(params) =>
          params.indexRelativeToCurrentPage % 2 === 0 ? 'row-even' : 'row-odd'
        }
        editMode="row"
        {...extraGridProps}
      />
    </gridContext.Provider>
  );
};

PamLayoutGrid.propTypes = {
  data: PropTypes.arrayOf(PropTypes.object).isRequired,
  rowsPerPageOptions: PropTypes.arrayOf(PropTypes.number),
  initialRowCount: PropTypes.number,
  layout: PropTypes.object.isRequired,
  initialSortColumn: PropTypes.string,
  initialSortDirection: PropTypes.oneOf(['asc', 'desc']),
  showToolbar: PropTypes.bool,
  extraGridProps: PropTypes.object,
  useTypeVariant: PropTypes.bool,
  themeGroup: PropTypes.object,
  linkComponent: PropTypes.elementType,
  actionsComponent: PropTypes.elementType,
  actions: PropTypes.arrayOf(
    PropTypes.shape({
      label: PropTypes.string,
      order: PropTypes.number.isRequired,
      width: PropTypes.number,
      actionList: PropTypes.arrayOf(
        PropTypes.shape({
          label: PropTypes.string.isRequired,
          order: PropTypes.number.isRequired,
          type: PropTypes.string,
          clickHandler: PropTypes.func.isRequired,
        })
      ).isRequired,
    })
  ),
  onFilterModelChange: PropTypes.func,
  onSortModelChange: PropTypes.func,
};

PamLayoutGrid.defaultProps = {
  showToolbar: false,
};

export { PamLayoutGrid as default };