@exxatdesignux/ui 0.2.16 → 0.2.17
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 +11 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +18 -15
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +108 -1
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +68 -34
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +172 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +74 -46
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -8
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -10,29 +10,73 @@
|
|
|
10
10
|
|
|
11
11
|
import * as React from "react"
|
|
12
12
|
import { useAppStore, type Product } from "@/stores/app-store"
|
|
13
|
+
import { brandForProduct } from "@/lib/product-brand"
|
|
13
14
|
|
|
14
15
|
export type { Product }
|
|
15
16
|
|
|
16
17
|
export function useProduct() {
|
|
17
|
-
const product
|
|
18
|
-
const setProduct
|
|
19
|
-
|
|
18
|
+
const product = useAppStore(s => s.product)
|
|
19
|
+
const setProduct = useAppStore(s => s.setProduct)
|
|
20
|
+
const customProductBrand = useAppStore(s => s.customProductBrand)
|
|
21
|
+
const setCustomProductBrand = useAppStore(s => s.setCustomProductBrand)
|
|
22
|
+
const productBrandColors = useAppStore(s => s.productBrandColors)
|
|
23
|
+
const setProductBrandColor = useAppStore(s => s.setProductBrandColor)
|
|
24
|
+
const hiddenProductIds = useAppStore(s => s.hiddenProductIds)
|
|
25
|
+
const hideProduct = useAppStore(s => s.hideProduct)
|
|
26
|
+
const showProduct = useAppStore(s => s.showProduct)
|
|
27
|
+
return {
|
|
28
|
+
product,
|
|
29
|
+
setProduct,
|
|
30
|
+
customProductBrand,
|
|
31
|
+
setCustomProductBrand,
|
|
32
|
+
productBrandColors,
|
|
33
|
+
setProductBrandColor,
|
|
34
|
+
hiddenProductIds,
|
|
35
|
+
hideProduct,
|
|
36
|
+
showProduct,
|
|
37
|
+
}
|
|
20
38
|
}
|
|
21
39
|
|
|
22
40
|
export function ProductProvider({ children }: { children: React.ReactNode }) {
|
|
23
41
|
const product = useAppStore(s => s.product)
|
|
42
|
+
const customProductBrand = useAppStore(s => s.customProductBrand)
|
|
43
|
+
const productBrandColors = useAppStore(s => s.productBrandColors)
|
|
24
44
|
|
|
25
45
|
// Rehydrate from localStorage once — keeps SSR render matching server output.
|
|
26
46
|
React.useEffect(() => {
|
|
27
47
|
void useAppStore.persist.rehydrate()
|
|
28
48
|
}, [])
|
|
29
49
|
|
|
30
|
-
// Sync theme class to <html> whenever product changes.
|
|
50
|
+
// Sync theme class to <html> whenever product (or its accent override) changes.
|
|
31
51
|
React.useEffect(() => {
|
|
32
52
|
const html = document.documentElement
|
|
33
|
-
html.classList.remove("theme-one", "theme-prism")
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
html.classList.remove("theme-one", "theme-prism", "theme-assessment", "theme-custom")
|
|
54
|
+
// Effective brand colour for the active product — picks up any
|
|
55
|
+
// per-product override the user set in Settings → Appearance. Drives
|
|
56
|
+
// `--custom-product-brand-color` so `theme-custom` chrome retints.
|
|
57
|
+
const effectiveBrandColor = brandForProduct(product, customProductBrand, productBrandColors).brandColor
|
|
58
|
+
html.style.setProperty("--custom-product-brand-color", effectiveBrandColor)
|
|
59
|
+
// If the user has set a brand-colour override for the active product,
|
|
60
|
+
// flip to `theme-custom` so the chrome retints from
|
|
61
|
+
// `--custom-product-brand-color`. The hardcoded `theme-one / theme-prism
|
|
62
|
+
// / theme-assessment` classes (with bespoke hue formulas in
|
|
63
|
+
// `globals.css`) are still used for the **default** look of each
|
|
64
|
+
// built-in.
|
|
65
|
+
const hasAccentOverride = Boolean(productBrandColors[product])
|
|
66
|
+
let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
|
|
67
|
+
if (hasAccentOverride) {
|
|
68
|
+
themeClass = "theme-custom"
|
|
69
|
+
} else if (product === "exxat-one") {
|
|
70
|
+
themeClass = "theme-one"
|
|
71
|
+
} else if (product === "exxat-prism") {
|
|
72
|
+
themeClass = "theme-prism"
|
|
73
|
+
} else if (product === "exxat-assessment" || (product === "exxat-custom" && !customProductBrand)) {
|
|
74
|
+
themeClass = "theme-assessment"
|
|
75
|
+
} else {
|
|
76
|
+
themeClass = "theme-custom"
|
|
77
|
+
}
|
|
78
|
+
html.classList.add(themeClass)
|
|
79
|
+
}, [customProductBrand, product, productBrandColors])
|
|
36
80
|
|
|
37
81
|
return <>{children}</>
|
|
38
82
|
}
|
|
@@ -43,6 +43,111 @@ export const DEFAULT_SYSTEM_BANNER_CONFIG: SystemBannerConfig = {
|
|
|
43
43
|
|
|
44
44
|
const STORAGE_KEY = "exxat:system-banner-config"
|
|
45
45
|
|
|
46
|
+
const ALLOWED_VARIANTS: ReadonlySet<SystemBannerVariant> = new Set([
|
|
47
|
+
"info",
|
|
48
|
+
"warning",
|
|
49
|
+
"error",
|
|
50
|
+
"success",
|
|
51
|
+
"promo",
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const ALLOWED_EMPHASIS: ReadonlySet<SystemBannerEmphasis> = new Set([
|
|
55
|
+
"prominent",
|
|
56
|
+
"subtle",
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip any `actionHref` whose URL scheme could execute script when the
|
|
61
|
+
* banner CTA is clicked (`javascript:`, `data:`, `vbscript:`, etc.).
|
|
62
|
+
*
|
|
63
|
+
* The banner UI renders `actionHref` as a plain `<a href>`, so a malicious
|
|
64
|
+
* value in `localStorage` — written by an extension, a victim of a
|
|
65
|
+
* same-origin bug elsewhere, or a future feature that accepts user input —
|
|
66
|
+
* would become a one-click XSS or open-redirect vector on every tab that
|
|
67
|
+
* receives the storage event. We accept only:
|
|
68
|
+
*
|
|
69
|
+
* - Absolute http(s) URLs.
|
|
70
|
+
* - Absolute mailto: / tel: URIs (banner CTAs sometimes deep-link these).
|
|
71
|
+
* - Same-origin relative paths (`/foo`, `./bar`, `../baz`).
|
|
72
|
+
* - The single legacy placeholder `"#"` shipped in the default config.
|
|
73
|
+
*
|
|
74
|
+
* Anything else collapses to `undefined`, which the banner treats as
|
|
75
|
+
* "no CTA link".
|
|
76
|
+
*/
|
|
77
|
+
function sanitizeActionHref(href: unknown): string | undefined {
|
|
78
|
+
if (typeof href !== "string") return undefined
|
|
79
|
+
const trimmed = href.trim()
|
|
80
|
+
if (!trimmed) return undefined
|
|
81
|
+
if (trimmed === "#") return trimmed
|
|
82
|
+
|
|
83
|
+
// Same-origin relative paths.
|
|
84
|
+
if (
|
|
85
|
+
trimmed.startsWith("/") ||
|
|
86
|
+
trimmed.startsWith("./") ||
|
|
87
|
+
trimmed.startsWith("../")
|
|
88
|
+
) {
|
|
89
|
+
return trimmed
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Absolute URLs — only allow http(s) / mailto: / tel:.
|
|
93
|
+
try {
|
|
94
|
+
// Use a dummy base so `new URL` accepts both absolute and protocol-relative inputs.
|
|
95
|
+
const url = new URL(trimmed, "https://exxat.invalid")
|
|
96
|
+
if (
|
|
97
|
+
url.protocol === "http:" ||
|
|
98
|
+
url.protocol === "https:" ||
|
|
99
|
+
url.protocol === "mailto:" ||
|
|
100
|
+
url.protocol === "tel:"
|
|
101
|
+
) {
|
|
102
|
+
return trimmed
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* fallthrough to reject */
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Coerce an unknown JSON payload (from `localStorage` or a cross-tab
|
|
113
|
+
* `storage` event) into a `SystemBannerConfig`. Unknown fields are dropped,
|
|
114
|
+
* known fields are type-narrowed, and any string field is capped so a
|
|
115
|
+
* malformed/oversized payload cannot stall the renderer.
|
|
116
|
+
*
|
|
117
|
+
* Returns `null` when the payload cannot be coerced — callers fall back to
|
|
118
|
+
* the shipped default rather than render attacker-controlled content.
|
|
119
|
+
*/
|
|
120
|
+
function coerceConfig(raw: unknown): SystemBannerConfig | null {
|
|
121
|
+
if (!raw || typeof raw !== "object") return null
|
|
122
|
+
const r = raw as Record<string, unknown>
|
|
123
|
+
const str = (v: unknown, max = 280): string | undefined =>
|
|
124
|
+
typeof v === "string" ? v.slice(0, max) : undefined
|
|
125
|
+
|
|
126
|
+
const variant = ALLOWED_VARIANTS.has(r.variant as SystemBannerVariant)
|
|
127
|
+
? (r.variant as SystemBannerVariant)
|
|
128
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.variant
|
|
129
|
+
const emphasis = ALLOWED_EMPHASIS.has(r.emphasis as SystemBannerEmphasis)
|
|
130
|
+
? (r.emphasis as SystemBannerEmphasis)
|
|
131
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.emphasis
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
enabled:
|
|
135
|
+
typeof r.enabled === "boolean"
|
|
136
|
+
? r.enabled
|
|
137
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.enabled,
|
|
138
|
+
variant,
|
|
139
|
+
emphasis,
|
|
140
|
+
title: str(r.title, 120) ?? DEFAULT_SYSTEM_BANNER_CONFIG.title,
|
|
141
|
+
message: str(r.message, 280) ?? DEFAULT_SYSTEM_BANNER_CONFIG.message,
|
|
142
|
+
actionLabel: str(r.actionLabel, 60),
|
|
143
|
+
actionHref: sanitizeActionHref(r.actionHref),
|
|
144
|
+
dismissible:
|
|
145
|
+
typeof r.dismissible === "boolean"
|
|
146
|
+
? r.dismissible
|
|
147
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.dismissible,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
46
151
|
interface SystemBannerContextValue {
|
|
47
152
|
config: SystemBannerConfig
|
|
48
153
|
updateConfig: (patch: Partial<SystemBannerConfig>) => void
|
|
@@ -66,9 +171,8 @@ function readStored(): SystemBannerConfig {
|
|
|
66
171
|
try {
|
|
67
172
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
68
173
|
if (!raw) return DEFAULT_SYSTEM_BANNER_CONFIG
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
return { ...DEFAULT_SYSTEM_BANNER_CONFIG, ...parsed }
|
|
174
|
+
const coerced = coerceConfig(JSON.parse(raw))
|
|
175
|
+
return coerced ?? DEFAULT_SYSTEM_BANNER_CONFIG
|
|
72
176
|
} catch {
|
|
73
177
|
return DEFAULT_SYSTEM_BANNER_CONFIG
|
|
74
178
|
}
|
|
@@ -95,11 +199,15 @@ export function SystemBannerProvider({ children }: { children: React.ReactNode }
|
|
|
95
199
|
}, [config, hydrated])
|
|
96
200
|
|
|
97
201
|
// Cross-tab sync — if you change the banner in one tab, others follow.
|
|
202
|
+
// The payload is treated as untrusted (an extension or future bug could
|
|
203
|
+
// write into the same key) so we route it through `coerceConfig` to drop
|
|
204
|
+
// unknown fields and `sanitizeActionHref` to refuse `javascript:` URLs.
|
|
98
205
|
React.useEffect(() => {
|
|
99
206
|
function onStorage(e: StorageEvent) {
|
|
100
207
|
if (e.key !== STORAGE_KEY || !e.newValue) return
|
|
101
208
|
try {
|
|
102
|
-
|
|
209
|
+
const coerced = coerceConfig(JSON.parse(e.newValue))
|
|
210
|
+
if (coerced) setConfig(coerced)
|
|
103
211
|
} catch {
|
|
104
212
|
/* ignore malformed payloads */
|
|
105
213
|
}
|
|
@@ -13,6 +13,24 @@ const eslintConfig = defineConfig([
|
|
|
13
13
|
"build/**",
|
|
14
14
|
"next-env.d.ts",
|
|
15
15
|
]),
|
|
16
|
+
{
|
|
17
|
+
rules: {
|
|
18
|
+
// Allow intentionally-unused args / vars / destructured props /
|
|
19
|
+
// generics when prefixed with `_`. This is the standard escape hatch
|
|
20
|
+
// for "I'm satisfying a callback signature but don't need this slot"
|
|
21
|
+
// — common in cell renderers (`(value, _row) => …`), destructured
|
|
22
|
+
// tuples (`const [_, setX] = useState()`), and generic constraints.
|
|
23
|
+
"@typescript-eslint/no-unused-vars": [
|
|
24
|
+
"warn",
|
|
25
|
+
{
|
|
26
|
+
argsIgnorePattern: "^_",
|
|
27
|
+
varsIgnorePattern: "^_",
|
|
28
|
+
caughtErrorsIgnorePattern: "^_",
|
|
29
|
+
destructuredArrayIgnorePattern: "^_",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
16
34
|
]);
|
|
17
35
|
|
|
18
36
|
export default eslintConfig;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { rafThrottle } from "@/lib/raf-throttle"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* When true, the sidebar should **not** pin utilities + profile to the bottom — the whole
|
|
@@ -14,25 +15,34 @@ export function useSidebarReflowZoom(): boolean {
|
|
|
14
15
|
|
|
15
16
|
React.useEffect(() => {
|
|
16
17
|
const vv = window.visualViewport
|
|
18
|
+
// Cache the MediaQueryList — calling `matchMedia` on every compute() is
|
|
19
|
+
// measurable on pinch/zoom where visualViewport scroll fires per frame.
|
|
20
|
+
const mql = window.matchMedia("(max-height: 640px)")
|
|
17
21
|
|
|
18
22
|
function compute() {
|
|
19
23
|
const scale = vv?.scale ?? 1
|
|
20
|
-
const short =
|
|
24
|
+
const short = mql.matches
|
|
21
25
|
const veryShort = window.innerHeight <= 420
|
|
22
|
-
|
|
26
|
+
const next = scale >= 1.99 || short || veryShort
|
|
27
|
+
// Avoid unnecessary React re-renders when nothing changed.
|
|
28
|
+
setReflow(prev => (prev === next ? prev : next))
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
compute()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
32
|
+
// rAF-coalesce: visualViewport.scroll can fire hundreds of times per second
|
|
33
|
+
// during pinch-zoom — without throttling we trigger setReflow + matchMedia
|
|
34
|
+
// per event. One sample per frame is enough for a layout breakpoint flag.
|
|
35
|
+
const scheduled = rafThrottle(compute)
|
|
36
|
+
vv?.addEventListener("resize", scheduled, { passive: true })
|
|
37
|
+
vv?.addEventListener("scroll", scheduled, { passive: true })
|
|
38
|
+
window.addEventListener("resize", scheduled, { passive: true })
|
|
39
|
+
mql.addEventListener("change", scheduled)
|
|
31
40
|
return () => {
|
|
32
|
-
|
|
33
|
-
vv?.removeEventListener("
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
scheduled.cancel()
|
|
42
|
+
vv?.removeEventListener("resize", scheduled)
|
|
43
|
+
vv?.removeEventListener("scroll", scheduled)
|
|
44
|
+
window.removeEventListener("resize", scheduled)
|
|
45
|
+
mql.removeEventListener("change", scheduled)
|
|
36
46
|
}
|
|
37
47
|
}, [])
|
|
38
48
|
|
|
@@ -1,280 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* @deprecated Placements-specific shim around the generic
|
|
3
|
+
* `@/lib/table-state-lifecycle`. New code SHOULD import the generic helpers
|
|
4
|
+
* (and the `useTableStateLifecycle` hook) directly. This file preserves the
|
|
5
|
+
* old API + the old storage key prefix (`exxat-ds:data-list:*`) so existing
|
|
6
|
+
* placements `localStorage` payloads continue to work during the migration.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
import {
|
|
10
|
+
applyLifecyclePersisted as applyLifecyclePersistedGeneric,
|
|
11
|
+
loadLifecycleFromStorage as loadLifecycleFromStorageGeneric,
|
|
12
|
+
loadPageFromStorage as loadPageFromStorageGeneric,
|
|
13
|
+
lifecycleStorageKey as lifecycleStorageKeyGeneric,
|
|
14
|
+
pageStorageKey,
|
|
15
|
+
parsePersistedLifecycle,
|
|
16
|
+
parsePersistedPage,
|
|
17
|
+
scheduleLifecycleSave as scheduleLifecycleSaveGeneric,
|
|
18
|
+
schedulePageSave as schedulePageSaveGeneric,
|
|
19
|
+
serializeLifecycle as serializeLifecycleGeneric,
|
|
20
|
+
type PersistedLifecycleV1,
|
|
21
|
+
type PersistedPageV1,
|
|
22
|
+
type TableStatePersistSlice,
|
|
23
|
+
} from "@/lib/table-state-lifecycle"
|
|
24
|
+
import type { ConditionalRule } from "@/components/table-properties/types"
|
|
25
|
+
|
|
26
|
+
/** Legacy namespace — kept so existing placements payloads remain readable. */
|
|
27
|
+
const PLACEMENTS_NAMESPACE = "data-list"
|
|
28
|
+
|
|
29
|
+
export const DATA_LIST_PAGE_STORAGE_KEY = pageStorageKey(PLACEMENTS_NAMESPACE)
|
|
14
30
|
|
|
15
31
|
export function lifecycleStorageKey(lifecycleTabId: string): string {
|
|
16
|
-
return
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const LIFECYCLE_SAVE_DEBOUNCE_MS = 400
|
|
20
|
-
const PAGE_SAVE_DEBOUNCE_MS = 400
|
|
21
|
-
|
|
22
|
-
const lifecycleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
23
|
-
const pageTimer: { t?: ReturnType<typeof setTimeout> } = {}
|
|
24
|
-
|
|
25
|
-
export interface PersistedLifecycleV1 {
|
|
26
|
-
v: 1
|
|
27
|
-
sortRules: SortRule[]
|
|
28
|
-
search: string
|
|
29
|
-
activeFilters: ActiveFilter[]
|
|
30
|
-
filterConnectors: Record<string, "and" | "or">
|
|
31
|
-
groupBy: string | null
|
|
32
|
-
colOrder: string[]
|
|
33
|
-
hiddenCols: string[]
|
|
34
|
-
colWidths: Record<string, number>
|
|
35
|
-
colPins: Record<string, "left" | "right">
|
|
36
|
-
colWrap: Record<string, boolean>
|
|
37
|
-
colMenuSearch: Record<string, string>
|
|
38
|
-
rowHeight: RowHeight
|
|
39
|
-
showGridlines: boolean
|
|
40
|
-
filterBarVisible: boolean
|
|
41
|
-
searchOpen: boolean
|
|
42
|
-
conditionalRules: ConditionalRule[]
|
|
43
|
-
pagination: boolean
|
|
44
|
-
paginationPage: number
|
|
45
|
-
paginationPageSize: number
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface PersistedPageV1 {
|
|
49
|
-
v: 1
|
|
50
|
-
displayOptions: DataListDisplayOptions
|
|
51
|
-
showMetrics: boolean
|
|
52
|
-
tabs: ViewTab[]
|
|
53
|
-
activeTabId: string
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Narrow surface used to hydrate / snapshot table state without importing the hook implementation. */
|
|
57
|
-
export interface TableStatePersistSlice {
|
|
58
|
-
sortRules: SortRule[]
|
|
59
|
-
search: string
|
|
60
|
-
activeFilters: ActiveFilter[]
|
|
61
|
-
filterConnectors: Record<string, "and" | "or">
|
|
62
|
-
groupBy: string | null
|
|
63
|
-
colOrder: string[]
|
|
64
|
-
hiddenCols: Set<string>
|
|
65
|
-
colWidths: Record<string, number>
|
|
66
|
-
colPins: Record<string, "left" | "right">
|
|
67
|
-
colWrap: Record<string, boolean>
|
|
68
|
-
colMenuSearch: Record<string, string>
|
|
69
|
-
rowHeight: RowHeight
|
|
70
|
-
showGridlines: boolean
|
|
71
|
-
filterBarVisible: boolean
|
|
72
|
-
searchOpen: boolean
|
|
73
|
-
setSortRules: Dispatch<SetStateAction<SortRule[]>>
|
|
74
|
-
setSearch: Dispatch<SetStateAction<string>>
|
|
75
|
-
setActiveFilters: Dispatch<SetStateAction<ActiveFilter[]>>
|
|
76
|
-
setFilterConnectors: Dispatch<SetStateAction<Record<string, "and" | "or">>>
|
|
77
|
-
setGroupBy: Dispatch<SetStateAction<string | null>>
|
|
78
|
-
setColOrder: Dispatch<SetStateAction<string[]>>
|
|
79
|
-
setHiddenCols: Dispatch<SetStateAction<Set<string>>>
|
|
80
|
-
setColWidths: Dispatch<SetStateAction<Record<string, number>>>
|
|
81
|
-
setColPins: Dispatch<SetStateAction<Record<string, "left" | "right">>>
|
|
82
|
-
setColWrap: Dispatch<SetStateAction<Record<string, boolean>>>
|
|
83
|
-
setColMenuSearch: Dispatch<SetStateAction<Record<string, string>>>
|
|
84
|
-
setRowHeight: Dispatch<SetStateAction<RowHeight>>
|
|
85
|
-
setShowGridlines: Dispatch<SetStateAction<boolean>>
|
|
86
|
-
setFilterBarVisible: Dispatch<SetStateAction<boolean>>
|
|
87
|
-
setSearchOpen: Dispatch<SetStateAction<boolean>>
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const VIEW_TYPES: DataListViewType[] = ["table", "list", "board", "dashboard"]
|
|
91
|
-
|
|
92
|
-
function isViewType(v: unknown): v is DataListViewType {
|
|
93
|
-
return typeof v === "string" && (VIEW_TYPES as string[]).includes(v)
|
|
32
|
+
return lifecycleStorageKeyGeneric(PLACEMENTS_NAMESPACE, lifecycleTabId)
|
|
94
33
|
}
|
|
95
34
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
id: o.id,
|
|
104
|
-
label: o.label,
|
|
105
|
-
viewType: o.viewType,
|
|
106
|
-
icon: o.icon,
|
|
107
|
-
filterId: o.filterId,
|
|
108
|
-
}
|
|
35
|
+
export {
|
|
36
|
+
parsePersistedLifecycle,
|
|
37
|
+
parsePersistedPage,
|
|
38
|
+
applyLifecyclePersistedGeneric as applyLifecyclePersisted,
|
|
39
|
+
type PersistedLifecycleV1,
|
|
40
|
+
type PersistedPageV1,
|
|
41
|
+
type TableStatePersistSlice,
|
|
109
42
|
}
|
|
110
43
|
|
|
111
|
-
export function
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
const j = JSON.parse(raw) as unknown
|
|
115
|
-
if (!j || typeof j !== "object") return null
|
|
116
|
-
const o = j as Record<string, unknown>
|
|
117
|
-
if (o.v !== 1) return null
|
|
118
|
-
if (!o.displayOptions || typeof o.displayOptions !== "object") return null
|
|
119
|
-
if (typeof o.showMetrics !== "boolean") return null
|
|
120
|
-
if (!Array.isArray(o.tabs) || typeof o.activeTabId !== "string") return null
|
|
121
|
-
const tabs = o.tabs.map(parseViewTab).filter((t): t is ViewTab => t !== null)
|
|
122
|
-
if (tabs.length === 0) return null
|
|
123
|
-
return {
|
|
124
|
-
v: 1,
|
|
125
|
-
displayOptions: o.displayOptions as DataListDisplayOptions,
|
|
126
|
-
showMetrics: o.showMetrics,
|
|
127
|
-
tabs,
|
|
128
|
-
activeTabId: o.activeTabId,
|
|
129
|
-
}
|
|
130
|
-
} catch {
|
|
131
|
-
return null
|
|
132
|
-
}
|
|
44
|
+
export function loadLifecycleFromStorage(lifecycleTabId: string): PersistedLifecycleV1 | null {
|
|
45
|
+
return loadLifecycleFromStorageGeneric(PLACEMENTS_NAMESPACE, lifecycleTabId)
|
|
133
46
|
}
|
|
134
47
|
|
|
135
|
-
export function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const o = j as Record<string, unknown>
|
|
141
|
-
if (o.v !== 1) return null
|
|
142
|
-
if (!Array.isArray(o.sortRules)) return null
|
|
143
|
-
if (typeof o.search !== "string") return null
|
|
144
|
-
if (!Array.isArray(o.activeFilters)) return null
|
|
145
|
-
if (!o.filterConnectors || typeof o.filterConnectors !== "object") return null
|
|
146
|
-
if (o.groupBy !== null && typeof o.groupBy !== "string") return null
|
|
147
|
-
if (!Array.isArray(o.colOrder)) return null
|
|
148
|
-
if (!Array.isArray(o.hiddenCols)) return null
|
|
149
|
-
if (!o.colWidths || typeof o.colWidths !== "object") return null
|
|
150
|
-
if (!o.colPins || typeof o.colPins !== "object") return null
|
|
151
|
-
if (!o.colWrap || typeof o.colWrap !== "object") return null
|
|
152
|
-
if (!o.colMenuSearch || typeof o.colMenuSearch !== "object") return null
|
|
153
|
-
if (typeof o.rowHeight !== "string") return null
|
|
154
|
-
if (typeof o.showGridlines !== "boolean") return null
|
|
155
|
-
if (typeof o.filterBarVisible !== "boolean") return null
|
|
156
|
-
if (typeof o.searchOpen !== "boolean") return null
|
|
157
|
-
if (!Array.isArray(o.conditionalRules)) return null
|
|
158
|
-
if (typeof o.pagination !== "boolean") return null
|
|
159
|
-
if (typeof o.paginationPage !== "number" || typeof o.paginationPageSize !== "number") return null
|
|
160
|
-
return o as unknown as PersistedLifecycleV1
|
|
161
|
-
} catch {
|
|
162
|
-
return null
|
|
163
|
-
}
|
|
48
|
+
export function scheduleLifecycleSave(
|
|
49
|
+
lifecycleTabId: string,
|
|
50
|
+
payload: PersistedLifecycleV1,
|
|
51
|
+
): void {
|
|
52
|
+
scheduleLifecycleSaveGeneric(PLACEMENTS_NAMESPACE, lifecycleTabId, payload)
|
|
164
53
|
}
|
|
165
54
|
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
for (const k of columnKeys) {
|
|
169
|
-
if (!ordered.includes(k)) ordered.push(k)
|
|
170
|
-
}
|
|
171
|
-
return ordered
|
|
55
|
+
export function loadPageFromStorage(): PersistedPageV1 | null {
|
|
56
|
+
return loadPageFromStorageGeneric(PLACEMENTS_NAMESPACE)
|
|
172
57
|
}
|
|
173
58
|
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
for (const k of Object.keys(out)) {
|
|
177
|
-
if (!keys.has(k)) delete out[k]
|
|
178
|
-
}
|
|
179
|
-
return out
|
|
59
|
+
export function schedulePageSave(payload: PersistedPageV1): void {
|
|
60
|
+
schedulePageSaveGeneric(PLACEMENTS_NAMESPACE, payload)
|
|
180
61
|
}
|
|
181
62
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
|
|
193
|
-
|
|
194
|
-
ts.setSortRules(p.sortRules)
|
|
195
|
-
ts.setSearch(p.search)
|
|
196
|
-
ts.setActiveFilters(p.activeFilters)
|
|
197
|
-
ts.setFilterConnectors(p.filterConnectors)
|
|
198
|
-
ts.setGroupBy(p.groupBy)
|
|
199
|
-
ts.setColOrder(colOrder)
|
|
200
|
-
ts.setHiddenCols(hidden)
|
|
201
|
-
ts.setColWidths(colWidths)
|
|
202
|
-
ts.setColPins(colPins)
|
|
203
|
-
ts.setColWrap(colWrap)
|
|
204
|
-
ts.setColMenuSearch(colMenuSearch)
|
|
205
|
-
ts.setRowHeight(p.rowHeight)
|
|
206
|
-
ts.setShowGridlines(p.showGridlines)
|
|
207
|
-
ts.setFilterBarVisible(p.filterBarVisible)
|
|
208
|
-
ts.setSearchOpen(p.searchOpen)
|
|
63
|
+
/**
|
|
64
|
+
* Placements lifecycle includes a few extra fields next to the table state.
|
|
65
|
+
* The generic serializer takes a free-form `extras` record; this thin
|
|
66
|
+
* adapter keeps the original call sites typed and ergonomic.
|
|
67
|
+
*/
|
|
68
|
+
export interface PlacementsLifecycleExtras extends Record<string, unknown> {
|
|
69
|
+
conditionalRules: ConditionalRule[]
|
|
70
|
+
pagination: boolean
|
|
71
|
+
paginationPage: number
|
|
72
|
+
paginationPageSize: number
|
|
209
73
|
}
|
|
210
74
|
|
|
211
75
|
export function serializeLifecycle(
|
|
212
76
|
ts: TableStatePersistSlice,
|
|
213
|
-
extras:
|
|
214
|
-
conditionalRules: ConditionalRule[]
|
|
215
|
-
pagination: boolean
|
|
216
|
-
paginationPage: number
|
|
217
|
-
paginationPageSize: number
|
|
218
|
-
},
|
|
77
|
+
extras: PlacementsLifecycleExtras,
|
|
219
78
|
): PersistedLifecycleV1 {
|
|
220
|
-
return
|
|
221
|
-
v: 1,
|
|
222
|
-
sortRules: ts.sortRules,
|
|
223
|
-
search: ts.search,
|
|
224
|
-
activeFilters: ts.activeFilters,
|
|
225
|
-
filterConnectors: ts.filterConnectors,
|
|
226
|
-
groupBy: ts.groupBy,
|
|
227
|
-
colOrder: ts.colOrder,
|
|
228
|
-
hiddenCols: [...ts.hiddenCols],
|
|
229
|
-
colWidths: { ...ts.colWidths },
|
|
230
|
-
colPins: { ...ts.colPins },
|
|
231
|
-
colWrap: { ...ts.colWrap },
|
|
232
|
-
colMenuSearch: { ...ts.colMenuSearch },
|
|
233
|
-
rowHeight: ts.rowHeight,
|
|
234
|
-
showGridlines: ts.showGridlines,
|
|
235
|
-
filterBarVisible: ts.filterBarVisible,
|
|
236
|
-
searchOpen: ts.searchOpen,
|
|
237
|
-
conditionalRules: extras.conditionalRules,
|
|
238
|
-
pagination: extras.pagination,
|
|
239
|
-
paginationPage: extras.paginationPage,
|
|
240
|
-
paginationPageSize: extras.paginationPageSize,
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
export function loadLifecycleFromStorage(lifecycleTabId: string): PersistedLifecycleV1 | null {
|
|
245
|
-
if (typeof window === "undefined") return null
|
|
246
|
-
return parsePersistedLifecycle(localStorage.getItem(lifecycleStorageKey(lifecycleTabId)))
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function scheduleLifecycleSave(lifecycleTabId: string, payload: PersistedLifecycleV1): void {
|
|
250
|
-
if (typeof window === "undefined") return
|
|
251
|
-
const prev = lifecycleTimers.get(lifecycleTabId)
|
|
252
|
-
if (prev) clearTimeout(prev)
|
|
253
|
-
const t = setTimeout(() => {
|
|
254
|
-
lifecycleTimers.delete(lifecycleTabId)
|
|
255
|
-
try {
|
|
256
|
-
localStorage.setItem(lifecycleStorageKey(lifecycleTabId), JSON.stringify(payload))
|
|
257
|
-
} catch {
|
|
258
|
-
/* quota / private mode */
|
|
259
|
-
}
|
|
260
|
-
}, LIFECYCLE_SAVE_DEBOUNCE_MS)
|
|
261
|
-
lifecycleTimers.set(lifecycleTabId, t)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export function loadPageFromStorage(): PersistedPageV1 | null {
|
|
265
|
-
if (typeof window === "undefined") return null
|
|
266
|
-
return parsePersistedPage(localStorage.getItem(DATA_LIST_PAGE_STORAGE_KEY))
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
export function schedulePageSave(payload: PersistedPageV1): void {
|
|
270
|
-
if (typeof window === "undefined") return
|
|
271
|
-
if (pageTimer.t) clearTimeout(pageTimer.t)
|
|
272
|
-
pageTimer.t = setTimeout(() => {
|
|
273
|
-
pageTimer.t = undefined
|
|
274
|
-
try {
|
|
275
|
-
localStorage.setItem(DATA_LIST_PAGE_STORAGE_KEY, JSON.stringify(payload))
|
|
276
|
-
} catch {
|
|
277
|
-
/* quota */
|
|
278
|
-
}
|
|
279
|
-
}, PAGE_SAVE_DEBOUNCE_MS)
|
|
79
|
+
return serializeLifecycleGeneric(ts, extras)
|
|
280
80
|
}
|
|
@@ -3,25 +3,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
|
3
3
|
import { devLog } from "./dev-log"
|
|
4
4
|
|
|
5
5
|
describe("devLog", () => {
|
|
6
|
-
const originalEnv = process.env.NODE_ENV
|
|
7
|
-
|
|
8
6
|
beforeEach(() => {
|
|
9
7
|
vi.spyOn(console, "log").mockImplementation(() => {})
|
|
10
8
|
})
|
|
11
9
|
|
|
12
10
|
afterEach(() => {
|
|
13
11
|
vi.restoreAllMocks()
|
|
14
|
-
|
|
12
|
+
vi.unstubAllEnvs()
|
|
15
13
|
})
|
|
16
14
|
|
|
17
15
|
it("logs in development", () => {
|
|
18
|
-
process.env.NODE_ENV
|
|
16
|
+
// `process.env.NODE_ENV` is typed as readonly in modern Node typings; use
|
|
17
|
+
// Vitest's `stubEnv` so the test compiles without a `// @ts-expect-error`
|
|
18
|
+
// dance and auto-restores after `unstubAllEnvs()`.
|
|
19
|
+
vi.stubEnv("NODE_ENV", "development")
|
|
19
20
|
devLog("hello", 1)
|
|
20
21
|
expect(console.log).toHaveBeenCalledWith("hello", 1)
|
|
21
22
|
})
|
|
22
23
|
|
|
23
24
|
it("does not log in production", () => {
|
|
24
|
-
|
|
25
|
+
vi.stubEnv("NODE_ENV", "production")
|
|
25
26
|
devLog("silent")
|
|
26
27
|
expect(console.log).not.toHaveBeenCalled()
|
|
27
28
|
})
|