feat(ui): dict table column sort + visibility toggle (localStorage)
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: DictTableState = { sort: null, hidden: [] }
|
||||
|
||||
const storageKey = (dictName: string): string => `ord-dict-table:${dictName}`
|
||||
|
||||
const readState = (dictName: string): DictTableState => {
|
||||
if (typeof window === 'undefined') return DEFAULT_STATE
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(dictName))
|
||||
if (!raw) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(raw) as Partial<DictTableState>
|
||||
return {
|
||||
sort:
|
||||
parsed.sort &&
|
||||
typeof parsed.sort.col === 'string' &&
|
||||
(parsed.sort.dir === 'asc' || parsed.sort.dir === 'desc')
|
||||
? { col: parsed.sort.col, dir: parsed.sort.dir }
|
||||
: null,
|
||||
hidden: Array.isArray(parsed.hidden)
|
||||
? parsed.hidden.filter((s): s is string => typeof s === 'string')
|
||||
: [],
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
const writeState = (dictName: string, state: DictTableState): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(dictName), JSON.stringify(state))
|
||||
} catch {
|
||||
/* QuotaExceeded / private mode — silently ignore, state остаётся
|
||||
только в memory. */
|
||||
}
|
||||
}
|
||||
|
||||
export function useDictTableState(dictName: string) {
|
||||
const [state, setState] = useState<DictTableState>(() => readState(dictName))
|
||||
|
||||
// Перечитать когда смена dictName (navigation между справочниками).
|
||||
useEffect(() => {
|
||||
setState(readState(dictName))
|
||||
}, [dictName])
|
||||
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user