Привет, Хабр! Некоторое время назад мне довелось участвовать в разработке админ-панели для видеоигры с уклоном на совместные соревнования. Так как финансирование осуществлялось за счет гранта, был ограничен бюджет.
Возникла потребность спроектировать архитектуру приложения так, чтобы единовременно написав заготовки списочных форм и форм элемента списка, их мог массово клепать разработчик уровня 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.
Тезисы о проблемах кода выше
Так как это разные
useState, изменениеtype,sizeиpaginationвызовет промежуточный рендеринг, когда значение первого состояния изменилось, но эффект для обновления вт��рого не отработал. Что если для запроса на сервер нужно получить актуальное значение всех трех состояний единовременно (см кнопку Apply)?Компонент
SettingsPanelбудет использован в приложении ровно один раз для фильтров именно на этой странице. Спорно, но по моему, это скорее функция, которая вернетJSX.Element, а не компонент.Что если мы хотим сделать пагинацию с фильтрами и сортировками на стороне backend? Как показать пользователю индикатор загрузки и блокировать приложение при status-500, через копипасту?
Решение проблемы
Нужно убрать копипасту состояний и вынести запрос на получение данных в чистую функцию, которая получает на вход
filterData,limit,offsetи т.д., возвращает либо массив, либо промис с массивом элементов для списочной формы;Нужно сделать конфиг для фильтров, чтобы соблюдать фирменный стиль и исключить копипасту компонента
SettingsPanel. Или передавать компонент через пропсы, в таком случае, заранее согласовать контракт;Нужно придумать функцию высшего порядка, которая до исполнения оригинальной функции получения данных (пункт 1) включит индикатор загрузки, выключит его в блоке
finallyи, по необходимости, оповестит пользователя о status-500.
Дополнительно
Не хватает кнопок для управления строками. Например, что если мы хотим пригласить выбранных трейдеров на конференцию? Нужно добавить колонку "Статус приглашения" в таблицу и сделать кнопку "Пригласить". Однако, если приглашение уже отправлено, соответствующую кнопку для трейдера следует отключить.

А ещё, желательно дать возможность отправлять приглашения нескольким трейдерам исходя из выбранных строк, логика отключения этой кнопки требует мемоизации данных при переключении страницы в списке.
Чистая функция для получения списка элементов
Для того, чтобы отделить бизнес-логику прикладного программиста от системной логики грида, я бы рекомендовал передать в пропсы компонента списочной формы чистую функцию со следующим прототипом:
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 получает следующие пять параметров:
filterData - содержимое компонента SettingsPanel, условно. Расширенные фильтры, например, с ComboBox, слайдерами и т.д.;
pagination - объект с двумя свойствами:
limitиoffsetсвойствами. Передаются на бек для пагинации, позволяют сделать следующееrows.slice(offset, limit + offset);sort - массив с сортировками колонок. С��ртировка может
asc(ascending - по возрастанию) илиdesc(descending - по убыванию);chips - объект с булевскими флагами для фильтрации списка. Например, среди списка сотрудников мы хотим выбрать только оформленных как самозанятых;
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}
/>
);Спасибо за внимание!
Когда начинаешь подобную задачу, не знаешь с чего начать. Надеюсь, эти заметки будут вам полезны).
