198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
|
||
/**
|
||
* Per-dict table state — column sort + visibility, persisted в localStorage.
|
||
*
|
||
* <p>Key format: {@code ord-dict-table:<dictName>} (один state на справочник).
|
||
* Не делим по browser tab — пользователь хочет одинаковый layout везде где
|
||
* открывал этот словарь. SSR-safe (guards `typeof window === 'undefined'`).
|
||
*
|
||
* <p>Sort: cycle {@code none → asc → desc → none} по той же колонке.
|
||
* Переключение колонки начинает с {@code asc}. Reduce friction для
|
||
* "посмотреть кто свежее / алфавит / scope grouping".
|
||
*
|
||
* <p>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 не сработает.
|
||
*
|
||
* <p>v1 (MR !282): юзер сам прятал колонки, default hidden=[].
|
||
* <p>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<DictTableState> | 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 его не должен трогать.
|
||
*
|
||
* <p>После любого 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<DictTableState>(() => 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<T>(
|
||
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]
|
||
}
|