Designing a React DataGrid to Save Boilerplate

through a grantwas on a budget

There was a need to design the application architecture so that at the same time writing blanks list forms and list element forms, their could massively rivet a junior-level developer. At the same time, after reaching self-sufficiency, the already written code will need to be left without wasting time and money on refactoring.

The admin panel is important because it is used to KYC and ban cheaters. However, for business this is not the main product, therefore, I would like to save money.

Market analysis

Let’s analyze the existing solutions on the market, namely DevExtreme React Grid, ReactVirtualized Grid, KendoReact Data Grid, MUI DataGridPro. All of them shift the responsibility for pagination, sorting and filters to the state of the component or application.

import * as React from 'react';

// import ...

const defaultTheme = createTheme();
const useStylesAntDesign = makeStyles(
  (theme) => ({
    // CSS-in-JS
  }),
  { defaultTheme },
);

const useStyles = makeStyles(
  (theme) => ({
    // CSS-in-JS
  }),
  { defaultTheme },
);

function SettingsPanel(props) {
  const { onApply, type, size, theme } = props;
  const [sizeState, setSize] = React.useState(size);
  const [typeState, setType] = React.useState(type);
  const [selectedPaginationValue, setSelectedPaginationValue] = React.useState(-1);
  const [activeTheme, setActiveTheme] = React.useState(theme);

  const handleSizeChange = React.useCallback((event) => {
    setSize(Number(event.target.value));
  }, []);

  const handleDatasetChange = React.useCallback((event) => {
    setType(event.target.value);
  }, []);

  const handlePaginationChange = React.useCallback((event) => {
    setSelectedPaginationValue(event.target.value);
  }, []);

  const handleThemeChange = React.useCallback((event) => {
    setActiveTheme(event.target.value);
  }, []);

  const handleApplyChanges = React.useCallback(() => {
    onApply({
      size: sizeState,
      type: typeState,
      pagesize: selectedPaginationValue,
      theme: activeTheme,
    });
  }, [sizeState, typeState, selectedPaginationValue, activeTheme, onApply]);

  return (
    <FormGroup className="MuiFormGroup-options" row>
      <FormControl variant="standard">
        <InputLabel>Dataset</InputLabel>
        <Select value={typeState} onChange={handleDatasetChange}>
          <MenuItem value="Employee">Employee</MenuItem>
          <MenuItem value="Commodity">Commodity</MenuItem>
        </Select>
      </FormControl>
      <FormControl variant="standard">
        <InputLabel>Rows</InputLabel>
        <Select value={sizeState} onChange={handleSizeChange}>
          <MenuItem value={100}>100</MenuItem>
          <MenuItem value={1000}>{Number(1000).toLocaleString()}</MenuItem>
          <MenuItem value={10000}>{Number(10000).toLocaleString()}</MenuItem>
          <MenuItem value={100000}>{Number(100000).toLocaleString()}</MenuItem>
        </Select>
      </FormControl>
      <FormControl variant="standard">
        <InputLabel>Page Size</InputLabel>
        <Select value={selectedPaginationValue} onChange={handlePaginationChange}>
          <MenuItem value={-1}>off</MenuItem>
          <MenuItem value={0}>auto</MenuItem>
          <MenuItem value={25}>25</MenuItem>
          <MenuItem value={100}>100</MenuItem>
          <MenuItem value={1000}>{Number(1000).toLocaleString()}</MenuItem>
        </Select>
      </FormControl>
      <FormControl variant="standard">
        <InputLabel>Theme</InputLabel>
        <Select value={activeTheme} onChange={handleThemeChange}>
          <MenuItem value="default">Default Theme</MenuItem>
          <MenuItem value="ant">Ant Design</MenuItem>
        </Select>
      </FormControl>
      <Button
        size="small"
        variant="outlined"
        color="primary"
        onClick={handleApplyChanges}
      >
        <KeyboardArrowRightIcon fontSize="small" /> Apply
      </Button>
    </FormGroup>
  );
}

SettingsPanel.propTypes = {
  onApply: PropTypes.func.isRequired,
  size: PropTypes.number.isRequired,
  theme: PropTypes.oneOf(['ant', 'default']).isRequired,
  type: PropTypes.oneOf(['Commodity', 'Employee']).isRequired,
};

