feat(ui): dict table column sort + visibility toggle (localStorage)
This commit is contained in:
@@ -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 { nowIsoLocal } from '@/lib/dates'
|
||||||
import { recordDisplayName } from '@/lib/locales'
|
import { recordDisplayName } from '@/lib/locales'
|
||||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
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.
|
* URL search params для filter state — share-friendly, persist между F5.
|
||||||
@@ -369,9 +372,39 @@ function DictionaryDetail() {
|
|||||||
setPage(0)
|
setPage(0)
|
||||||
}, [search, scopeFilter, aoi])
|
}, [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(
|
const visibleRecords = useMemo(
|
||||||
() => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
() => sortedRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
|
||||||
[filteredRecords, page],
|
[sortedRecords, page],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Каноничный список видимых ключей — для select-all и для очистки selection
|
// Каноничный список видимых ключей — для select-all и для очистки selection
|
||||||
@@ -554,15 +587,19 @@ function DictionaryDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Schema-driven extra columns: enum + reference поля — самые info-dense
|
// Schema-driven extra columns: enum + reference поля — самые info-dense
|
||||||
// не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
|
// не-локализованные scalar'ы. Skip name/code/businessKey (уже показаны),
|
||||||
// строку (с businessKey + name + scope + extras + дата + actions = 6
|
// всё нескалярное (object/array/localized).
|
||||||
// колонок, comfortably fits на 1280px). 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 extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
|
||||||
const props = detailQuery.data?.schemaJson?.properties ?? {}
|
const props = detailQuery.data?.schemaJson?.properties ?? {}
|
||||||
const interesting: { key: string; label: string; isFk: boolean }[] = []
|
const interesting: { key: string; label: string; isFk: boolean }[] = []
|
||||||
for (const [key, prop] of Object.entries(props)) {
|
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
|
if (key === 'name' || key === 'code' || key === 'businessKey') continue
|
||||||
const isLocalized = Boolean(prop['x-localized'])
|
const isLocalized = Boolean(prop['x-localized'])
|
||||||
if (isLocalized) continue
|
if (isLocalized) continue
|
||||||
@@ -574,6 +611,22 @@ function DictionaryDetail() {
|
|||||||
return interesting
|
return interesting
|
||||||
}, [detailQuery.data?.schemaJson])
|
}, [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 editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
|
||||||
const rawRecordQuery = useRecordRaw(name, editingKey)
|
const rawRecordQuery = useRecordRaw(name, editingKey)
|
||||||
|
|
||||||
@@ -1107,6 +1160,16 @@ function DictionaryDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{filteredRecords.length === 0 ? (
|
{filteredRecords.length === 0 ? (
|
||||||
@@ -1135,13 +1198,56 @@ function DictionaryDetail() {
|
|||||||
/>
|
/>
|
||||||
</TableHeaderCell>
|
</TableHeaderCell>
|
||||||
)}
|
)}
|
||||||
<TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
|
<SortableHeaderCell
|
||||||
<TableHeaderCell hideBelow="md">name / code</TableHeaderCell>
|
colKey="businessKey"
|
||||||
{extraColumns.map((c) => (
|
sort={dictTable.sort}
|
||||||
<TableHeaderCell key={c.key} hideBelow="lg">{c.label}</TableHeaderCell>
|
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>
|
||||||
))}
|
))}
|
||||||
<TableHeaderCell hideBelow="sm">{t('dict.col.scope')}</TableHeaderCell>
|
{!dictTable.isHidden('scope') && (
|
||||||
<TableHeaderCell hideBelow="lg">{t('dict.col.validFrom')}</TableHeaderCell>
|
<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:
|
{/* Actions column — без подписи per redesign prototype:
|
||||||
* 3-dot trigger открывает menu с History / Edit / Close.
|
* 3-dot trigger открывает menu с History / Edit / Close.
|
||||||
* Aria-label на cell хедере для screen readers (visual: пусто). */}
|
* Aria-label на cell хедере для screen readers (visual: пусто). */}
|
||||||
@@ -1199,13 +1305,17 @@ function DictionaryDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{!dictTable.isHidden('name') && (
|
||||||
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
|
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
|
||||||
{recordDisplayName(
|
{recordDisplayName(
|
||||||
r.data,
|
r.data,
|
||||||
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{extraColumns.map((c) => {
|
)}
|
||||||
|
{extraColumns
|
||||||
|
.filter((c) => !dictTable.isHidden(c.key))
|
||||||
|
.map((c) => {
|
||||||
const v = r.data?.[c.key]
|
const v = r.data?.[c.key]
|
||||||
if (v == null || v === '') {
|
if (v == null || v === '') {
|
||||||
return (
|
return (
|
||||||
@@ -1230,14 +1340,18 @@ function DictionaryDetail() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{!dictTable.isHidden('scope') && (
|
||||||
<TableCell hideBelow="sm">
|
<TableCell hideBelow="sm">
|
||||||
<Badge variant="info">{r.dataScope}</Badge>
|
<Badge variant="info">{r.dataScope}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{!dictTable.isHidden('validFrom') && (
|
||||||
<TableCell hideBelow="lg">
|
<TableCell hideBelow="lg">
|
||||||
<span className="text-cell text-mute">
|
<span className="text-cell text-mute">
|
||||||
{new Date(r.validFrom).toLocaleDateString()}
|
{new Date(r.validFrom).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
{/* 3-dot DropdownMenu per redesign prototype. Заменяет
|
{/* 3-dot DropdownMenu per redesign prototype. Заменяет
|
||||||
|
|||||||
Reference in New Issue
Block a user