Merge branch 'feat/dict-table-sort-columns' into 'main'

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

See merge request 2-6/2-6-4/terravault/ordinis!282
This commit is contained in:
Александр Зимин
2026-06-02 10:58:59 +00:00
5 changed files with 571 additions and 49 deletions
@@ -0,0 +1,119 @@
import { useTranslation } from 'react-i18next'
import { ColumnsIcon } from '@phosphor-icons/react'
import { Popover, PopoverTrigger, PopoverContent, Checkbox } from '@/ui'
import { cn } from '@/lib/utils'
/**
* Кнопка «Колонки» в toolbar таблицы записей. Открывает popover со списком
* available колонок + чекбоксы видимости. Mutations через onToggle —
* persisted в localStorage через {@link useDictTableState}.
*
* <p>businessKey всегда скрыт из меню (primary identifier, обязательная).
* Остальные (name, extra cols, scope, validFrom) могут быть hidden.
*
* <p>UX: counter "N/M выбрано" + кнопка «Все»/«Очистить» для quick toggles.
*/
export interface ColumnOption {
key: string
label: string
/** Если true — чекбокс disabled (e.g. businessKey всегда видим). */
alwaysVisible?: boolean
}
export interface ColumnVisibilityMenuProps {
columns: ColumnOption[]
hidden: string[]
onToggle: (col: string) => void
/** Quick action: show all columns. */
onShowAll: () => void
}
export function ColumnVisibilityMenu({
columns,
hidden,
onToggle,
onShowAll,
}: ColumnVisibilityMenuProps) {
const { t } = useTranslation()
const togglable = columns.filter((c) => !c.alwaysVisible)
const visibleCount = togglable.filter((c) => !hidden.includes(c.key)).length
const total = togglable.length
const allVisible = visibleCount === total
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t('dict.columns.menuLabel', { defaultValue: 'Колонки' })}
title={t('dict.columns.menuLabel', { defaultValue: 'Колонки' })}
className={cn(
// size-9 = 36px touch target — на уровне других toolbar buttons.
// Не 44 потому что toolbar height 32-36px и не хочется растягивать.
'h-9 px-2 inline-flex items-center gap-1.5 rounded-md border border-line bg-surface text-cell text-ink-2',
'hover:text-ink hover:bg-surface-2 transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
<ColumnsIcon size={14} weight="regular" />
<span className="hidden sm:inline">
{t('dict.columns.button', { defaultValue: 'Колонки' })}
</span>
<span className="text-mute tabular-nums">{visibleCount}/{total}</span>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-line">
<span className="text-cell text-mute">
{t('dict.columns.title', { defaultValue: 'Видимость колонок' })}
</span>
<button
type="button"
disabled={allVisible}
onClick={onShowAll}
className={cn(
'text-cell',
allVisible
? 'text-mute cursor-not-allowed'
: 'text-accent hover:underline',
)}
>
{t('dict.columns.showAll', { defaultValue: 'Показать все' })}
</button>
</div>
<ul className="py-1 max-h-72 overflow-y-auto">
{columns.map((c) => {
const isHidden = hidden.includes(c.key)
const disabled = c.alwaysVisible
return (
<li key={c.key}>
<label
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-body',
disabled
? 'text-mute cursor-not-allowed'
: 'text-ink hover:bg-surface-2 cursor-pointer',
)}
>
<Checkbox
checked={!isHidden}
disabled={disabled}
onChange={() => !disabled && onToggle(c.key)}
aria-label={c.label}
/>
<span className="truncate flex-1">{c.label}</span>
{disabled && (
<span className="text-cap text-mute">
{t('dict.columns.required', { defaultValue: 'обяз.' })}
</span>
)}
</label>
</li>
)
})}
</ul>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,76 @@
import * as React from 'react'
import { CaretUpIcon, CaretDownIcon, CaretUpDownIcon } from '@phosphor-icons/react'
import { TableHeaderCell } from '@/ui'
import type { SortState } from '@/lib/useDictTableState'
import { cn } from '@/lib/utils'
/**
* Кликабельный заголовок колонки с indicator'ом sort state. Cycle через
* none → asc → desc → none. State приходит из {@link useDictTableState}
* (persisted в localStorage per dict).
*
* <p>Indicator:
* <ul>
* <li>none → серая двойная стрелка (CaretUpDown).</li>
* <li>asc → акцентная стрелка вверх (CaretUp).</li>
* <li>desc → акцентная стрелка вниз (CaretDown).</li>
* </ul>
*
* <p>Pass-through props у TableHeaderCell (hideBelow, align, aria-label).
* Кликабельность — button-inside-cell, не сам cell — чтобы padding и
* outline кликабельной зоны были корректными visually.
*/
export interface SortableHeaderCellProps
extends Omit<React.ComponentProps<typeof TableHeaderCell>, 'children' | 'onClick'> {
/** Идентификатор колонки в state.sort. */
colKey: string
/** Текст заголовка. */
children: React.ReactNode
/** Текущий sort state из useDictTableState. */
sort: SortState
/** Callback на click — useDictTableState.toggleSort. */
onSort: (col: string) => void
}
export function SortableHeaderCell({
colKey,
children,
sort,
onSort,
...cellProps
}: SortableHeaderCellProps) {
const active = sort?.col === colKey
const dir = active ? sort.dir : null
return (
<TableHeaderCell {...cellProps}>
<button
type="button"
onClick={() => onSort(colKey)}
aria-label={
dir === 'asc'
? `${children} (по возрастанию)`
: dir === 'desc'
? `${children} (по убыванию)`
: `${children} (сортировать)`
}
aria-sort={dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'}
className={cn(
'inline-flex items-center gap-1 -my-1 -mx-1 px-1 py-1 rounded-sm',
'hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active ? 'text-accent' : 'text-inherit',
)}
>
<span>{children}</span>
{dir === 'asc' ? (
<CaretUpIcon size={12} weight="bold" />
) : dir === 'desc' ? (
<CaretDownIcon size={12} weight="bold" />
) : (
<CaretUpDownIcon size={12} weight="regular" className="text-mute/60 opacity-0 group-hover:opacity-100 transition-opacity" />
)}
</button>
</TableHeaderCell>
)
}
@@ -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]
}
@@ -61,6 +61,9 @@ import { UserCell } from '@/lib/useUserDisplay'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
import { useDictTableState, applySort } from '@/lib/useDictTableState'
import { ColumnVisibilityMenu, type ColumnOption } from '@/components/dict/ColumnVisibilityMenu'
import { SortableHeaderCell } from '@/components/dict/SortableHeaderCell'
/**
* URL search params для filter state — share-friendly, persist между F5.
@@ -369,9 +372,39 @@ function DictionaryDetail() {
setPage(0)
}, [search, scopeFilter, aoi])
// Table state — sort (cycle none→asc→desc) + hidden columns persisted в
// localStorage per dict через useDictTableState.
const dictTable = useDictTableState(name)
// Getter для applySort — извлекает значение по col key из record.
// Изолирован чтобы sort работал унифицировано для всех типов колонок.
const recordColValue = useMemo(
() => (r: FlattenedRecord, col: string): unknown => {
if (col === 'businessKey') return r.businessKey
if (col === 'name') {
return recordDisplayName(
r.data,
detailQuery.data?.defaultLocale ?? 'ru-RU',
)
}
if (col === 'scope') return r.dataScope
if (col === 'validFrom') return r.validFrom
return r.data?.[col]
},
[detailQuery.data?.defaultLocale],
)
// Sort применяем к filteredRecords ДО pagination — иначе sort внутри
// страницы (например asc) показал бы только N сортированных значений из
// пула N+M, остальные на следующих страницах. Sort scope = filtered set.
const sortedRecords = useMemo(
() => applySort(filteredRecords, dictTable.sort, recordColValue),
[filteredRecords, dictTable.sort, recordColValue],
)
const visibleRecords = useMemo(
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
[filteredRecords, page],
() => sortedRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
[sortedRecords, page],
)
// Каноничный список видимых ключей — для select-all и для очистки selection
@@ -554,15 +587,19 @@ function DictionaryDetail() {
}
// Schema-driven extra columns: enum + reference поля — самые info-dense
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
// строку (с businessKey + name + scope + extras + дата + actions = 6
// колонок, comfortably fits на 1280px). Skip name/code/businessKey
// (уже показаны), всё нескалярное (object/array/localized).
// не-локализованные scalar'ы. Skip name/code/businessKey (уже показаны),
// всё нескалярное (object/array/localized).
//
// Раньше cap = 2, чтобы строка comfortably fits на 1280px. Сейчас cap
// снят — все eligible колонки в pool, юзер выбирает через
// ColumnVisibilityMenu (стейт persisted в localStorage per dict).
// Soft cap 8 — защита от absurd schemas (50+ enum полей сломали бы
// toolbar layout до того как user успел зайти в menu).
const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
const props = detailQuery.data?.schemaJson?.properties ?? {}
const interesting: { key: string; label: string; isFk: boolean }[] = []
for (const [key, prop] of Object.entries(props)) {
if (interesting.length >= 2) break
if (interesting.length >= 8) break
if (key === 'name' || key === 'code' || key === 'businessKey') continue
const isLocalized = Boolean(prop['x-localized'])
if (isLocalized) continue
@@ -574,6 +611,22 @@ function DictionaryDetail() {
return interesting
}, [detailQuery.data?.schemaJson])
// Column registry: pool из которого юзер выбирает что показывать
// (см. ColumnVisibilityMenu в toolbar). businessKey alwaysVisible
// (primary identifier, без него nothing makes sense). Остальные —
// toggleable. Stored в localStorage через useDictTableState (объявлен
// выше, рядом с sortedRecords).
const columnOptions = useMemo<ColumnOption[]>(() => {
const opts: ColumnOption[] = [
{ key: 'businessKey', label: t('dict.col.businessKey'), alwaysVisible: true },
{ key: 'name', label: t('dict.col.name', { defaultValue: 'name / code' }) },
...extraColumns.map((c) => ({ key: c.key, label: c.label })),
{ key: 'scope', label: t('dict.col.scope') },
{ key: 'validFrom', label: t('dict.col.validFrom') },
]
return opts
}, [extraColumns, t])
const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
const rawRecordQuery = useRecordRaw(name, editingKey)
@@ -1107,6 +1160,16 @@ function DictionaryDetail() {
</button>
</div>
)}
{/* Column visibility menu — persisted в localStorage per dict.
* Скрывает любую non-essential колонку (всё кроме businessKey). */}
<div className="ml-auto">
<ColumnVisibilityMenu
columns={columnOptions}
hidden={dictTable.hidden}
onToggle={dictTable.toggleHidden}
onShowAll={() => dictTable.setHidden([])}
/>
</div>
</div>
{filteredRecords.length === 0 ? (
@@ -1135,13 +1198,56 @@ function DictionaryDetail() {
/>
</TableHeaderCell>
)}
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
<TableHeaderCell hideBelow="md">name / code</TableHeaderCell>
{extraColumns.map((c) => (
<TableHeaderCell key={c.key} hideBelow="lg">{c.label}</TableHeaderCell>
))}
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
<SortableHeaderCell
colKey="businessKey"
sort={dictTable.sort}
onSort={dictTable.toggleSort}
>
{t('dict.col.businessKey')}
</SortableHeaderCell>
{!dictTable.isHidden('name') && (
<SortableHeaderCell
colKey="name"
hideBelow="md"
sort={dictTable.sort}
onSort={dictTable.toggleSort}
>
{t('dict.col.name', { defaultValue: 'name / code' })}
</SortableHeaderCell>
)}
{extraColumns
.filter((c) => !dictTable.isHidden(c.key))
.map((c) => (
<SortableHeaderCell
key={c.key}
colKey={c.key}
hideBelow="lg"
sort={dictTable.sort}
onSort={dictTable.toggleSort}
>
{c.label}
</SortableHeaderCell>
))}
{!dictTable.isHidden('scope') && (
<SortableHeaderCell
colKey="scope"
hideBelow="sm"
sort={dictTable.sort}
onSort={dictTable.toggleSort}
>
{t('dict.col.scope')}
</SortableHeaderCell>
)}
{!dictTable.isHidden('validFrom') && (
<SortableHeaderCell
colKey="validFrom"
hideBelow="lg"
sort={dictTable.sort}
onSort={dictTable.toggleSort}
>
{t('dict.col.validFrom')}
</SortableHeaderCell>
)}
{/* Actions column — без подписи per redesign prototype:
* 3-dot trigger открывает menu с History / Edit / Close.
* Aria-label на cell хедере для screen readers (visual: пусто). */}
@@ -1199,45 +1305,53 @@ function DictionaryDetail() {
) : null}
</div>
</TableCell>
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
{recordDisplayName(
r.data,
detailQuery.data?.defaultLocale ?? 'ru-RU',
)}
</TableCell>
{extraColumns.map((c) => {
const v = r.data?.[c.key]
if (v == null || v === '') {
{!dictTable.isHidden('name') && (
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
{recordDisplayName(
r.data,
detailQuery.data?.defaultLocale ?? 'ru-RU',
)}
</TableCell>
)}
{extraColumns
.filter((c) => !dictTable.isHidden(c.key))
.map((c) => {
const v = r.data?.[c.key]
if (v == null || v === '') {
return (
<TableCell key={c.key} hideBelow="lg">
<span className="text-cell text-mute/70"></span>
</TableCell>
)
}
const text = String(v)
return (
<TableCell key={c.key} hideBelow="lg">
<span className="text-cell text-mute/70"></span>
<span
className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk
? 'bg-accent/8 text-accent'
: 'bg-ink/8 text-ink',
].join(' ')}
>
{text}
</span>
</TableCell>
)
}
const text = String(v)
return (
<TableCell key={c.key} hideBelow="lg">
<span
className={[
'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
c.isFk
? 'bg-accent/8 text-accent'
: 'bg-ink/8 text-ink',
].join(' ')}
>
{text}
</span>
</TableCell>
)
})}
<TableCell hideBelow="sm">
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
<TableCell hideBelow="lg">
<span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</span>
</TableCell>
})}
{!dictTable.isHidden('scope') && (
<TableCell hideBelow="sm">
<Badge variant="info">{r.dataScope}</Badge>
</TableCell>
)}
{!dictTable.isHidden('validFrom') && (
<TableCell hideBelow="lg">
<span className="text-cell text-mute">
{new Date(r.validFrom).toLocaleDateString()}
</span>
</TableCell>
)}
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end">
{/* 3-dot DropdownMenu per redesign prototype. Заменяет