export default function FullFeaturedDemo() {
  const classes = useStyles();
  const antDesignClasses = useStylesAntDesign();
  const [isAntDesign, setIsAntDesign] = React.useState(false);
  const [type, setType] = React.useState('Commodity');
  const [size, setSize] = React.useState(100);
  const { loading, data, setRowLength, loadNewData } = useDemoData({
    dataSet: type,
    rowLength: size,
    maxColumns: 40,
    editable: true,
  });

  const [pagination, setPagination] = React.useState({
    pagination: false,
    autoPageSize: false,
    pageSize: undefined,
  });

  const getActiveTheme = () => {
    return isAntDesign ? 'ant' : 'default';
  };

  const handleApplyClick = (settings) => {
    if (size !== settings.size) {
      setSize(settings.size);
    }

    if (type !== settings.type) {
      setType(settings.type);
    }

    if (getActiveTheme() !== settings.theme) {
      setIsAntDesign(!isAntDesign);
    }

    if (size !== settings.size || type !== settings.type) {
      setRowLength(settings.size);
      loadNewData();
    }

    const newPaginationSettings = {
      pagination: settings.pagesize !== -1,
      autoPageSize: settings.pagesize === 0,
      pageSize: settings.pagesize > 0 ? settings.pagesize : undefined,
    };

    setPagination((currentPaginationSettings) => {
      if (
        currentPaginationSettings.pagination === newPaginationSettings.pagination &&
        currentPaginationSettings.autoPageSize ===
          newPaginationSettings.autoPageSize &&
        currentPaginationSettings.pageSize === newPaginationSettings.pageSize
      ) {
        return currentPaginationSettings;
      }
      return newPaginationSettings;
    });
  };

  return (
    <div className={classes.root}>
      <SettingsPanel
        onApply={handleApplyClick}
        size={size}
        type={type}
        theme={getActiveTheme()}
      />
      <DataGridPro
        className={isAntDesign ? antDesignClasses.root : undefined}
        {...data}
        components={{
          Toolbar: GridToolbar,
        }}
        loading={loading}
        checkboxSelection
        disableSelectionOnClick
        {...pagination}
      />
    </div>
  );
}

View full DataGridPro example you can here. Such an approach inevitably leads to an increase in the cost of a frame, since the copy-paste of sorting, filters and pagination will fall on the project as a legacy dead weight

Abstracts about code problems above

  1. Since it’s different useStatechange type, size and pagination will trigger an intermediate render when the value of the first state has changed, but the effect to update the second has not worked. What if for a request to the server you need to get the current value of all three states at the same time (see the button apply)?

  2. Component SettingsPanel will be used exactly once in the application for filters on this page. It is debatable, but in my opinion, this is more of a function that will return JSX.Elementnot a component

  3. What if we want to do pagination with filters and sorts on the backend side? How to show the loading indicator to the user and block the application at status-500, through copy-paste?

Solution

  1. It is necessary to remove the copy-paste of states and make the request for obtaining data into a pure function that receives input filterData, limit, offset etc., returns either an array or a promise with an array of elements for a list form

  2. You need to make a config for filters in order to comply with the corporate style and exclude the copy-paste of the component SettingsPanel. Or pass the component through props, in which case, agree on a contract in advance.

  3. It is necessary to come up with a higher-order function, which, before the execution of the original data acquisition function (paragraph 1) will turn on the loading indicator, turn it off in the block finally and, if necessary, notify the user about status-500

Additionally

There are not enough buttons to control the lines. For example, what if we want to invite selected traders to a conference? You need to add the “Invitation Status” column to the table and make an “Invite” button. However, if the invitation has already been sent, the corresponding button for the trader should be disabled.

MUI DataGridPro
MUI DataGridPro

And yet, it is desirable to give the opportunity to send invitations to several traders based on the selected rows, the logic of disabling this button requires data memoization when switching pages in the list.

Pure function to get a list of elements

In order to separate the business logic of the application programmer from the system logic of the grid, I would recommend passing a pure function with the following prototype to the props of the list form component

import { List } from 'react-declarative';

type RowId = string | number;

interface IRowData {
  id: RowId;
}

type HandlerPagination = {
  limit: number;
  offset: number;
};

type HandlerSortModel<RowData extends IRowData = any> = {
  field: keyof RowData;
  sort: 'asc' | 'desc';
}[];

