@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. 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 = useAppStore(s => s.product)
18
- const setProduct = useAppStore(s => s.setProduct)
19
- return { product, setProduct }
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
- html.classList.add(product === "exxat-one" ? "theme-one" : "theme-prism")
35
- }, [product])
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 parsed = JSON.parse(raw) as Partial<SystemBannerConfig>
70
- // Merge so newly-added fields in the default keep working for old payloads.
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
- setConfig({ ...DEFAULT_SYSTEM_BANNER_CONFIG, ...JSON.parse(e.newValue) })
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 = window.matchMedia("(max-height: 640px)").matches
24
+ const short = mql.matches
21
25
  const veryShort = window.innerHeight <= 420
22
- setReflow(scale >= 1.99 || short || veryShort)
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
- vv?.addEventListener("resize", compute)
27
- vv?.addEventListener("scroll", compute)
28
- window.addEventListener("resize", compute)
29
- const mql = window.matchMedia("(max-height: 640px)")
30
- mql.addEventListener("change", compute)
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
- vv?.removeEventListener("resize", compute)
33
- vv?.removeEventListener("scroll", compute)
34
- window.removeEventListener("resize", compute)
35
- mql.removeEventListener("change", compute)
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
- * Persist Data list page UI: per-page shell (tabs, display options) and per–lifecycle-tab table state.
3
- * Keys are versioned so future migrations can bump `v` or the key suffix.
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 type { Dispatch, SetStateAction } from "react"
7
- import type { RowHeight } from "@/lib/row-height"
8
- import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
9
- import type { ActiveFilter, ConditionalRule, SortRule } from "@/components/table-properties/types"
10
- import type { ViewTab } from "@/components/templates/list-page"
11
- import type { DataListViewType } from "@/lib/data-list-view"
12
-
13
- export const DATA_LIST_PAGE_STORAGE_KEY = "exxat-ds:data-list:page:v1"
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 `exxat-ds:data-list:lifecycle:v1:${lifecycleTabId}`
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
- function parseViewTab(raw: unknown): ViewTab | null {
97
- if (!raw || typeof raw !== "object") return null
98
- const o = raw as Record<string, unknown>
99
- if (typeof o.id !== "string" || typeof o.label !== "string") return null
100
- if (!isViewType(o.viewType)) return null
101
- if (typeof o.icon !== "string" || typeof o.filterId !== "string") return null
102
- return {
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 parsePersistedPage(raw: string | null): PersistedPageV1 | null {
112
- if (!raw) return null
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 parsePersistedLifecycle(raw: string | null): PersistedLifecycleV1 | null {
136
- if (!raw) return null
137
- try {
138
- const j = JSON.parse(raw) as unknown
139
- if (!j || typeof j !== "object") return null
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 mergeColOrder(saved: string[], columnKeys: Set<string>): string[] {
167
- const ordered = saved.filter(k => columnKeys.has(k))
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 filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<string>): T {
175
- const out = { ...obj }
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
- export function applyLifecyclePersisted(
183
- ts: TableStatePersistSlice,
184
- p: PersistedLifecycleV1,
185
- columnKeys: Set<string>,
186
- ): void {
187
- const colOrder = mergeColOrder(p.colOrder, columnKeys)
188
- const hidden = new Set(p.hiddenCols.filter(k => columnKeys.has(k)))
189
- const colWidths = filterRecordKeys(p.colWidths, columnKeys) as Record<string, number>
190
- const colPins = filterRecordKeys(p.colPins, columnKeys) as Record<string, "left" | "right">
191
- const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
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
- process.env.NODE_ENV = originalEnv
12
+ vi.unstubAllEnvs()
15
13
  })
16
14
 
17
15
  it("logs in development", () => {
18
- process.env.NODE_ENV = "development"
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
- process.env.NODE_ENV = "production"
25
+ vi.stubEnv("NODE_ENV", "production")
25
26
  devLog("silent")
26
27
  expect(console.log).not.toHaveBeenCalled()
27
28
  })