feat(ui): dict table column sort + visibility toggle (localStorage)
This commit is contained in:
@@ -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. Заменяет
|
||||
|
||||
Reference in New Issue
Block a user