From fece673017b5fbae0035f275cabba14f3d06879c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 2 Jun 2026 12:22:19 +0000 Subject: [PATCH] =?UTF-8?q?fix(dict):=20default-hide=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20existing=20users=20(v=3D2=20storage)=20+=20sticky=20header/p?= =?UTF-8?q?agination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/lib/useDictTableState.ts | 63 +++++++++++++------ .../src/routes/dictionaries.$name.tsx | 29 ++++++--- 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/ordinis-admin-ui/src/lib/useDictTableState.ts b/ordinis-admin-ui/src/lib/useDictTableState.ts index 8a07d78..91b7af7 100644 --- a/ordinis-admin-ui/src/lib/useDictTableState.ts +++ b/ordinis-admin-ui/src/lib/useDictTableState.ts @@ -24,45 +24,72 @@ export interface DictTableState { hidden: string[] } +/** + * Schema version stored в localStorage. Bumpит когда меняется default + * visibility logic — старые записи с другим (или missing) _v читаются как + * unmigrated, и для них применяется текущий fallback. После первого + * write entry обновляется с current version → больше fallback не сработает. + * + *

v1 (MR !282): юзер сам прятал колонки, default hidden=[]. + *

v2 (MR after !287): info-dense schemas (10+ scalar полей) hide tail + * by default. Юзеры из v1 имеют `{hidden:[]}` в storage — без version + * bump мы бы их default-показ оставили (т.е. fix не сработал бы для них). + */ +const STATE_VERSION = 2 + +interface StoredState extends DictTableState { + _v?: number +} + const DEFAULT_STATE: DictTableState = { sort: null, hidden: [] } const storageKey = (dictName: string): string => `ord-dict-table:${dictName}` +const parseSort = (raw: Partial | null | undefined): SortState => { + const s = raw?.sort + return s && + typeof s.col === 'string' && + (s.dir === 'asc' || s.dir === 'desc') + ? { col: s.col, dir: s.dir } + : null +} + /** - * Прочитать persisted state. Если в storage пусто (юзер ни разу не - * взаимодействовал с этим dict) → используем `fallbackHidden` как initial - * visibility (для info-dense schemas: hide tail columns по умолчанию, - * чтобы таблица помещалась в viewport без H-scroll). Любой toggle/setHidden - * запишет state в storage → дальнейшие посещения respect юзерский выбор - * вне зависимости от fallback. + * Прочитать persisted state. Если в storage пусто, или запись unversioned/ + * outdated → возвращаем `fallbackHidden` (для info-dense schemas: hide tail + * columns по умолчанию). Sort всё равно preserve'ем — он юзерский выбор, + * version bump его не должен трогать. + * + *

После любого toggle/setHidden writeState запишет state с current _v → + * дальнейшие посещения respect юзерский выбор вне зависимости от fallback. */ const readState = (dictName: string, fallbackHidden: string[]): DictTableState => { - const fallback: DictTableState = { sort: null, hidden: fallbackHidden } - if (typeof window === 'undefined') return fallback + if (typeof window === 'undefined') return { sort: null, hidden: fallbackHidden } try { const raw = window.localStorage.getItem(storageKey(dictName)) - if (!raw) return fallback - const parsed = JSON.parse(raw) as Partial + if (!raw) return { sort: null, hidden: fallbackHidden } + const parsed = JSON.parse(raw) as StoredState + // Unversioned (v1 → юзер из MR !282) или mismatched _v → reapply + // fallback. Sort preserve. + if (parsed._v !== STATE_VERSION) { + return { sort: parseSort(parsed), hidden: fallbackHidden } + } 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, + sort: parseSort(parsed), hidden: Array.isArray(parsed.hidden) ? parsed.hidden.filter((s): s is string => typeof s === 'string') : [], } } catch { - return fallback + return { sort: null, hidden: fallbackHidden } } } const writeState = (dictName: string, state: DictTableState): void => { if (typeof window === 'undefined') return try { - window.localStorage.setItem(storageKey(dictName), JSON.stringify(state)) + const stored: StoredState = { ...state, _v: STATE_VERSION } + window.localStorage.setItem(storageKey(dictName), JSON.stringify(stored)) } catch { /* QuotaExceeded / private mode — silently ignore, state остаётся только в memory. */ diff --git a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx index efe3759..ff47d1f 100644 --- a/ordinis-admin-ui/src/routes/dictionaries.$name.tsx +++ b/ordinis-admin-ui/src/routes/dictionaries.$name.tsx @@ -1209,9 +1209,12 @@ function DictionaryDetail() { /> )} {/* No wrap — outer editor panel handles border. Direct - * table → continuous visual flow toolbar→table в one card. */} + * table → continuous visual flow toolbar→table в one card. + * TableHead sticky top: при page V-scroll header не уезжает, + * sort/aria всегда видны. z-10 чтобы над cells. bg-surface + * чтобы строки под header не просвечивали при scroll. */} - + {canMutate && ( @@ -1422,14 +1425,20 @@ function DictionaryDetail() {
{totalPages > 1 && ( - setPage((p) => Math.max(0, p - 1))} - onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} - /> + // Sticky bottom: pagination всегда в viewport при V-scroll + // длинной таблицы — не надо листать вниз чтобы перейти на + // page 2. bg-surface + border-top чтобы визуально отделить + // от scrolling tbody. +

+ setPage((p) => Math.max(0, p - 1))} + onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} + /> +
)} )}