feat: фильтры таблиц заявок запоминаются, видно какие включены
Tests / frontend-test (push) Failing after 2m11s
Tests / frontend-test (push) Failing after 2m11s
Пользователи жаловались, что фильтры постоянно сбрасываются. Тип, статус, период и сортировка теперь хранятся в localStorage — отдельно на каждого пользователя и таблицу, с проверкой значений при чтении. Чтобы забытый фильтр не выглядел как «пропали заявки», над таблицей строка «Включены фильтры» с чипами, «Сбросить все» (чистит и хранилище) и «Показано X из Y». Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
@import '../../../../styles/design-tokens';
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $sp-1;
|
||||
margin-bottom: $sp-4;
|
||||
padding: $sp-2 $sp-3;
|
||||
border: 1px solid $gold-300;
|
||||
border-radius: $r-sm;
|
||||
background: $gold-50;
|
||||
font-family: $font-sans;
|
||||
font-size: $fs-sm;
|
||||
color: $gray-900;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: $sp-1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 4px 6px 4px 10px;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-full;
|
||||
background: $white;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: $gray-500;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: .15s;
|
||||
|
||||
&:hover {
|
||||
background: $gray-200;
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: $fs-caption;
|
||||
color: $gray-500;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { Button } from '@tnsu/ui-kit';
|
||||
import { IRequestType } from '../../shared/approval';
|
||||
import { ITableFilters } from './requestsTableFilters';
|
||||
import { activeFilterChips } from './activeFilterChips';
|
||||
import styles from './ActiveFiltersBar.module.scss';
|
||||
|
||||
interface Props {
|
||||
filters: ITableFilters;
|
||||
types: IRequestType[] | undefined;
|
||||
onChange: (filters: ITableFilters) => void;
|
||||
onReset: () => void;
|
||||
/** Сколько заявок показано после фильтров; undefined — ещё грузится (счётчик не показываем). */
|
||||
shown: number | undefined;
|
||||
/** Сколько всего в разделе без фильтров; undefined — неизвестно (тогда «из …» не пишем). */
|
||||
total: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* «Включены фильтры: …» над таблицей. Фильтры запоминаются между заходами (см.
|
||||
* filtersStorage.ts), и без этой строки человек мог бы забыть про включённый
|
||||
* фильтр и решить, что заявки пропали. Видна только при включённом фильтре.
|
||||
*/
|
||||
export const ActiveFiltersBar: React.FC<Props> = ({ filters, types, onChange, onReset, shown, total }) => {
|
||||
const chips = activeFilterChips(filters, types);
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.bar} role="status">
|
||||
<div className={styles.main}>
|
||||
<span className={styles.label}>Включены фильтры:</span>
|
||||
<div className={styles.chips}>
|
||||
{chips.map((chip) => (
|
||||
<span key={chip.key} className={styles.chip}>
|
||||
{chip.label}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.chipRemove}
|
||||
aria-label={`Убрать фильтр «${chip.label}»`}
|
||||
title="Убрать фильтр"
|
||||
onClick={() => onChange(chip.without)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
Сбросить все
|
||||
</Button>
|
||||
</div>
|
||||
{shown !== undefined && (
|
||||
<div className={styles.count}>
|
||||
{total !== undefined && total !== shown ? `Показано ${shown} из ${total}` : `Показано ${shown}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,8 @@ import { useRoutedQuery } from '../../shared/usePersistedQuery';
|
||||
import { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||
import { Pager } from './Pager';
|
||||
import { RequestsFilterBar } from './RequestsFilterBar';
|
||||
import { ActiveFiltersBar } from './ActiveFiltersBar';
|
||||
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
||||
import { SortableHeader } from './SortableHeader';
|
||||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||
@@ -441,6 +443,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
// нет — всё как раньше.
|
||||
const refinement = useTableRefinement({
|
||||
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
|
||||
storageScope: effectiveUserEmail(context),
|
||||
statusFromRoute: view === 'own:created',
|
||||
routeStatus: statusFilter,
|
||||
onRouteStatusChange: onStatusFilterChange,
|
||||
@@ -454,6 +457,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
||||
// идём), а автообновление и rp-requests-changed перечитывают.
|
||||
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
|
||||
// Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке
|
||||
// включённых фильтров. undefined — неизвестно (постраничная загрузка или
|
||||
// статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3».
|
||||
const [unfilteredTotal, setUnfilteredTotal] = React.useState<number | undefined>(undefined);
|
||||
|
||||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||
@@ -654,6 +661,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
setRows(fresh.rows);
|
||||
setTotalRows(fresh.totalRows);
|
||||
}
|
||||
const full = fullListRef.current;
|
||||
const narrowedByRouteStatus = view === 'own:created' && !!statusFilter;
|
||||
setUnfilteredTotal(
|
||||
isRefined && !narrowedByRouteStatus && full && full.key === fullKey ? full.rows.length : undefined
|
||||
);
|
||||
// Данные точно от текущей таблицы — теперь по ним можно судить о
|
||||
// количестве страниц (см. сброс страницы ниже).
|
||||
setLoadedKey(dataKey);
|
||||
@@ -720,6 +732,18 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
if (rows && page > totalPages) onPageChange(totalPages);
|
||||
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
|
||||
|
||||
// Запомненный тип заявки, которого больше нет среди доступных пользователю
|
||||
// (тип убрали или доступ к нему отозвали), — снимаем: иначе таблица навсегда
|
||||
// осталась бы пустой по фильтру, который в выпадающем списке даже не выбрать.
|
||||
const storedTypeId = refinement.barFilters.typeId;
|
||||
React.useEffect(() => {
|
||||
if (view === 'all' || !types || storedTypeId === undefined) return;
|
||||
if (types.some((t) => t.id === storedTypeId)) return;
|
||||
refinement.onFiltersChange({ ...refinement.barFilters, typeId: undefined });
|
||||
// refinement пересобирается на каждый рендер — реагируем только на сам тип и справочник.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view, types, storedTypeId]);
|
||||
|
||||
return (
|
||||
<section className={styles.view}>
|
||||
<div className={styles.head}>
|
||||
@@ -743,10 +767,21 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
||||
types={view === 'all' ? undefined : types}
|
||||
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
|
||||
showStatus={columnsFor(view, '').status}
|
||||
canReset={refinement.canReset}
|
||||
// При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»);
|
||||
// здесь кнопка остаётся только для случая «изменена одна сортировка».
|
||||
canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)}
|
||||
onReset={refinement.onReset}
|
||||
/>
|
||||
|
||||
<ActiveFiltersBar
|
||||
filters={refinement.barFilters}
|
||||
types={types}
|
||||
onChange={refinement.onFiltersChange}
|
||||
onReset={refinement.onReset}
|
||||
shown={loaded ? total : undefined}
|
||||
total={unfilteredTotal}
|
||||
/>
|
||||
|
||||
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
||||
{!error && !loaded && <div className={styles.empty}>Загрузка…</div>}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { activeFilterChips, formatDay } from './activeFilterChips';
|
||||
import { EMPTY_FILTERS } from './requestsTableFilters';
|
||||
import { IRequestType } from '../../shared/approval';
|
||||
|
||||
const TYPES = [{ id: 1, name: 'Заявка на отпуск' }] as IRequestType[];
|
||||
|
||||
describe('activeFilterChips', () => {
|
||||
it('без фильтров — чипов нет', () => {
|
||||
expect(activeFilterChips(EMPTY_FILTERS, TYPES)).toEqual([]);
|
||||
});
|
||||
|
||||
it('чип на каждый фильтр, убирание чипа снимает только его', () => {
|
||||
const filters = { typeId: 1, status: 'Черновик', dateFrom: '2026-09-01', dateTo: '2026-09-30' };
|
||||
const chips = activeFilterChips(filters, TYPES);
|
||||
expect(chips.map((c) => c.label)).toEqual([
|
||||
'Тип: Заявка на отпуск',
|
||||
'Статус: Черновик',
|
||||
'Дата: 01.09.2026 – 30.09.2026',
|
||||
]);
|
||||
expect(chips[2].without).toEqual({ typeId: 1, status: 'Черновик', dateFrom: '', dateTo: '' });
|
||||
});
|
||||
|
||||
it('открытый с одной стороны период', () => {
|
||||
expect(activeFilterChips({ ...EMPTY_FILTERS, dateFrom: '2026-09-01' }, TYPES)[0].label).toBe('Дата: с 01.09.2026');
|
||||
expect(activeFilterChips({ ...EMPTY_FILTERS, dateTo: '2026-09-30' }, TYPES)[0].label).toBe('Дата: по 30.09.2026');
|
||||
});
|
||||
|
||||
it('тип ещё не загружен — чип по номеру, а не пропадает', () => {
|
||||
expect(activeFilterChips({ ...EMPTY_FILTERS, typeId: 7 }, undefined)[0].label).toBe('Тип: №7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDay', () => {
|
||||
it('ISO-день в ДД.ММ.ГГГГ', () => {
|
||||
expect(formatDay('2026-10-08')).toBe('08.10.2026');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IRequestType } from '../../shared/approval';
|
||||
import { EMPTY_FILTERS, ITableFilters } from './requestsTableFilters';
|
||||
|
||||
/** Чип включённого фильтра: подпись + каким станет набор фильтров, если чип убрать. */
|
||||
export interface IFilterChip {
|
||||
key: 'type' | 'status' | 'period';
|
||||
label: string;
|
||||
without: ITableFilters;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'ДД.ММ.ГГГГ'. */
|
||||
export function formatDay(day: string): string {
|
||||
const [y, m, d] = day.split('-');
|
||||
return y && m && d ? `${d}.${m}.${y}` : day;
|
||||
}
|
||||
|
||||
function periodLabel(from: string, to: string): string {
|
||||
if (from && to) return `${formatDay(from)} – ${formatDay(to)}`;
|
||||
return from ? `с ${formatDay(from)}` : `по ${formatDay(to)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Чипы для строки «Включены фильтры». Тип показывается по названию; если тип
|
||||
* не найден среди доступных (types ещё грузятся) — по номеру, чтобы чип не
|
||||
* исчезал и фильтр нельзя было «потерять» из виду.
|
||||
*/
|
||||
export function activeFilterChips(filters: ITableFilters, types: IRequestType[] | undefined): IFilterChip[] {
|
||||
const chips: IFilterChip[] = [];
|
||||
|
||||
if (filters.typeId !== undefined) {
|
||||
const type = types ? types.filter((t) => t.id === filters.typeId)[0] : undefined;
|
||||
chips.push({
|
||||
key: 'type',
|
||||
label: `Тип: ${type ? type.name : `№${filters.typeId}`}`,
|
||||
without: { ...filters, typeId: EMPTY_FILTERS.typeId },
|
||||
});
|
||||
}
|
||||
if (filters.status) {
|
||||
chips.push({ key: 'status', label: `Статус: ${filters.status}`, without: { ...filters, status: '' } });
|
||||
}
|
||||
if (filters.dateFrom || filters.dateTo) {
|
||||
chips.push({
|
||||
key: 'period',
|
||||
label: `Дата: ${periodLabel(filters.dateFrom, filters.dateTo)}`,
|
||||
without: { ...filters, dateFrom: '', dateTo: '' },
|
||||
});
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { parseStoredRefinement, readStoredRefinement, writeStoredRefinement } from './filtersStorage';
|
||||
import { DEFAULT_SORT, EMPTY_FILTERS } from './requestsTableFilters';
|
||||
|
||||
const SCOPE = 'User@tnsu.kz';
|
||||
const TABLE = 'own:created|';
|
||||
|
||||
describe('parseStoredRefinement', () => {
|
||||
it('корректная запись восстанавливается целиком', () => {
|
||||
const raw = JSON.stringify({
|
||||
filters: { typeId: 5, status: 'На согласовании', dateFrom: '2026-09-01', dateTo: '2026-09-30' },
|
||||
sort: { key: 'status', direction: 'asc' },
|
||||
});
|
||||
expect(parseStoredRefinement(raw)).toEqual({
|
||||
filters: { typeId: 5, status: 'На согласовании', dateFrom: '2026-09-01', dateTo: '2026-09-30' },
|
||||
sort: { key: 'status', direction: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('пусто или битый JSON — ничего не восстанавливаем', () => {
|
||||
expect(parseStoredRefinement(null)).toBeUndefined();
|
||||
expect(parseStoredRefinement('{oops')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('неизвестный статус, кривые даты и сортировка отбрасываются по отдельности', () => {
|
||||
const raw = JSON.stringify({
|
||||
filters: { typeId: '5', status: 'Неизвестный', dateFrom: '01.09.2026', dateTo: '2026-09-30' },
|
||||
sort: { key: 'author', direction: 'up' },
|
||||
});
|
||||
expect(parseStoredRefinement(raw)).toEqual({
|
||||
filters: { ...EMPTY_FILTERS, dateTo: '2026-09-30' },
|
||||
sort: DEFAULT_SORT,
|
||||
});
|
||||
});
|
||||
|
||||
it('перевёрнутый период не восстанавливается', () => {
|
||||
const raw = JSON.stringify({ filters: { dateFrom: '2026-10-01', dateTo: '2026-09-01' } });
|
||||
expect(parseStoredRefinement(raw)?.filters).toEqual(EMPTY_FILTERS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('read/writeStoredRefinement', () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
it('запись и чтение по пользователю и таблице; регистр email не важен', () => {
|
||||
const value = { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT };
|
||||
writeStoredRefinement(SCOPE, TABLE, value);
|
||||
expect(readStoredRefinement('user@tnsu.kz', TABLE)).toEqual(value);
|
||||
expect(readStoredRefinement('other@tnsu.kz', TABLE)).toBeUndefined();
|
||||
expect(readStoredRefinement(SCOPE, 'approvals|')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('состояние по умолчанию удаляет запись — сброс чистит хранилище', () => {
|
||||
writeStoredRefinement(SCOPE, TABLE, { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT });
|
||||
writeStoredRefinement(SCOPE, TABLE, { filters: EMPTY_FILTERS, sort: DEFAULT_SORT });
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('без пользователя ничего не пишем и не читаем', () => {
|
||||
writeStoredRefinement('', TABLE, { filters: { ...EMPTY_FILTERS, status: 'Черновик' }, sort: DEFAULT_SORT });
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
expect(readStoredRefinement('', TABLE)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { REQUEST_STATUS_CHOICES } from '../../shared/approval';
|
||||
import { DEFAULT_SORT, EMPTY_FILTERS, hasActiveFilters, isDefaultSort, ISortState, ITableFilters, SortKey } from './requestsTableFilters';
|
||||
|
||||
/**
|
||||
* Фильтры и сортировка таблиц заявок в localStorage — чтобы не сбрасывались
|
||||
* при обновлении страницы, переходе в «Поиск»/«Новую заявку» и возврате из
|
||||
* заявки по ссылке. Своя запись на каждого пользователя и таблицу.
|
||||
*
|
||||
* Хранилище может быть недоступно (приватный режим, политика браузера) или
|
||||
* содержать устаревшее/битое значение — тогда таблица работает как раньше, с
|
||||
* чистыми фильтрами, а не падает. Любое сомнительное поле отбрасывается.
|
||||
*/
|
||||
|
||||
export interface IStoredRefinement {
|
||||
filters: ITableFilters;
|
||||
sort: ISortState;
|
||||
}
|
||||
|
||||
/** Версия формата: поменяется структура — старые записи просто не прочитаются. */
|
||||
const STORAGE_PREFIX = 'rp-table-filters:v1';
|
||||
|
||||
const SORT_KEYS: SortKey[] = ['code', 'type', 'date', 'status'];
|
||||
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function storageKey(scope: string, tableKey: string): string {
|
||||
return `${STORAGE_PREFIX}:${scope.trim().toLowerCase()}:${tableKey}`;
|
||||
}
|
||||
|
||||
function asDay(value: unknown): string {
|
||||
return typeof value === 'string' && ISO_DAY.test(value) ? value : '';
|
||||
}
|
||||
|
||||
/** Разобрать сохранённое значение; всё, что не прошло проверку, — как «не задано». */
|
||||
export function parseStoredRefinement(raw: string | null): IStoredRefinement | undefined {
|
||||
if (!raw) return undefined;
|
||||
let parsed: { filters?: Record<string, unknown>; sort?: Record<string, unknown> };
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return undefined;
|
||||
|
||||
const f = parsed.filters || {};
|
||||
const typeId = typeof f.typeId === 'number' && isFinite(f.typeId) ? f.typeId : undefined;
|
||||
const status = typeof f.status === 'string' && REQUEST_STATUS_CHOICES.indexOf(f.status) !== -1 ? f.status : '';
|
||||
let dateFrom = asDay(f.dateFrom);
|
||||
let dateTo = asDay(f.dateTo);
|
||||
// Перевёрнутый период пустым списком выглядел бы как поломка — такой не восстанавливаем.
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
dateFrom = '';
|
||||
dateTo = '';
|
||||
}
|
||||
|
||||
const s = parsed.sort || {};
|
||||
const sortKey = SORT_KEYS.indexOf(s.key as SortKey) !== -1 ? (s.key as SortKey) : undefined;
|
||||
const direction = s.direction === 'asc' || s.direction === 'desc' ? s.direction : undefined;
|
||||
const sort: ISortState = sortKey && direction ? { key: sortKey, direction } : DEFAULT_SORT;
|
||||
|
||||
return { filters: { ...EMPTY_FILTERS, typeId, status, dateFrom, dateTo }, sort };
|
||||
}
|
||||
|
||||
export function readStoredRefinement(scope: string, tableKey: string): IStoredRefinement | undefined {
|
||||
if (!scope) return undefined;
|
||||
try {
|
||||
return parseStoredRefinement(window.localStorage.getItem(storageKey(scope, tableKey)));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохранить; состояние по умолчанию — удалить запись (сброс чистит хранилище). */
|
||||
export function writeStoredRefinement(scope: string, tableKey: string, value: IStoredRefinement): void {
|
||||
if (!scope) return;
|
||||
try {
|
||||
const key = storageKey(scope, tableKey);
|
||||
if (!hasActiveFilters(value.filters) && isDefaultSort(value.sort)) {
|
||||
window.localStorage.removeItem(key);
|
||||
} else {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
} catch {
|
||||
// Хранилище недоступно — фильтры просто не запомнятся, работать это не мешает.
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SortKey,
|
||||
toggleSort,
|
||||
} from './requestsTableFilters';
|
||||
import { readStoredRefinement, writeStoredRefinement } from './filtersStorage';
|
||||
|
||||
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
|
||||
interface IRefinement {
|
||||
@@ -22,6 +23,11 @@ const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_
|
||||
interface IOptions {
|
||||
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */
|
||||
tableKey: string;
|
||||
/**
|
||||
* Чьи фильтры запоминать (email пользователя) — ключ записи в localStorage,
|
||||
* чтобы на общем компьютере у двоих фильтры не смешивались. '' — не запоминать.
|
||||
*/
|
||||
storageScope: string;
|
||||
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
|
||||
statusFromRoute: boolean;
|
||||
routeStatus: string;
|
||||
@@ -51,16 +57,23 @@ export interface ITableRefinement {
|
||||
*
|
||||
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
|
||||
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
|
||||
* согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки
|
||||
* и переходы между её разделами, но не уход на «Поиск»/«Новую заявку».
|
||||
* согласовании». Запоминается в localStorage (см. filtersStorage.ts): при первом
|
||||
* открытии таблицы берётся оттуда, каждое изменение туда пишется, сброс — удаляет
|
||||
* запись. Так фильтры переживают обновление страницы и уход на «Поиск»/«Новую заявку».
|
||||
*
|
||||
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
|
||||
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
|
||||
*/
|
||||
export function useTableRefinement(options: IOptions): ITableRefinement {
|
||||
const { tableKey, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options;
|
||||
const { tableKey, storageScope, statusFromRoute, routeStatus, onRouteStatusChange, page, onPageChange } = options;
|
||||
const [byTable, setByTable] = React.useState<Record<string, IRefinement>>({});
|
||||
const refinement = byTable[tableKey] ?? DEFAULT_REFINEMENT;
|
||||
// Таблица ещё не трогалась в этом сеансе — состояние из хранилища. Читаем
|
||||
// только тогда: дальше источник истины — byTable, а хранилище лишь его копия.
|
||||
const stored = React.useMemo(
|
||||
() => (byTable[tableKey] ? undefined : readStoredRefinement(storageScope, tableKey)),
|
||||
[byTable, storageScope, tableKey]
|
||||
);
|
||||
const refinement = byTable[tableKey] ?? stored ?? DEFAULT_REFINEMENT;
|
||||
const { sort } = refinement;
|
||||
|
||||
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
|
||||
@@ -73,6 +86,7 @@ export function useTableRefinement(options: IOptions): ITableRefinement {
|
||||
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
|
||||
const save = (next: IRefinement, routeStatusChanged: boolean): void => {
|
||||
setByTable((prev) => ({ ...prev, [tableKey]: next }));
|
||||
writeStoredRefinement(storageScope, tableKey, next);
|
||||
if (!routeStatusChanged && page !== 1) onPageChange(1);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user