type HandlerChips<RowData extends IRowData = any> = 
  Record<keyof RowData, boolean>;

type HandlerResult<RowData extends IRowData = IAnything> = {
  rows: RowData[];
  total: number | null;
};

type Handler<FilterData = any, RowData extends IRowData = any> = (
  filterData: FilterData,
  pagination: ListHandlerPagination,
  sort: ListHandlerSortModel<RowData>,
  chips: ListHandlerChips<RowData>,
  search: string,
) => Promise<HandlerResult<RowData>> | HandlerResult<RowData>;

const handler: Handler = (filterData, pagination, sort, chips, search) => {
	...
};

...

<List
  handler={handler}
  ...
/>

Function handler gets the following five parameters

  1. filterData – contents of the SettingsPanel component, conditionally. Advanced filters, e.g. with ComboBox, sliders, etc.

  2. pagination – an object with two properties: limit and offsetproperties. Passed to the back for pagination, allow you to do the following rows.slice(offset, limit + offset)

  3. sort – an array with sorted columns. Sorting can asc (ascending – ascending) or desc (descending – descending)

  4. chips – an object with boolean flags to filter the list. For example, among the list of employees, we want to select only those registered as self-employed

  5. search- a string with a global search, trying to parse the user’s native language as Google does

The handler function can be assembled via Factorywhich can be put in a hook

import mock from './person_list.json';

...

const handler = useArrayPaginator(mock);

Hook useArrayPaginator will have the following implementation, which will allow you to change the processing of each of the five arguments on the fly handler. By the way, you can also pass a promise to the input, which will return an array of elements without sorting, pagination, etc.

import ...

const EMPTY_RESPONSE = {
    rows: [],
    total: null,
};

type ArrayPaginatorHandler<FilterData = any, RowData extends IRowData = any> =
  RowData[] | ((
    data: FilterData,
    pagination: ListHandlerPagination,
    sort: ListHandlerSortModel<RowData>,
    chips: ListHandlerChips<RowData>,
    search: string,
  ) => Promise<ListHandlerResult<RowData>> | ListHandlerResult<RowData>);

export interface IArrayPaginatorParams<
  FilterData = any,
  RowData extends IRowData = any
> {
    filterHandler?: (rows: RowData[], filterData: FilterData) => RowData[];
    chipsHandler?: (rows: RowData[], chips: HandlerChips<RowData>) => RowData[];
    sortHandler?: (rows: RowData[], sort: HandlerSortModel<RowData>) => RowData[];
    paginationHandler?: (rows: RowData[], pagination: HandlerPagination) => RowData[];
    searchHandler?: (rows: RowData[], search: string) => RowData[];
    withPagination?: boolean;
    withFilters?: boolean;
    withChips?: boolean;
    withSort?: boolean;
    withTotal?: boolean;
    withSearch?: boolean;
    onError?: (e: Error) => void;
    onLoadStart?: () => void;
    onLoadEnd?: (isOk: boolean) => void;
}

export const useArrayPaginator = <
  FilterData = any,
  RowData extends IRowData = any
