import { useCallback, useEffect, useState } from 'react' /** * Per-dict table state — column sort + visibility, persisted в localStorage. * *

Key format: {@code ord-dict-table:} (один state на справочник). * Не делим по browser tab — пользователь хочет одинаковый layout везде где * открывал этот словарь. SSR-safe (guards `typeof window === 'undefined'`). * *

Sort: cycle {@code none → asc → desc → none} по той же колонке. * Переключение колонки начинает с {@code asc}. Reduce friction для * "посмотреть кто свежее / алфавит / scope grouping". * *

Hidden: array column-key'ев которые юзер скрыл. {@code businessKey} * forced visible на уровне UI (см. SortableHeaderCell — primary identifier, * без него таблица бессмысленна). Прочие — optional. */ export type SortDir = 'asc' | 'desc' export type SortState = { col: string; dir: SortDir } | null export interface DictTableState { sort: SortState hidden: string[] } /** * Schema version stored в localStorage. Bumpит когда меняется default * visibility logic — старые записи с другим (или missing) _v читаются как * unmigrated, и для них применяется текущий fallback. После первого * write entry обновляется с current version → больше fallback не сработает. * *

v1 (MR !282): юзер сам прятал колонки, default hidden=[]. *

v2 (MR after !287): info-dense schemas (10+ scalar полей) hide tail * by default. Юзеры из v1 имеют `{hidden:[]}` в storage — без version * bump мы бы их default-показ оставили (т.е. fix не сработал бы для них). */ const STATE_VERSION = 2 interface StoredState extends DictTableState { _v?: number } const DEFAULT_STATE: DictTableState = { sort: null, hidden: [] } const storageKey = (dictName: string): string => `ord-dict-table:${dictName}` const parseSort = (raw: Partial | null | undefined): SortState => { const s = raw?.sort return s && typeof s.col === 'string' && (s.dir === 'asc' || s.dir === 'desc') ? { col: s.col, dir: s.dir } : null } /** * Прочитать persisted state. Если в storage пусто, или запись unversioned/ * outdated → возвращаем `fallbackHidden` (для info-dense schemas: hide tail * columns по умолчанию). Sort всё равно preserve'ем — он юзерский выбор, * version bump его не должен трогать. * *

После любого toggle/setHidden writeState запишет state с current _v → * дальнейшие посещения respect юзерский выбор вне зависимости от fallback. */ const readState = (dictName: string, fallbackHidden: string[]): DictTableState => { if (typeof window === 'undefined') return { sort: null, hidden: fallbackHidden } try { const raw = window.localStorage.getItem(storageKey(dictName)) if (!raw) return { sort: null, hidden: fallbackHidden } const parsed = JSON.parse(raw) as StoredState // Unversioned (v1 → юзер из MR !282) или mismatched _v → reapply // fallback. Sort preserve. if (parsed._v !== STATE_VERSION) { return { sort: parseSort(parsed), hidden: fallbackHidden } } return { sort: parseSort(parsed), hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((s): s is string => typeof s === 'string') : [], } } catch { return { sort: null, hidden: fallbackHidden } } } const writeState = (dictName: string, state: DictTableState): void => { if (typeof window === 'undefined') return try { const stored: StoredState = { ...state, _v: STATE_VERSION } window.localStorage.setItem(storageKey(dictName), JSON.stringify(stored)) } catch { /* QuotaExceeded / private mode — silently ignore, state остаётся только в memory. */ } } export function useDictTableState(dictName: string, defaultHidden: string[] = []) { // Stable key для defaultHidden чтобы useEffect не зацикливался на каждый // render (массив literal каждый раз new reference). Caller обычно строит // defaultHidden из useMemo, но guard всё равно нужен. const defaultKey = defaultHidden.join(',') const [state, setState] = useState(() => readState(dictName, defaultHidden)) // Перечитать когда смена dictName (navigation между справочниками) или // когда меняется набор defaultHidden (schema reload → новые extra columns). useEffect(() => { setState(readState(dictName, defaultHidden)) // eslint-disable-next-line react-hooks/exhaustive-deps -- defaultKey stable }, [dictName, defaultKey]) useEffect(() => { writeState(dictName, state) }, [dictName, state]) /** Click на колонку: cycle через asc → desc → none. * Click на другую колонку сначала: asc. */ const toggleSort = useCallback((col: string) => { setState((prev) => { const cur = prev.sort if (!cur || cur.col !== col) return { ...prev, sort: { col, dir: 'asc' } } if (cur.dir === 'asc') return { ...prev, sort: { col, dir: 'desc' } } return { ...prev, sort: null } }) }, []) const setHidden = useCallback((hidden: string[]) => { setState((prev) => ({ ...prev, hidden })) }, []) const toggleHidden = useCallback((col: string) => { setState((prev) => { const has = prev.hidden.includes(col) const next = has ? prev.hidden.filter((c) => c !== col) : [...prev.hidden, col] return { ...prev, hidden: next } }) }, []) const isHidden = useCallback( (col: string): boolean => state.hidden.includes(col), [state.hidden], ) const reset = useCallback(() => { setState(DEFAULT_STATE) }, []) return { state, sort: state.sort, hidden: state.hidden, toggleSort, setHidden, toggleHidden, isHidden, reset, } } /** Helper для рендера: применяет sort к array записей по getter-функции. * Null/empty значения всегда в конце независимо от direction (NULLS LAST * для обоих asc/desc — стандартное SQL поведение, иначе reverse'нул бы * пустоты наверх и заслонил полезные данные на первой странице). */ export function applySort( rows: T[], sort: SortState, getValue: (row: T, col: string) => unknown, ): T[] { if (!sort) return rows const { col, dir } = sort // Partition: nullish rows всегда в конце, valued — сортируем. const valued: T[] = [] const nullish: T[] = [] for (const r of rows) { const v = getValue(r, col) if (v == null || v === '') nullish.push(r) else valued.push(r) } valued.sort((a, b) => { const av = getValue(a, col) const bv = getValue(b, col) if (typeof av === 'string' && typeof bv === 'string') { // numeric:true → "BK-10" > "BK-2" корректно (для бизнес-ключей). return av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) } if (typeof av === 'number' && typeof bv === 'number') { return av - bv } // Дата ISO-string fallback — lex compare работает на ISO 8601. return String(av).localeCompare(String(bv), undefined, { numeric: true }) }) if (dir === 'desc') valued.reverse() return [...valued, ...nullish] }