fix(dict): default-hide для existing users (v=2 storage) + sticky header/pagination
This commit is contained in:
@@ -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 не сработает.
|
||||
*
|
||||
* <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 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 пусто (юзер ни разу не
|
||||
* взаимодействовал с этим 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 его не должен трогать.
|
||||
*
|
||||
* <p>После любого 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<DictTableState>
|
||||
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. */
|
||||
|
||||
@@ -1209,9 +1209,12 @@ function DictionaryDetail() {
|
||||
/>
|
||||
)}
|
||||
{/* 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>
|
||||
<TableHead>
|
||||
<TableHead className="sticky top-0 z-10 bg-surface">
|
||||
<TableRow>
|
||||
{canMutate && (
|
||||
<TableHeaderCell>
|
||||
@@ -1422,14 +1425,20 @@ function DictionaryDetail() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
{totalPages > 1 && (
|
||||
<RecordsPagination
|
||||
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))}
|
||||
/>
|
||||
// Sticky bottom: pagination всегда в viewport при V-scroll
|
||||
// длинной таблицы — не надо листать вниз чтобы перейти на
|
||||
// page 2. bg-surface + border-top чтобы визуально отделить
|
||||
// от scrolling tbody.
|
||||
<div className="sticky bottom-0 z-10 bg-surface border-t border-line">
|
||||
<RecordsPagination
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user