>(
  rowsHandler: ArrayPaginatorHandler<FilterData, RowData>, {
    filterHandler = (rows, filterData) => {
        Object.entries(filterData).forEach(([key, value]) => {
            if (value) {
                const templateValue = String(value).toLocaleLowerCase();
                rows = rows.filter((row) => {
                    const rowValue = String(row[key as keyof RowData])
                      .toLowerCase();
                    return rowValue.includes(templateValue);
                });
            }
        });
        return rows;
    },
    chipsHandler = (rows, chips) => {
        if (!Object.values(chips).reduce((acm, cur) => acm || cur, false)) {
            return rows;
        }
        const tmp: RowData[][] = [];
        Object.entries(chips).forEach(([chip, enabled]) => {
            if (enabled) {
                tmp.push(rows.filter((row) => row[chip]));
            }
        });
        return tmp.flat();
    },
    sortHandler = (rows, sort) => {
        sort.forEach(({
            field,
            sort,
        }) => {
            rows = rows.sort((a, b) => {
                if (sort === 'asc') {
                    return compareFn(a[field], b[field]);
                } else if (sort === 'desc') {
                    return compareFn(b[field], a[field]);
                }
            });
        });
        return rows;
    },
    searchHandler = (rows, search) => {
        if (rows.length > 0 && search) {
            return rows.filter((row) => {
                return String(row["title"]).toLowerCase()
                  .includes(search.toLowerCase());
            });
        } else {
            return rows;
        }
    },
    paginationHandler = (rows, {
        limit,
        offset,
    }) => {
        if (rows.length > limit) {
            return rows.slice(offset, limit + offset);
        } else {
            return rows;
        }
    },
    withPagination = true,
    withFilters = true,
    withChips = true,
    withSort = true,
    withTotal = true,
    withSearch = true,
    onError,
    onLoadStart,
    onLoadEnd,
}: IArrayPaginatorParams<FilterData, RowData> = {}) => {

    const resolveRows = useMemo(() => async (
        filterData: FilterData,
        pagination: ListHandlerPagination,
        sort: ListHandlerSortModel,
        chips: ListHandlerChips,
        search: string,
    ) => {
        if (typeof rowsHandler === 'function') {
            return await rowsHandler(
              filterData,
              pagination,
              sort,
              chips,
              search
            );
        } else {
            return rowsHandler;
        }
    }, []);

    const handler: Handler<FilterData, RowData> = useMemo(() =>
        async (filterData, pagination, sort, chips, search) => {
          let isOk = true;
          try {
              onLoadStart && onLoadStart();
              const data = await resolveRows(
                filterData,
                pagination,
                sort,
                chips,
                search
              );
              let rows = Array.isArray(data) ? data : data.rows;
              if (withFilters) {
                  rows = filterHandler(rows.slice(0), filterData);
              }
              if (withChips) {
                rows = chipsHandler(rows.slice(0), chips);
              }
              if (withSort) {
                  rows = sortHandler(rows.slice(0), sort);
              }
              if (withSearch) {
                  rows = searchHandler(rows.slice(0), search);
              }
              if (withPagination) {
                  rows = paginationHandler(rows.slice(0), pagination);
              }
              const total = Array.isArray(data)
                ? data.length
                : (data.total || null);
              return {
                  rows,
                  total: withTotal ? total : null,
              };
          } catch (e) {
              isOk = false;
              if (onError) {
                  onError(e as Error);
                  return { ...EMPTY_RESPONSE };
              } else {
                  throw e;
              }
          } finally {
              onLoadEnd && onLoadEnd(isOk);
          }
    }, []);

    return handler;
};

export default useArrayPaginator;

We can wrap the hook useArrayPaginator into your own, which will intercept the callbacks onLoadStart, onLoadEnd, onError and show the user a loading animation. By analogy, it is easy to write useApiPaginatorwhich will collect the request to json:api

Column declaration

In order to display columns, you need to make a config. If necessary, you can write several display engines, with / without virtualization, with infinite scrolling or pages, a mobile version …

import { IColumn, List } from 'react-declatative';

const columns: IColumn<IPerson>[] = [
  {
    type: ColumnType.Component,
    headerName: 'Avatar',
    width: () => 65,
    phoneOrder: 1,
    minHeight: 60,
    element: ({ avatar }) => (
      <Box style={{ display: 'flex', alignItems: 'center' }}>
        <Avatar
          src={avatar}
          alt={avatar}
        />
      </Box>
    ),
  },
  {
    type: ColumnType.Compute,
    primary: true,
    field: 'name',
    headerName: 'Name',
    width: (fullWidth) => Math.max(fullWidth * 0.1, 135),
    compute: ({ firstName, lastName }) => `${firstName} ${lastName}`,  
  },
  ...
  {
    type: ColumnType.Action,
    headerName: 'Actions',
    width: () => 50,
  },
];

...

return (
  <List
    handler={handler}
    ...
    columns={columns}
  />
);
Grid desktop display
Grid desktop display

It is noteworthy that such an interface allows not only to set the column width so that text wrapping changes the line height or enables horizontal scrolling, but also to make a full-fledged adaptation for the list form

Mobile Grid Display
Mobile Grid Display

For each line, you can optionally specify a three-dot menu. There you can put a link to the form of the list element or generate a report for one element

import { IListRowAction, List } from 'react-declatative';

const rowActions: IListRowAction<IPerson>[] = [
  {
    label: 'Export to excel',
    action: 'excel-export',
    isVisible: (person) => person.features.includes('excel-export'),
  },
  {
    label: 'Remove draft',
    action: 'remove-draft',
    isDisabled: (person) => !person.features.includes('remove-draft'),
    icon: Delete,
  },
];

