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 { IPage, pageKey, pageSignature, readPage, writePage } from './pageCache';
|
||||||
import { Pager } from './Pager';
|
import { Pager } from './Pager';
|
||||||
import { RequestsFilterBar } from './RequestsFilterBar';
|
import { RequestsFilterBar } from './RequestsFilterBar';
|
||||||
|
import { ActiveFiltersBar } from './ActiveFiltersBar';
|
||||||
|
import { effectiveUserEmail } from '../../shared/impersonation';
|
||||||
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
import { RequestHistoryDialog } from './RequestHistoryDialog';
|
||||||
import { SortableHeader } from './SortableHeader';
|
import { SortableHeader } from './SortableHeader';
|
||||||
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
import { filterRows, hasActiveFilters, ISortState, SortKey, sortRows } from './requestsTableFilters';
|
||||||
@@ -441,6 +443,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// нет — всё как раньше.
|
// нет — всё как раньше.
|
||||||
const refinement = useTableRefinement({
|
const refinement = useTableRefinement({
|
||||||
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
|
tableKey: `${view}|${allRequestsType?.id ?? ''}`,
|
||||||
|
storageScope: effectiveUserEmail(context),
|
||||||
statusFromRoute: view === 'own:created',
|
statusFromRoute: view === 'own:created',
|
||||||
routeStatus: statusFilter,
|
routeStatus: statusFilter,
|
||||||
onRouteStatusChange: onStatusFilterChange,
|
onRouteStatusChange: onStatusFilterChange,
|
||||||
@@ -454,6 +457,10 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
// Листание страниц и смена фильтра его переиспользуют (заново по сети не
|
||||||
// идём), а автообновление и rp-requests-changed перечитывают.
|
// идём), а автообновление и rp-requests-changed перечитывают.
|
||||||
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
|
const fullListRef = React.useRef<{ key: string; rows: IMyRequest[] } | undefined>(undefined);
|
||||||
|
// Сколько заявок в разделе БЕЗ фильтров — для «Показано 3 из 10» в строке
|
||||||
|
// включённых фильтров. undefined — неизвестно (постраничная загрузка или
|
||||||
|
// статус из маршрута уже сузил список на сервере): тогда пишем просто «Показано 3».
|
||||||
|
const [unfilteredTotal, setUnfilteredTotal] = React.useState<number | undefined>(undefined);
|
||||||
|
|
||||||
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
// Какой ИМЕННО таблице принадлежат лежащие в состоянии rows/totalRows.
|
||||||
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
// Компонент один на все разделы (см. LIST_VIEWS в RequestPortal), поэтому при
|
||||||
@@ -654,6 +661,11 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
setRows(fresh.rows);
|
setRows(fresh.rows);
|
||||||
setTotalRows(fresh.totalRows);
|
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);
|
setLoadedKey(dataKey);
|
||||||
@@ -720,6 +732,18 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
if (rows && page > totalPages) onPageChange(totalPages);
|
if (rows && page > totalPages) onPageChange(totalPages);
|
||||||
}, [loadedKey, dataKey, rows, page, totalPages, onPageChange]);
|
}, [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 (
|
return (
|
||||||
<section className={styles.view}>
|
<section className={styles.view}>
|
||||||
<div className={styles.head}>
|
<div className={styles.head}>
|
||||||
@@ -743,10 +767,21 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
types={view === 'all' ? undefined : types}
|
types={view === 'all' ? undefined : types}
|
||||||
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
|
// Статус — там же, где его колонка: в остальных разделах он у всех строк один.
|
||||||
showStatus={columnsFor(view, '').status}
|
showStatus={columnsFor(view, '').status}
|
||||||
canReset={refinement.canReset}
|
// При включённых фильтрах сброс — в строке ActiveFiltersBar («Сбросить все»);
|
||||||
|
// здесь кнопка остаётся только для случая «изменена одна сортировка».
|
||||||
|
canReset={refinement.canReset && !hasActiveFilters(refinement.barFilters)}
|
||||||
onReset={refinement.onReset}
|
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>}
|
{error && <div className={styles.error}>Не удалось загрузить заявки: {error}</div>}
|
||||||
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
{deleteError && <div className={styles.error}>{deleteError}</div>}
|
||||||
{!error && !loaded && <div className={styles.empty}>Загрузка…</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,
|
SortKey,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
} from './requestsTableFilters';
|
} from './requestsTableFilters';
|
||||||
|
import { readStoredRefinement, writeStoredRefinement } from './filtersStorage';
|
||||||
|
|
||||||
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
|
/** Фильтры и сортировка одной таблицы (см. requestsTableFilters.ts). */
|
||||||
interface IRefinement {
|
interface IRefinement {
|
||||||
@@ -22,6 +23,11 @@ const DEFAULT_REFINEMENT: IRefinement = { filters: EMPTY_FILTERS, sort: DEFAULT_
|
|||||||
interface IOptions {
|
interface IOptions {
|
||||||
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */
|
/** Ключ таблицы: раздел (+ тип для «Все заявки»). */
|
||||||
tableKey: string;
|
tableKey: string;
|
||||||
|
/**
|
||||||
|
* Чьи фильтры запоминать (email пользователя) — ключ записи в localStorage,
|
||||||
|
* чтобы на общем компьютере у двоих фильтры не смешивались. '' — не запоминать.
|
||||||
|
*/
|
||||||
|
storageScope: string;
|
||||||
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
|
/** Статус живёт в маршруте, а не здесь (вкладка «Инициированные»). */
|
||||||
statusFromRoute: boolean;
|
statusFromRoute: boolean;
|
||||||
routeStatus: string;
|
routeStatus: string;
|
||||||
@@ -51,16 +57,23 @@ export interface ITableRefinement {
|
|||||||
*
|
*
|
||||||
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
|
* СВОЁ у каждой таблицы: RequestsTableView один на все разделы, и без ключа по
|
||||||
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
|
* разделу фильтр «Тип: Отпуск» из «Моих заявок» переезжал бы в «На
|
||||||
* согласовании». Живёт, пока смонтирована таблица: переживает открытие заявки
|
* согласовании». Запоминается в localStorage (см. filtersStorage.ts): при первом
|
||||||
* и переходы между её разделами, но не уход на «Поиск»/«Новую заявку».
|
* открытии таблицы берётся оттуда, каждое изменение туда пишется, сброс — удаляет
|
||||||
|
* запись. Так фильтры переживают обновление страницы и уход на «Поиск»/«Новую заявку».
|
||||||
*
|
*
|
||||||
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
|
* Статус на «Инициированных» живёт в маршруте (так было и до фильтров) и
|
||||||
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
|
* отбирается сервером — здесь его не дублируем, чтобы не было двух источников истины.
|
||||||
*/
|
*/
|
||||||
export function useTableRefinement(options: IOptions): ITableRefinement {
|
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 [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 { sort } = refinement;
|
||||||
|
|
||||||
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
|
const localFilters: ITableFilters = statusFromRoute ? { ...refinement.filters, status: '' } : refinement.filters;
|
||||||
@@ -73,6 +86,7 @@ export function useTableRefinement(options: IOptions): ITableRefinement {
|
|||||||
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
|
// переход (onPageChange) ушёл бы от ещё старого маршрута и откатил бы статус.
|
||||||
const save = (next: IRefinement, routeStatusChanged: boolean): void => {
|
const save = (next: IRefinement, routeStatusChanged: boolean): void => {
|
||||||
setByTable((prev) => ({ ...prev, [tableKey]: next }));
|
setByTable((prev) => ({ ...prev, [tableKey]: next }));
|
||||||
|
writeStoredRefinement(storageScope, tableKey, next);
|
||||||
if (!routeStatusChanged && page !== 1) onPageChange(1);
|
if (!routeStatusChanged && page !== 1) onPageChange(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user