Как стать автором
Обновить

Проектируем DataGrid на React так, чтобы сэкономить Boilerplate

Время на прочтение 13 мин
Количество просмотров 7.1K

Привет, Хабр! Некоторое время назад мне довелось участвовать в разработке админ-панели для видеоигры с уклоном на совместные соревнования. Так как финансирование осуществлялось за счет гранта, был ограничен бюджет.

Возникла потребность спроектировать архитектуру приложения так, чтобы единовременно написав заготовки списочных форм и форм элемента списка, их мог массово клепать разработчик уровня Junior-. При этом, после выхода на самоокупаемость, уже написанный код потребуется оставить не тратя время и деньги на рефакторинг.

Админ-панель важна, так как с её помощью осуществляется KYC и бан читеров. Однако, для бизнеса это не основной продукт, поэтому, хотелось бы сэкономить деньги.

Анализ рынка

Разберем существующие решения на рынке, а именно DevExtreme React Grid, ReactVirtualized Grid, KendoReact Data Grid, MUI DataGridPro. Все они перекладывают ответственность за пагинацию, сортировки и фильтры на состояние компонента или приложения.

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>
  );
}

Посмотреть полный пример DataGridPro можно тут. Подобный подход неизбежно ведет к повышению стоимости кадра, так как копипаста сортировок, фильтров и пагинации ляжет на проект мертвым грузом legacy.

Тезисы о проблемах кода выше

  1. Так как это разные useState, изменение type, size и pagination вызовет промежуточный рендеринг, когда значение первого состояния изменилось, но эффект для обновления второго не отработал. Что если для запроса на сервер нужно получить актуальное значение всех трех состояний единовременно (см кнопку Apply)?

  2. Компонент SettingsPanel будет использован в приложении ровно один раз для фильтров именно на этой странице. Спорно, но по моему, это скорее функция, которая вернет JSX.Element, а не компонент.

  3. Что если мы хотим сделать пагинацию с фильтрами и сортировками на стороне backend? Как показать пользователю индикатор загрузки и блокировать приложение при status-500, через копипасту?

Решение проблемы

  1. Нужно убрать копипасту состояний и вынести запрос на получение данных в чистую функцию, которая получает на вход filterData, limit, offset и т.д., возвращает либо массив, либо промис с массивом элементов для списочной формы;

  2. Нужно сделать конфиг для фильтров, чтобы соблюдать фирменный стиль и исключить копипасту компонента SettingsPanel. Или передавать компонент через пропсы, в таком случае, заранее согласовать контракт;

  3. Нужно придумать функцию высшего порядка, которая до исполнения оригинальной функции получения данных (пункт 1) включит индикатор загрузки, выключит его в блоке finally и, по необходимости, оповестит пользователя о status-500.

Дополнительно

Не хватает кнопок для управления строками. Например, что если мы хотим пригласить выбранных трейдеров на конференцию? Нужно добавить колонку "Статус приглашения" в таблицу и сделать кнопку "Пригласить". Однако, если приглашение уже отправлено, соответствующую кнопку для трейдера следует отключить.

MUI DataGridPro
MUI DataGridPro

А ещё, желательно дать возможность отправлять приглашения нескольким трейдерам исходя из выбранных строк, логика отключения этой кнопки требует мемоизации данных при переключении страницы в списке.

Чистая функция для получения списка элементов

Для того, чтобы отделить бизнес-логику прикладного программиста от системной логики грида, я бы рекомендовал передать в пропсы компонента списочной формы чистую функцию со следующим прототипом:

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}
  ...
/>

Функция handler получает следующие пять параметров:

  1. filterData - содержимое компонента SettingsPanel, условно. Расширенные фильтры, например, с ComboBox, слайдерами и т.д.;

  2. pagination - объект с двумя свойствами: limit и offsetсвойствами. Передаются на бек для пагинации, позволяют сделать следующее rows.slice(offset, limit + offset);

  3. sort - массив с сортировками колонок. Сортировка может asc (ascending - по возрастанию) или desc (descending - по убыванию);

  4. chips - объект с булевскими флагами для фильтрации списка. Например, среди списка сотрудников мы хотим выбрать только оформленных как самозанятых;

  5. search - строка с глобальным поиском, пытаемся распарсить нативный язык пользователя как делает гугл.

Функцию handler можно собрать через Factory, которую можно положить в хук:

import mock from './person_list.json';

...

const handler = useArrayPaginator(mock);

Хук useArrayPaginator будет иметь следующую реализацию, что позволит на лету поменять обработки каждого из пяти аргументов handler. К слову, так же на вход можно передать промис, который вернет массив элементов без сортировок, пагинации и т.д.

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;

Мы можем обернуть хук useArrayPaginator в свой собственный, который перехватит коллбеки onLoadStart, onLoadEnd, onError и покажет пользователю анимацию загрузки. По аналогии легко написать useApiPaginator, который соберет запрос к json:api.

Объявление колонок

Для того, чтобы отображать колонки, необходимо сделать конфиг. При необходимости, можно написать несколько движков отображения, с/без виртуализации, с бесконечным скроллингом или страницами, мобильной версией...

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}
  />
);
Десктопное отображение грида
Десктопное отображение грида

Примечательно, что подобный интерфейс позволяет не только задать ширину колонки так, чтобы перенос текста менял высоту строки или включал горизонтальный скроллинг, но и сделать полноценную адаптивку для списочной формы.

Мобильное отображение грида
Мобильное отображение грида

Для каждой строки опционально можно указать меню три точки. Туда можно положить ссылку на форму элемента списка или генерацию отчета для одного элемента.

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}
  />
);

Элементы трех точек для строки списочной формы можно отключать используя коллбек isDisabled и скрывать используя коллбек isVisible . Оба коллбека получают на вход элемент строки и возвращают boolean | Promise<boolean>...

Действия над несколькими строками

Операции, которыми используются редко можно вынести в глобальное меню "три точки" в FAB в правом верхнем углу формы. Туда можно положить экспорт в Excel для выбранных нескольких элементов списка или кнопку обновления содержимого страницы.

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}
  />
);

Однако, если пользователь исполняет одно и то же действие часто, лучше сделать кнопку на виду для минимизации количества кликов. Дополнительно, следует оставить флажок, который позволяет применить действие ко всему списку, не выгружая со стороны backend id-шники.

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}
  />
);

В отличие от элементов глобального меню в трех точках такую кнопку нельзя скрыть, поэтому, сделаем коллбек isAvailable. Он так же возвращает boolean | Promise<boolean>, однако, получает два параметра: выбранные элементы списка и флаг isAll. Позволяет не грузить с backend массив id-шников, упомянуто выше.

Обработка ввода пользователя

После выбора элемента контекстного меню или клика по кнопке операции, вызываются следующие коллбеки:

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}
  />
);

Дополнительно

Помимо глобального поиска (показывается при скрытых фильтрах) можно развернуть фильтры и указать несколько параметров. Может рендерится либо по JSON шаблону, либо через слот - передачи компонента с заранее объявленным интерфейсом пропсов через контекст.

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}
  />
);

Спасибо за внимание!

Когда начинаешь подобную задачу, не знаешь с чего начать. Надеюсь, эти заметки будут вам полезны).

Теги:
Хабы:
+1
Комментарии 7
Комментарии Комментарии 7

Публикации

Истории

Работа

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн