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
@@ -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. Заменяет