Привет, Хабр! Некоторое время назад мне довелось участвовать в разработке админ-панели для видеоигры с уклоном на совместные соревнования. Так как финансирование осуществлялось за счет гранта, был ограничен бюджет.
Возникла потребность спроектировать архитектуру приложения так, чтобы единовременно написав заготовки списочных форм и форм элемента списка, их мог массово клепать разработчик уровня 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}
/>
);
Спасибо за внимание!
Когда начинаешь подобную задачу, не знаешь с чего начать. Надеюсь, эти заметки будут вам полезны).