Files
mdm-ordinis/ordinis-admin-ui/src/lib/useDictTableState.ts
T

198 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]
}