@exxatdesignux/ui 0.2.17 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +6 -1
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
- package/package.json +1 -1
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +11 -4
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +44 -14
- package/template/app/layout.tsx +2 -0
- package/template/components/app-sidebar.tsx +4 -3
- package/template/components/compliance-table.tsx +0 -20
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/placement-board-card.tsx +1 -1
- package/template/components/placements-list-view.tsx +1 -1
- package/template/components/placements-table.tsx +3 -36
- package/template/components/product-switcher.tsx +2 -3
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +12 -24
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/sites-table.tsx +0 -20
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +16 -13
- package/template/components/team-table.tsx +0 -21
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +2 -2
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +58 -11
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# KPI flat band (`KeyMetrics` `variant="flat"`)
|
|
2
|
+
|
|
3
|
+
> **Component:** `components/key-metrics.tsx` — **`flatMetricsHairlineClass`**, **`flatBandStyle`**.
|
|
4
|
+
> **Tokens:** `app/globals.css` — `--key-metrics-flat-*`.
|
|
5
|
+
> **Cursor:** `.cursor/rules/exxat-kpi-flat-band.mdc` · `.cursor/skills/exxat-kpi-flat-band/SKILL.md`
|
|
6
|
+
> **Related:** `docs/kpi-strip-max-four-pattern.md`, `docs/kpi-trend-pattern.md`
|
|
7
|
+
|
|
8
|
+
## Intent
|
|
9
|
+
|
|
10
|
+
List hubs and the main dashboard mix view use **`KeyMetrics variant="flat"`** as a **metrics strip without a surface**: users see KPI copy and deltas on the **page canvas**, with a **brand-colored glow** under the band only. This is **not** a card, tinted panel, or `gap-px` grid fill.
|
|
11
|
+
|
|
12
|
+
## MUST
|
|
13
|
+
|
|
14
|
+
1. **No band surface** — The `<section>` background is **only** `var(--key-metrics-flat-band-radial)`. **Do not** stack `--key-metrics-flat-band-linear`, opaque gradients, or `box-shadow` fills that read as a grey/lavender box.
|
|
15
|
+
2. **Transparent cells** — `metricsCellSurfaceClassName` is **`bg-transparent`** for `variant="flat"`. **Do not** use `bg-background`, `bg-card`, or `gap-px` + `bg-border` / `bg-foreground/*` on the grid (that paints tile surfaces).
|
|
16
|
+
3. **Hairlines = borders only** — Use **`flatMetricsHairlineClass(itemCount, metricsHalfWidthLayout)`** in `key-metrics.tsx`:
|
|
17
|
+
- **2 tiles:** `border-r` on the first cell only.
|
|
18
|
+
- **4 tiles, wide strip (default):** `border-r` on cells 1–3 (verticals between all columns); **no** horizontal rule.
|
|
19
|
+
- **4 tiles, narrow `@container` (< 30rem, 2×2 grid):** odd-column `border-r` + `border-b` on the top row only (via `@[max-width:29.99rem]` overrides).
|
|
20
|
+
4. **Divider color (OKLCH)** — `--key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent)`; apply on children with `[&>*]:border-[color:var(--key-metrics-flat-divider)]`. Dividers follow **active product** hue (`--sidebar-border`), not neutral grey alone.
|
|
21
|
+
5. **Glow (OKLCH)** — Radial stops use `color-mix(in oklch, var(--brand-color) …%, transparent)` so **Exxat One / Prism / Assessment / `theme-custom`** each tint correctly. **Do not** hardcode rose/indigo literals on theme blocks unless documenting a one-off.
|
|
22
|
+
6. **List page usage** — Prefer **`showHeader={false}`**, **`metricsSingleRow`** when four KPIs share one row; pass **`insight`** only when the insight rail is product-required (same row uses `lg:grid-cols-[3fr_2fr]`).
|
|
23
|
+
7. **Cap at four tiles** — See **`docs/kpi-strip-max-four-pattern.md`**.
|
|
24
|
+
|
|
25
|
+
## MUST NOT
|
|
26
|
+
|
|
27
|
+
- Add **`--key-metrics-flat-band-linear`** back into `flatBandStyle` or hub inline styles (e.g. question-bank hub hero).
|
|
28
|
+
- Use **`variant="card"`** on **`ListPageTemplate`** metrics when the design calls for a **flat strip** on the page background.
|
|
29
|
+
- Duplicate KPI numbers in ad-hoc **`Card`** grids on the same hub.
|
|
30
|
+
- Set **`variant="mutedSuffix"`** on product wordmarks to grey out the **suffix** in dark mode — suffix stays **Exxat pink** (`wordmarkColor`); see **`lib/product-brand.ts`**.
|
|
31
|
+
|
|
32
|
+
## Tokens (`app/globals.css`)
|
|
33
|
+
|
|
34
|
+
| Token | Role |
|
|
35
|
+
|--------|------|
|
|
36
|
+
| `--key-metrics-flat-band-radial` | Bottom brand glow (only layer on flat `<section>`) |
|
|
37
|
+
| `--key-metrics-flat-band-shadow` | **`none`** for flat band (no faux surface lift) |
|
|
38
|
+
| `--key-metrics-flat-cell-bg` | **`transparent`** |
|
|
39
|
+
| `--key-metrics-flat-divider` | OKLCH hairline between cells |
|
|
40
|
+
|
|
41
|
+
Dark mode (`.dark`): same rules — transparent cells, radial glow only, no linear fill to `--background`.
|
|
42
|
+
|
|
43
|
+
## Reference implementations
|
|
44
|
+
|
|
45
|
+
- `components/question-bank-client.tsx` — `KeyMetrics variant="flat" metricsSingleRow`
|
|
46
|
+
- `components/dashboard-tabs.tsx` — mix view flat band + insight
|
|
47
|
+
- `components/placements-client.tsx`, `team-client.tsx`, `compliance-client.tsx` — list hub metrics slot
|
|
48
|
+
|
|
49
|
+
## Insight rail (flat + side-by-side)
|
|
50
|
+
|
|
51
|
+
When **`insight`** is shown beside KPIs, the insight **`Card`** may keep its own surface; the **KPI grid** stays transparent. **Do not** add `lg:border-l` on the insight column for flat band — the insight card ring is the separator (`key-metrics.tsx`).
|
|
52
|
+
|
|
53
|
+
## See also
|
|
54
|
+
|
|
55
|
+
- **`docs/kpi-strip-max-four-pattern.md`**
|
|
56
|
+
- **`docs/kpi-trend-pattern.md`**
|
|
57
|
+
- **`docs/shell-surface-elevation-pattern.md`** — sidebar / secondary panel / page stack
|
|
@@ -26,4 +26,5 @@ On **primary list hubs** (`ListPageTemplate` metrics slot) and on **dashboard
|
|
|
26
26
|
## See also
|
|
27
27
|
|
|
28
28
|
- **`docs/kpi-trend-pattern.md`** — deltas, arrows, **`trendPolarity`**.
|
|
29
|
+
- **`docs/kpi-flat-band-pattern.md`** — **`variant="flat"`** presentation (orthogonal to tile count).
|
|
29
30
|
- **`.cursor/rules/exxat-kpi-max-four.mdc`**, **`.cursor/skills/exxat-kpi-max-four/SKILL.md`**
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Shell surface elevation (sidebar · secondary panel · page)
|
|
2
|
+
|
|
3
|
+
> **Tokens:** `app/globals.css` — `--sidebar`, `--secondary-panel-bg`, `--background`, `--brand-tint*`.
|
|
4
|
+
> **Shell:** `components/templates/nested-secondary-panel-shell.tsx` — `bg-[var(--secondary-panel-bg)]`.
|
|
5
|
+
> **Cursor:** `.cursor/rules/exxat-primary-nav-secondary-panel.mdc` · `.cursor/skills/exxat-primary-nav-secondary-panel/SKILL.md`
|
|
6
|
+
|
|
7
|
+
## Stack (back → front)
|
|
8
|
+
|
|
9
|
+
| Level | Surface | Token / class | Notes |
|
|
10
|
+
|-------|---------|---------------|--------|
|
|
11
|
+
| **0** | Primary icon rail + app chrome | `--sidebar` (= `--brand-tint` on light product themes) | Darkest brand wash in the shell |
|
|
12
|
+
| **1** | Nested secondary panel (Library, etc.) | `--secondary-panel-bg` | **Lighter** than level 0; **same product hue** |
|
|
13
|
+
| **2** | Main page / inset content | `--background` | Lightest (white canvas light; dark charcoal dark) |
|
|
14
|
+
|
|
15
|
+
**MUST** derive secondary panel fill from **`--brand-tint` / `--brand-tint-light`**, not a fixed rose or neutral grey. When the user selects **Exxat One**, both levels use **indigo hue ~286**; **Prism** uses **rose ~342**; **`theme-custom`** follows `--custom-product-brand-color` via `ProductProvider`.
|
|
16
|
+
|
|
17
|
+
## OKLCH formulas (light)
|
|
18
|
+
|
|
19
|
+
```css
|
|
20
|
+
--sidebar: var(--brand-tint);
|
|
21
|
+
--secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## OKLCH formulas (dark)
|
|
25
|
+
|
|
26
|
+
```css
|
|
27
|
+
--secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Per-product **dark** theme blocks (`.theme-one.dark`, `.theme-prism.dark`, …) set **`--brand-tint-light`** where needed so mixes stay on-hue.
|
|
31
|
+
|
|
32
|
+
## Implementation
|
|
33
|
+
|
|
34
|
+
- **`NestedSecondaryPanelShell`** — `bg-[var(--secondary-panel-bg)]`, `ring-sidebar-border` (not generic `ring-border` alone).
|
|
35
|
+
- **Do not** set secondary panel to `bg-sidebar` (same as level 0 — loses elevation).
|
|
36
|
+
- **Do not** use `color-mix(… var(--sidebar) …)` without brand tokens if it drifts from active product theme.
|
|
37
|
+
|
|
38
|
+
## Product theme classes
|
|
39
|
+
|
|
40
|
+
- **`theme-one`** / **`theme-prism`** / **`theme-assessment`** — built-in OKLCH brand scales in `globals.css`.
|
|
41
|
+
- **`theme-custom`** — when user picks an accent in Settings; driven by `--custom-product-brand-color`.
|
|
42
|
+
- **`ProductProvider`** — applies `theme-one` vs `theme-prism` vs `theme-custom`; accent override only when it **differs** from the product default (see `accentOverrideActive` in `contexts/product-context.tsx`).
|
|
43
|
+
|
|
44
|
+
## Logo vs chrome
|
|
45
|
+
|
|
46
|
+
- **Chrome** (sidebar, secondary panel, KPI glow) follows **`--brand-tint` / `--brand-color`** per product.
|
|
47
|
+
- **Logo art** (mark + suffix) stays **Exxat pink** via `wordmarkColor` / `markGradient` in `lib/product-brand.ts` — recolouring a product in Settings changes **theme accent**, not corporate logo pink.
|
|
48
|
+
|
|
49
|
+
## See also
|
|
50
|
+
|
|
51
|
+
- **`docs/kpi-flat-band-pattern.md`** — flat KPI strip uses brand glow only, no surface
|
|
52
|
+
- **`apps/web/AGENTS.md` §4.6** — secondary panel wiring
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Detect stale Turbopack / webpack chunk failures after dev-server rebuilds. */
|
|
2
|
+
export function isChunkLoadError(error: unknown): boolean {
|
|
3
|
+
if (!error || typeof error !== "object") return false
|
|
4
|
+
const err = error as { name?: string; message?: string }
|
|
5
|
+
const name = err.name ?? ""
|
|
6
|
+
const msg = err.message ?? ""
|
|
7
|
+
return (
|
|
8
|
+
name === "ChunkLoadError" ||
|
|
9
|
+
msg.includes("Failed to load chunk") ||
|
|
10
|
+
msg.includes("Loading chunk") ||
|
|
11
|
+
msg.includes("ChunkLoadError")
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -1,32 +1,97 @@
|
|
|
1
|
-
import type { ConditionalRule } from "@/components/table-properties/types"
|
|
1
|
+
import type { ConditionalRule, FilterTextMask } from "@/components/table-properties/types"
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
export type ConditionalColumnHint = {
|
|
4
|
+
key: string
|
|
5
|
+
sortKey?: string
|
|
6
|
+
filter?: { type?: string; textMask?: FilterTextMask }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function rowValueForRule<T extends Record<string, unknown>>(
|
|
10
|
+
row: T,
|
|
11
|
+
rule: ConditionalRule,
|
|
12
|
+
columns?: ConditionalColumnHint[],
|
|
13
|
+
): string {
|
|
14
|
+
const col = columns?.find(c => c.key === rule.fieldKey)
|
|
15
|
+
const dataKey = (col?.sortKey ?? rule.fieldKey) as keyof T
|
|
16
|
+
return String(row[dataKey] ?? "")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ruleHasActiveValues(
|
|
20
|
+
rule: ConditionalRule,
|
|
21
|
+
columns?: ConditionalColumnHint[],
|
|
22
|
+
): boolean {
|
|
23
|
+
if (rule.values.length === 0) return false
|
|
24
|
+
const col = columns?.find(c => c.key === rule.fieldKey)
|
|
25
|
+
if (col?.filter?.type === "text") return (rule.values[0] ?? "").trim().length > 0
|
|
26
|
+
return true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function conditionalTextMatches(
|
|
30
|
+
cellVal: string,
|
|
31
|
+
needle: string,
|
|
32
|
+
op: "contains" | "not_contains",
|
|
33
|
+
textMask: FilterTextMask | undefined,
|
|
34
|
+
) {
|
|
35
|
+
const v = cellVal.trim()
|
|
36
|
+
const n = needle.trim()
|
|
37
|
+
if (!n) return op === "not_contains"
|
|
38
|
+
if (textMask === "phone" || textMask === "zip") {
|
|
39
|
+
const nd = n.replace(/\D/g, "")
|
|
40
|
+
const hay = v.replace(/\D/g, "")
|
|
41
|
+
if (!nd) return op === "not_contains"
|
|
42
|
+
const hit = hay.includes(nd)
|
|
43
|
+
return op === "contains" ? hit : !hit
|
|
44
|
+
}
|
|
45
|
+
const hit = v.toLowerCase().includes(n.toLowerCase())
|
|
46
|
+
return op === "contains" ? hit : !hit
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a conditional rule matches a row (same logic as DataTable cells). */
|
|
50
|
+
export function conditionalRuleMatchesRow<T extends Record<string, unknown>>(
|
|
51
|
+
row: T,
|
|
52
|
+
rule: ConditionalRule,
|
|
53
|
+
columns?: ConditionalColumnHint[],
|
|
54
|
+
): boolean {
|
|
55
|
+
if (!ruleHasActiveValues(rule, columns)) return false
|
|
56
|
+
const v = rowValueForRule(row, rule, columns).trim()
|
|
57
|
+
const col = columns?.find(c => c.key === rule.fieldKey)
|
|
58
|
+
const textMask = col?.filter?.type === "text" ? col.filter.textMask : undefined
|
|
59
|
+
switch (rule.operator) {
|
|
60
|
+
case "is":
|
|
61
|
+
return rule.values.includes(v)
|
|
62
|
+
case "is_not":
|
|
63
|
+
return !rule.values.includes(v)
|
|
64
|
+
case "contains":
|
|
65
|
+
return rule.values.some(val => conditionalTextMatches(v, val, "contains", textMask))
|
|
66
|
+
case "not_contains":
|
|
67
|
+
return !rule.values.some(val => conditionalTextMatches(v, val, "contains", textMask))
|
|
68
|
+
default:
|
|
69
|
+
return false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** First matching conditional rule background for a row (list/board row tint). */
|
|
4
74
|
export function getConditionalRowBackground<T extends Record<string, unknown>>(
|
|
5
75
|
row: T,
|
|
6
76
|
rules: ConditionalRule[] | undefined,
|
|
77
|
+
columns?: ConditionalColumnHint[],
|
|
7
78
|
): string | undefined {
|
|
8
79
|
if (!rules?.length) return undefined
|
|
9
80
|
for (const rule of rules) {
|
|
10
|
-
|
|
11
|
-
const v = cellVal.trim()
|
|
12
|
-
switch (rule.operator) {
|
|
13
|
-
case "is":
|
|
14
|
-
if (rule.values.length > 0 && rule.values.includes(v)) return rule.bgColor
|
|
15
|
-
break
|
|
16
|
-
case "is_not":
|
|
17
|
-
if (rule.values.length > 0 && !rule.values.includes(v)) return rule.bgColor
|
|
18
|
-
break
|
|
19
|
-
case "contains":
|
|
20
|
-
if (rule.values.length > 0 && rule.values.some(val => v.toLowerCase().includes(val.toLowerCase())))
|
|
21
|
-
return rule.bgColor
|
|
22
|
-
break
|
|
23
|
-
case "not_contains":
|
|
24
|
-
if (rule.values.length > 0 && !rule.values.some(val => v.toLowerCase().includes(val.toLowerCase())))
|
|
25
|
-
return rule.bgColor
|
|
26
|
-
break
|
|
27
|
-
default:
|
|
28
|
-
break
|
|
29
|
-
}
|
|
81
|
+
if (conditionalRuleMatchesRow(row, rule, columns)) return rule.bgColor
|
|
30
82
|
}
|
|
31
83
|
return undefined
|
|
32
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Background for one table cell from conditional rules on that column. */
|
|
87
|
+
export function getConditionalCellBackground<T extends Record<string, unknown>>(
|
|
88
|
+
row: T,
|
|
89
|
+
colKey: string,
|
|
90
|
+
rules: ConditionalRule[] | undefined,
|
|
91
|
+
columns?: ConditionalColumnHint[],
|
|
92
|
+
): string | undefined {
|
|
93
|
+
if (!rules?.length) return undefined
|
|
94
|
+
const rule = rules.find(r => r.fieldKey === colKey)
|
|
95
|
+
if (!rule || !conditionalRuleMatchesRow(row, rule, columns)) return undefined
|
|
96
|
+
return rule.bgColor
|
|
97
|
+
}
|
|
@@ -30,3 +30,9 @@ export function dataListViewLabel(view: DataListViewType): string {
|
|
|
30
30
|
export function dataListViewIcon(view: DataListViewType): string {
|
|
31
31
|
return DATA_LIST_VIEW_TILES.find(t => t.value === view)?.icon ?? "fa-table"
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
/** Add-view menu hint + `<Shortcut>` keys (1–9). Skipped in inputs via `useShortcut`. */
|
|
35
|
+
export function dataListViewAddShortcut(index: number): string | undefined {
|
|
36
|
+
if (index < 0 || index > 8) return undefined
|
|
37
|
+
return String(index + 1)
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Cookie name persisted by `@exxatdesignux/ui` `SidebarProvider` (`setOpen`). */
|
|
2
|
+
export const SIDEBAR_STATE_COOKIE_NAME = "sidebar_state"
|
|
3
|
+
|
|
4
|
+
/** Read desktop sidebar expanded state for SSR `defaultOpen` (matches client cookie restore). */
|
|
5
|
+
export function sidebarDefaultOpenFromCookie(
|
|
6
|
+
value: string | undefined,
|
|
7
|
+
): boolean {
|
|
8
|
+
return value !== "false"
|
|
9
|
+
}
|
|
@@ -229,7 +229,19 @@ function filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<s
|
|
|
229
229
|
return out
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
function sanitizeActiveFilters(
|
|
233
|
+
filters: ActiveFilter[],
|
|
234
|
+
columnKeys: Set<string>,
|
|
235
|
+
): ActiveFilter[] {
|
|
236
|
+
return filters.filter(f => columnKeys.has(f.fieldKey))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function sanitizeSortRules(rules: SortRule[], columnKeys: Set<string>): SortRule[] {
|
|
240
|
+
return rules.filter(r => columnKeys.has(r.fieldKey))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Column layout only — keeps in-memory search / filters when the column set changes. */
|
|
244
|
+
export function applyLifecycleColumnLayout(
|
|
233
245
|
ts: TableStatePersistSlice,
|
|
234
246
|
p: PersistedLifecycleV1,
|
|
235
247
|
columnKeys: Set<string>,
|
|
@@ -241,11 +253,6 @@ export function applyLifecyclePersisted(
|
|
|
241
253
|
const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
|
|
242
254
|
const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
|
|
243
255
|
|
|
244
|
-
ts.setSortRules(p.sortRules)
|
|
245
|
-
ts.setSearch(p.search)
|
|
246
|
-
ts.setActiveFilters(p.activeFilters)
|
|
247
|
-
ts.setFilterConnectors(p.filterConnectors)
|
|
248
|
-
ts.setGroupBy(p.groupBy)
|
|
249
256
|
ts.setColOrder(colOrder)
|
|
250
257
|
ts.setHiddenCols(hidden)
|
|
251
258
|
ts.setColWidths(colWidths)
|
|
@@ -254,6 +261,20 @@ export function applyLifecyclePersisted(
|
|
|
254
261
|
ts.setColMenuSearch(colMenuSearch)
|
|
255
262
|
ts.setRowHeight(p.rowHeight)
|
|
256
263
|
ts.setShowGridlines(p.showGridlines)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyLifecyclePersisted(
|
|
267
|
+
ts: TableStatePersistSlice,
|
|
268
|
+
p: PersistedLifecycleV1,
|
|
269
|
+
columnKeys: Set<string>,
|
|
270
|
+
): void {
|
|
271
|
+
applyLifecycleColumnLayout(ts, p, columnKeys)
|
|
272
|
+
|
|
273
|
+
ts.setSortRules(sanitizeSortRules(p.sortRules, columnKeys))
|
|
274
|
+
ts.setSearch(p.search)
|
|
275
|
+
ts.setActiveFilters(sanitizeActiveFilters(p.activeFilters, columnKeys))
|
|
276
|
+
ts.setFilterConnectors(p.filterConnectors)
|
|
277
|
+
ts.setGroupBy(p.groupBy != null && columnKeys.has(p.groupBy) ? p.groupBy : null)
|
|
257
278
|
ts.setFilterBarVisible(p.filterBarVisible)
|
|
258
279
|
ts.setSearchOpen(p.searchOpen)
|
|
259
280
|
}
|
|
@@ -427,20 +448,46 @@ export function useTableStateLifecycle<TExtras extends Record<string, unknown> |
|
|
|
427
448
|
onLoadExtrasRef.current = onLoadExtras
|
|
428
449
|
})
|
|
429
450
|
|
|
451
|
+
const columnKeysFingerprint = React.useMemo(
|
|
452
|
+
() => [...columnKeys].sort().join("\0"),
|
|
453
|
+
[columnKeys],
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
const loadedScopeRef = React.useRef<string | null>(null)
|
|
457
|
+
const appliedColumnFingerprintRef = React.useRef<string | null>(null)
|
|
458
|
+
|
|
430
459
|
// ── Load ────────────────────────────────────────────────────────────────
|
|
431
460
|
// useLayoutEffect so the rehydrated state paints in the first frame after
|
|
432
461
|
// mount instead of flashing the unhydrated defaults first.
|
|
433
462
|
React.useLayoutEffect(() => {
|
|
463
|
+
// Wait until column defs exist — applying persisted sort/filters against an
|
|
464
|
+
// empty key set would sanitize everything away and look like Properties broke.
|
|
465
|
+
if (columnKeys.size === 0) return
|
|
466
|
+
|
|
467
|
+
const scope = `${namespace}:${tabId}`
|
|
434
468
|
const raw = loadLifecycleFromStorage(namespace, tabId)
|
|
469
|
+
|
|
470
|
+
if (loadedScopeRef.current !== scope) {
|
|
471
|
+
loadedScopeRef.current = scope
|
|
472
|
+
appliedColumnFingerprintRef.current = columnKeysFingerprint
|
|
473
|
+
if (!raw) return
|
|
474
|
+
applyLifecyclePersisted(tableState, raw, columnKeys)
|
|
475
|
+
const e = readLifecycleExtras<Record<string, unknown>>(raw)
|
|
476
|
+
onLoadExtrasRef.current?.(e as TExtras | Record<string, unknown> | undefined)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (appliedColumnFingerprintRef.current === columnKeysFingerprint) return
|
|
481
|
+
appliedColumnFingerprintRef.current = columnKeysFingerprint
|
|
435
482
|
if (!raw) return
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
483
|
+
// Column defs changed (e.g. hub scope / dynamic filter options) — re-merge
|
|
484
|
+
// layout only; do not wipe in-memory filters the user set in Properties.
|
|
485
|
+
applyLifecycleColumnLayout(tableState, raw, columnKeys)
|
|
439
486
|
// `tableState` is freshly returned each render; depending on it would
|
|
440
487
|
// re-apply persisted state on every keystroke and undo edits. Depend only
|
|
441
|
-
// on the load scope (namespace + tabId + column
|
|
488
|
+
// on the load scope (namespace + tabId + column fingerprint).
|
|
442
489
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
443
|
-
}, [namespace, tabId,
|
|
490
|
+
}, [namespace, tabId, columnKeysFingerprint])
|
|
444
491
|
|
|
445
492
|
// ── Save ────────────────────────────────────────────────────────────────
|
|
446
493
|
// Serialise + debounce on every persisted slice change. Don't depend on
|