@exxatdesignux/ui 0.2.16 → 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 +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- 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-kpi-flat-band/SKILL.md +38 -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 +19 -0
- 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 +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- 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 +4 -0
- package/template/components/data-table/index.tsx +99 -91
- 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 +276 -100
- 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/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- 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} +2 -2
- 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 +19 -133
- 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} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -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 +19 -6
- 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/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- 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/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- 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/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
})
|