return (
  <List
    handler={handler}
    columns={columns}
    ...
    rowActions={rowActions}
  />
);

The three-dot elements for a list form row can be disabled using the callback isDisabled and hide using callback isVisible . Both callbacks receive a string element as input and return boolean | Promise<boolean>

Actions on multiple lines

Operations that are rarely used can be moved to the global “three dots” menu in FAB in the upper right corner of the form. There you can put an export to Excel for the selected multiple list items or a page content refresh button.

import { IListAction, List } from 'react-declarative';

const actions: IListAction<IPerson>[] = [
  {
    type: ActionType.Add,
    label: 'Create item'
  },
  {
    type: ActionType.Fab,
    label: 'Settings',
  },
  {
    type: ActionType.Menu,
    options: [
      {
        action: 'update-now',
      },
      {
        action: 'resort-action',
      },
      {
        action: 'excel-export',
        label: 'Export to excel',
        isDisabled: async (rows) => {
          return rows.every(({ features }) =>
            features.includes('excel-export')
          );
        },
      },
      {
        action: 'disabled-action',
        isDisabled: async (rows) => {
          await sleep(5_000)
          return true
        },
        label: 'Disabled',
      },
      {
        action: 'hidden-action',
        isVisible: (rows) => false,
        label: 'Hidden',
      },
    ],
  }
];

return (
  <List
    handler={handler}
    columns={columns}
    rowActions={rowActions}
    ...
    actions={actions}
  />
);

However, if the user performs the same action frequently, it is better to make the button visible to minimize the number of clicks. Additionally, you should leave a checkbox that allows you to apply the action to the entire list without unloading id-shniks from the backend

import { IListOperation, List } from 'react-declarative';

const operations: IListOperation<IPerson>[] = [
  {
    action: 'operation-one',
    label: 'Operation one',
  },
  {
    action: 'operation-two',
    label: 'Operation two',
    isAvailable: async (rows, isAll) => {
      console.log({ rows, isAll })
      return true;
    }
  },
  {
    action: 'operation-three',
    label: 'Operation three',
  },
];

return (
  <List
    handler={handler}
    columns={columns}
    rowActions={rowActions}
    actions={actions}
    ...
    operations={operations}
  />
);

Unlike the elements of the global menu at three points, such a button cannot be hidden, therefore, let’s make a callback isAvailable. It also returns boolean | Promise<boolean>however, takes two parameters: the selected list items and a flag isAll. Allows you not to load an array of id-shnikov from the backend, mentioned above

Handling user input

After selecting a context menu item or clicking on the operation button, the following callbacks are called

import { List } from 'react-declarative';

...

const handleAction = (action: string, rows: IPerson) => {
  if (action === 'excel-export') {
    ...
  } else if (...) {
    ...
};

const handleRowAction = (action: string, row: IPerson) => {
  if (action === 'excel-export') {
    ...
  } else if (...) {
    ...
};

const handleOperation = (action: string, rows: IPerson, isAll: boolean) => {
  if (action === 'operation-one') {
    ...
  } else if (...) {
    ...
};

return (
  <List
    handler={handler}
    columns={columns}
    rowActions={rowActions}
    actions={actions}
    operations={operations}
    ...
    onAction={handleAction}
    onRowAction={handleRowAction}
    onOperation={handleOperation}
  />
);

Additionally

In addition to the global search (shown with hidden filters), you can expand the filters and specify several options. Can be rendered either by JSON template, or through slot – passing a component with a pre-declared props interface through the context

import { IField, List } from 'react-declarative';

const filters: IField[] = [
  {
    type: FieldType.Text,
    name: 'firstName',
    title: 'First name',
  },
  {
    type: FieldType.Text,
    name: 'lastName',
    title: 'Last name',
  }
];

return (
  <List
    handler={handler}
    columns={columns}
    rowActions={rowActions}
    actions={actions}
    operations={operations}
    ...
    onAction={handleAction}
    onRowAction={handleRowAction}
    onOperation={handleOperation}
    ...
    filters={filters}
  />
);

Thank you for your attention!

When you start a task like this, you don’t know where to start. I hope these notes will be useful to you)

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *