feat(ui): dict table column sort + visibility toggle (localStorage)

This commit is contained in:
Александр Зимин
2026-06-02 10:58:59 +00:00
parent c20ffb6dee
commit c2e7dd0c87
5 changed files with 571 additions and 49 deletions
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest'
import { applySort } from './useDictTableState'
describe('applySort', () => {
type Row = { id: string; name: string; age: number | null; ts: string }
const rows: Row[] = [
{ id: 'b', name: 'Beta', age: 30, ts: '2026-01-02T00:00:00Z' },
{ id: 'a', name: 'Alpha', age: null, ts: '2026-01-01T00:00:00Z' },
{ id: 'c', name: 'gamma', age: 25, ts: '2026-01-03T00:00:00Z' },
]
const get = (r: Row, col: string): unknown =>
col === 'id' ? r.id : col === 'name' ? r.name : col === 'age' ? r.age : r.ts
it('returns original when sort=null', () => {
expect(applySort(rows, null, get)).toEqual(rows)
})
it('sorts strings asc with numeric collation (case-insensitive)', () => {
const out = applySort(rows, { col: 'name', dir: 'asc' }, get).map((r) => r.id)
expect(out).toEqual(['a', 'b', 'c'])
})
it('sorts strings desc', () => {
const out = applySort(rows, { col: 'name', dir: 'desc' }, get).map((r) => r.id)
expect(out).toEqual(['c', 'b', 'a'])
})
it('puts null at the end for asc', () => {
const out = applySort(rows, { col: 'age', dir: 'asc' }, get).map((r) => r.id)
expect(out).toEqual(['c', 'b', 'a'])
})
it('puts null at the end for desc too', () => {
const out = applySort(rows, { col: 'age', dir: 'desc' }, get).map((r) => r.id)
expect(out[2]).toBe('a')
})
it('sorts numeric strings naturally (BK-10 > BK-2)', () => {
type R = { bk: string }
const items: R[] = [{ bk: 'BK-10' }, { bk: 'BK-2' }, { bk: 'BK-100' }]
const out = applySort(items, { col: 'bk', dir: 'asc' }, (r) => r.bk).map((r) => r.bk)
expect(out).toEqual(['BK-2', 'BK-10', 'BK-100'])
})
it('sorts ISO date strings asc (lex == chronological)', () => {
const out = applySort(rows, { col: 'ts', dir: 'asc' }, get).map((r) => r.id)
expect(out).toEqual(['a', 'b', 'c'])
})
it('does not mutate input', () => {
const before = [...rows]
applySort(rows, { col: 'name', dir: 'desc' }, get)
expect(rows).toEqual(before)
})
})
// useDictTableState localStorage interactions покрываются E2E (browser)
// — здесь только applySort logic, чистая функция без DOM dependencies.
@@ -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]
}