diff --git a/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx b/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx new file mode 100644 index 0000000..c1b5462 --- /dev/null +++ b/ordinis-admin-ui/src/components/dict/ColumnVisibilityMenu.tsx @@ -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}. + * + *

businessKey всегда скрыт из меню (primary identifier, обязательная). + * Остальные (name, extra cols, scope, validFrom) могут быть hidden. + * + *

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 ( + + + + + +

+ + {t('dict.columns.title', { defaultValue: 'Видимость колонок' })} + + +
+ + + + ) +} diff --git a/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx b/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx new file mode 100644 index 0000000..18743a2 --- /dev/null +++ b/ordinis-admin-ui/src/components/dict/SortableHeaderCell.tsx @@ -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). + * + *

Indicator: + *

+ * + *

Pass-through props у TableHeaderCell (hideBelow, align, aria-label). + * Кликабельность — button-inside-cell, не сам cell — чтобы padding и + * outline кликабельной зоны были корректными visually. + */ + +export interface SortableHeaderCellProps + extends Omit, '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 ( + + + + ) +} diff --git a/ordinis-admin-ui/src/lib/useDictTableState.test.ts b/ordinis-admin-ui/src/lib/useDictTableState.test.ts new file mode 100644 index 0000000..03e14e4 --- /dev/null +++ b/ordinis-admin-ui/src/lib/useDictTableState.test.ts @@ -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. diff --git a/ordinis-admin-ui/src/lib/useDictTableState.ts b/ordinis-admin-ui/src/lib/useDictTableState.ts new file mode 100644 index 0000000..5b5d2ac --- /dev/null +++ b/ordinis-admin-ui/src/lib/useDictTableState.ts @@ -0,0 +1,155 @@ +import { useCallback, useEffect, useState } from 'react' + +/** + * Per-dict table state — column sort + visibility, persisted в localStorage. + * + *

Key format: {@code ord-dict-table:} (один state на справочник). + * Не делим по browser tab — пользователь хочет одинаковый layout везде где + * открывал этот словарь. SSR-safe (guards `typeof window === 'undefined'`). + * + *

Sort: cycle {@code none → asc → desc → none} по той же колонке. + * Переключение колонки начинает с {@code asc}. Reduce friction для + * "посмотреть кто свежее / алфавит / scope grouping". + * + *

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 + 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(() => 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( + 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] +} diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index 6fc57e1..3982995 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -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(() => { + 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() { )} + {/* Column visibility menu — persisted в localStorage per dict. + * Скрывает любую non-essential колонку (всё кроме businessKey). */} +

+
{filteredRecords.length === 0 ? ( @@ -1135,13 +1198,56 @@ function DictionaryDetail() { /> )} - {t('dict.col.businessKey')} - name / code - {extraColumns.map((c) => ( - {c.label} - ))} - {t('dict.col.scope')} - {t('dict.col.validFrom')} + + {t('dict.col.businessKey')} + + {!dictTable.isHidden('name') && ( + + {t('dict.col.name', { defaultValue: 'name / code' })} + + )} + {extraColumns + .filter((c) => !dictTable.isHidden(c.key)) + .map((c) => ( + + {c.label} + + ))} + {!dictTable.isHidden('scope') && ( + + {t('dict.col.scope')} + + )} + {!dictTable.isHidden('validFrom') && ( + + {t('dict.col.validFrom')} + + )} {/* 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} - - {recordDisplayName( - r.data, - detailQuery.data?.defaultLocale ?? 'ru-RU', - )} - - {extraColumns.map((c) => { - const v = r.data?.[c.key] - if (v == null || v === '') { + {!dictTable.isHidden('name') && ( + + {recordDisplayName( + r.data, + detailQuery.data?.defaultLocale ?? 'ru-RU', + )} + + )} + {extraColumns + .filter((c) => !dictTable.isHidden(c.key)) + .map((c) => { + const v = r.data?.[c.key] + if (v == null || v === '') { + return ( + + + + ) + } + const text = String(v) return ( - + + {text} + ) - } - const text = String(v) - return ( - - - {text} - - - ) - })} - - {r.dataScope} - - - - {new Date(r.validFrom).toLocaleDateString()} - - + })} + {!dictTable.isHidden('scope') && ( + + {r.dataScope} + + )} + {!dictTable.isHidden('validFrom') && ( + + + {new Date(r.validFrom).toLocaleDateString()} + + + )} e.stopPropagation()}>
{/* 3-dot DropdownMenu per redesign prototype. Заменяет