Merge branch 'fix/dict-table-default-hide-existing-users' into 'main'

fix(dict): default-hide для existing users (v=2 storage) + sticky header/pagination

See merge request 2-6/2-6-4/terravault/ordinis!288
This commit is contained in:
Александр Зимин
2026-06-02 12:22:19 +00:00
2 changed files with 64 additions and 28 deletions
+45 -18
View File
@@ -24,45 +24,72 @@ export interface DictTableState {
hidden: string[] hidden: string[]
} }
/**
* Schema version stored в localStorage. Bumpит когда меняется default
* visibility logic — старые записи с другим (или missing) _v читаются как
* unmigrated, и для них применяется текущий fallback. После первого
* write entry обновляется с current version → больше fallback не сработает.
*
* <p>v1 (MR !282): юзер сам прятал колонки, default hidden=[].
* <p>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 DEFAULT_STATE: DictTableState = { sort: null, hidden: [] }
const storageKey = (dictName: string): string => `ord-dict-table:${dictName}` const storageKey = (dictName: string): string => `ord-dict-table:${dictName}`
const parseSort = (raw: Partial<DictTableState> | 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 пусто (юзер ни разу не * Прочитать persisted state. Если в storage пусто, или запись unversioned/
* взаимодействовал с этим dict) → используем `fallbackHidden` как initial * outdated → возвращаем `fallbackHidden` (для info-dense schemas: hide tail
* visibility (для info-dense schemas: hide tail columns по умолчанию, * columns по умолчанию). Sort всё равно preserve'ем — он юзерский выбор,
* чтобы таблица помещалась в viewport без H-scroll). Любой toggle/setHidden * version bump его не должен трогать.
* запишет state в storage → дальнейшие посещения respect юзерский выбор *
* вне зависимости от fallback. * <p>После любого toggle/setHidden writeState запишет state с current _v →
* дальнейшие посещения respect юзерский выбор вне зависимости от fallback.
*/ */
const readState = (dictName: string, fallbackHidden: string[]): DictTableState => { const readState = (dictName: string, fallbackHidden: string[]): DictTableState => {
const fallback: DictTableState = { sort: null, hidden: fallbackHidden } if (typeof window === 'undefined') return { sort: null, hidden: fallbackHidden }
if (typeof window === 'undefined') return fallback
try { try {
const raw = window.localStorage.getItem(storageKey(dictName)) const raw = window.localStorage.getItem(storageKey(dictName))
if (!raw) return fallback if (!raw) return { sort: null, hidden: fallbackHidden }
const parsed = JSON.parse(raw) as Partial<DictTableState> 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 { return {
sort: sort: parseSort(parsed),
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) hidden: Array.isArray(parsed.hidden)
? parsed.hidden.filter((s): s is string => typeof s === 'string') ? parsed.hidden.filter((s): s is string => typeof s === 'string')
: [], : [],
} }
} catch { } catch {
return fallback return { sort: null, hidden: fallbackHidden }
} }
} }
const writeState = (dictName: string, state: DictTableState): void => { const writeState = (dictName: string, state: DictTableState): void => {
if (typeof window === 'undefined') return if (typeof window === 'undefined') return
try { 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 { } catch {
/* QuotaExceeded / private mode — silently ignore, state остаётся /* QuotaExceeded / private mode — silently ignore, state остаётся
только в memory. */ только в memory. */
@@ -1209,9 +1209,12 @@ function DictionaryDetail() {
/> />
)} )}
{/* No <Panel> wrap — outer editor panel handles border. Direct {/* No <Panel> 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. */}
<Table> <Table>
<TableHead> <TableHead className="sticky top-0 z-10 bg-surface">
<TableRow> <TableRow>
{canMutate && ( {canMutate && (
<TableHeaderCell> <TableHeaderCell>
@@ -1422,14 +1425,20 @@ function DictionaryDetail() {
</TableBody> </TableBody>
</Table> </Table>
{totalPages > 1 && ( {totalPages > 1 && (
<RecordsPagination // Sticky bottom: pagination всегда в viewport при V-scroll
page={page} // длинной таблицы — не надо листать вниз чтобы перейти на
totalPages={totalPages} // page 2. bg-surface + border-top чтобы визуально отделить
totalCount={filteredCount} // от scrolling tbody.
pageSize={PAGE_SIZE} <div className="sticky bottom-0 z-10 bg-surface border-t border-line">
onPrev={() => setPage((p) => Math.max(0, p - 1))} <RecordsPagination
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))} page={page}
/> totalPages={totalPages}
totalCount={filteredCount}
pageSize={PAGE_SIZE}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
/>
</div>
)} )}
</> </>
)} )}