@exxatdesignux/ui 0.2.9 → 0.2.11

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 (126) hide show
  1. package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
  3. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
  4. package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
  6. package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
  7. package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
  10. package/consumer-extras/patterns/data-views-pattern.md +12 -4
  11. package/package.json +4 -1
  12. package/src/components/ui/banner.tsx +20 -7
  13. package/src/components/ui/date-picker-field.tsx +3 -3
  14. package/src/components/ui/dropdown-menu.tsx +17 -6
  15. package/src/components/ui/input-group.tsx +1 -1
  16. package/src/components/ui/input.tsx +1 -1
  17. package/src/components/ui/select.tsx +1 -1
  18. package/src/components/ui/separator.tsx +2 -2
  19. package/src/components/ui/sidebar.tsx +31 -3
  20. package/src/components/ui/textarea.tsx +1 -1
  21. package/src/globals.css +0 -1
  22. package/src/index.ts +1 -0
  23. package/src/lib/date-filter.ts +13 -4
  24. package/src/lib/dropdown-menu-surface.ts +13 -0
  25. package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
  26. package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
  27. package/template/.nvmrc +1 -1
  28. package/template/AGENTS.md +82 -27
  29. package/template/app/(app)/examples/page.tsx +2 -1
  30. package/template/app/(app)/help/page.tsx +6 -0
  31. package/template/app/(app)/layout.tsx +7 -4
  32. package/template/app/(app)/question-bank/find/page.tsx +12 -0
  33. package/template/app/(app)/question-bank/layout.tsx +46 -0
  34. package/template/app/(app)/question-bank/library/page.tsx +11 -0
  35. package/template/app/(app)/question-bank/list/page.tsx +12 -0
  36. package/template/app/(app)/question-bank/page.tsx +4 -3
  37. package/template/app/globals.css +1 -2
  38. package/template/components/app-sidebar.tsx +51 -13
  39. package/template/components/ask-leo-composer.tsx +173 -45
  40. package/template/components/ask-leo-sidebar.tsx +9 -1
  41. package/template/components/chart-area-interactive.tsx +3 -13
  42. package/template/components/charts-overview.tsx +33 -6
  43. package/template/components/collaboration-access-flow.tsx +144 -0
  44. package/template/components/compliance-page-header.tsx +1 -1
  45. package/template/components/compliance-table.tsx +2 -2
  46. package/template/components/dashboard-tabs.tsx +4 -3
  47. package/template/components/data-list-table-cells.tsx +1 -1
  48. package/template/components/data-list-table.tsx +1 -1
  49. package/template/components/data-table/index.tsx +5 -5
  50. package/template/components/data-table/use-table-state.ts +18 -2
  51. package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
  52. package/template/components/data-view-dashboard-charts-team.tsx +8 -5
  53. package/template/components/data-view-dashboard-charts.tsx +62 -227
  54. package/template/components/dedicated-search-recents.tsx +96 -0
  55. package/template/components/dedicated-search-url-composer.tsx +112 -0
  56. package/template/components/getting-started.tsx +1 -1
  57. package/template/components/hub-tree-panel-view.tsx +10 -26
  58. package/template/components/invite-collaborators-drawer.tsx +453 -0
  59. package/template/components/key-metrics.tsx +54 -8
  60. package/template/components/nav-documents.tsx +1 -1
  61. package/template/components/new-placement-form.tsx +3 -3
  62. package/template/components/page-header.tsx +76 -59
  63. package/template/components/placements-board-view.tsx +3 -3
  64. package/template/components/placements-page-header.tsx +1 -1
  65. package/template/components/placements-table-columns.tsx +3 -2
  66. package/template/components/product-switcher.tsx +0 -1
  67. package/template/components/question-bank-board-view.tsx +35 -47
  68. package/template/components/question-bank-client.tsx +293 -81
  69. package/template/components/question-bank-dashboard-charts.tsx +174 -0
  70. package/template/components/question-bank-favorite-button.tsx +46 -0
  71. package/template/components/question-bank-hub-client.tsx +436 -0
  72. package/template/components/question-bank-list-view.tsx +26 -19
  73. package/template/components/question-bank-new-folder-sheet.tsx +56 -42
  74. package/template/components/question-bank-os-folder-view.tsx +3 -14
  75. package/template/components/question-bank-page-header.tsx +85 -53
  76. package/template/components/question-bank-panel-activator.tsx +3 -4
  77. package/template/components/question-bank-secondary-nav.tsx +523 -65
  78. package/template/components/question-bank-table.tsx +125 -343
  79. package/template/components/secondary-panel.tsx +130 -63
  80. package/template/components/settings-client.tsx +3 -1
  81. package/template/components/sidebar-shell.tsx +2 -0
  82. package/template/components/sites-all-client.tsx +1 -1
  83. package/template/components/sites-table.tsx +1 -1
  84. package/template/components/system-banner-slot.tsx +2 -1
  85. package/template/components/table-properties/drawer.tsx +3 -3
  86. package/template/components/table-properties/sort-card.tsx +1 -1
  87. package/template/components/team-page-header.tsx +1 -1
  88. package/template/components/team-table.tsx +8 -4
  89. package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
  90. package/template/components/templates/dedicated-search-results-template.tsx +19 -0
  91. package/template/components/templates/discovery-hub-template.tsx +273 -0
  92. package/template/components/templates/list-page.tsx +11 -4
  93. package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
  94. package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
  95. package/template/docs/card-vs-rows-pattern.md +36 -0
  96. package/template/docs/collaboration-access-pattern.md +114 -0
  97. package/template/docs/data-views-pattern.md +12 -4
  98. package/template/docs/drawer-vs-dialog-pattern.md +50 -0
  99. package/template/docs/kpi-strip-max-four-pattern.md +29 -0
  100. package/template/docs/kpi-trend-pattern.md +43 -0
  101. package/template/fontawesome-subset.manifest.json +2 -2
  102. package/template/hooks/use-location-hash.ts +14 -8
  103. package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
  104. package/template/lib/ask-leo-route-context.ts +24 -0
  105. package/template/lib/collaborator-access.ts +92 -0
  106. package/template/lib/command-menu-config.ts +8 -1
  107. package/template/lib/command-menu-search-data.ts +11 -8
  108. package/template/lib/data-list-display-options.ts +1 -1
  109. package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
  110. package/template/lib/date-filter.ts +1 -0
  111. package/template/lib/dedicated-search-recents.ts +76 -0
  112. package/template/lib/dedicated-search-url.ts +23 -0
  113. package/template/lib/discovery-hub.ts +15 -0
  114. package/template/lib/list-status-badges.ts +1 -21
  115. package/template/lib/mock/navigation.tsx +4 -2
  116. package/template/lib/mock/placements.ts +9 -9
  117. package/template/lib/mock/question-bank-folders.ts +7 -0
  118. package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
  119. package/template/lib/mock/question-bank-inspector.ts +1 -2
  120. package/template/lib/mock/question-bank-kpi.ts +38 -26
  121. package/template/lib/mock/question-bank.ts +43 -16
  122. package/template/lib/question-bank-dedicated-search.ts +19 -0
  123. package/template/lib/question-bank-hub-search.ts +90 -0
  124. package/template/lib/question-bank-nav.ts +322 -6
  125. package/template/lib/question-bank-recent-searches.ts +22 -0
  126. package/template/package.json +1 -2
@@ -6,27 +6,51 @@
6
6
  */
7
7
 
8
8
  import * as React from "react"
9
- import { usePathname, useRouter, useSearchParams } from "next/navigation"
9
+ import { useRouter, useSearchParams } from "next/navigation"
10
10
  import {
11
11
  ListPageTemplate,
12
12
  type ViewTab,
13
13
  dataListViewIcon,
14
14
  type DataListViewType,
15
15
  } from "@/components/data-views"
16
- import { QuestionBankPanelActivator } from "@/components/question-bank-panel-activator"
17
16
  import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
17
+ import { CollaborationAccessFlow } from "@/components/collaboration-access-flow"
18
18
  import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
19
- import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
20
- import { useSecondaryPanel } from "@/components/secondary-panel"
19
+ import { SecondaryPanelHubTemplate } from "@/components/templates/secondary-panel-hub-template"
20
+ import { QuestionBankAccessBridge, QuestionBankFolderBridge } from "@/components/secondary-panel"
21
21
  import { KeyMetrics } from "@/components/key-metrics"
22
+ import { useSecondaryPanelHubNav } from "@/hooks/use-secondary-panel-hub-nav"
22
23
  import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
24
+ import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
23
25
  import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
24
26
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
25
27
  import {
26
- filterQuestionBankItemsByNav,
28
+ applyQuestionBankHubDisplayFilters,
29
+ isQuestionBankDefaultNav,
30
+ isQuestionBankDedicatedSearchPathname,
27
31
  parseQuestionBankNav,
32
+ QUESTION_BANK_HUB_BREADCRUMB,
33
+ QUESTION_BANK_HUB_FIND_PATH,
34
+ QUESTION_BANK_LIBRARY_PATH,
35
+ QUESTION_BANK_LIBRARY_HUB_PATHS,
36
+ QUESTION_BANK_LIST_PATH,
37
+ questionBankCanonicalNavHref,
28
38
  questionBankHubHeaderModel,
39
+ questionBankHubTextMatchesNothing,
40
+ type QuestionBankLandingFilterState,
29
41
  } from "@/lib/question-bank-nav"
42
+ import {
43
+ patchQuestionBankDedicatedSearchParams,
44
+ QUESTION_BANK_DEDICATED_SEARCH_PLACEHOLDERS,
45
+ } from "@/lib/question-bank-dedicated-search"
46
+ import { recordQuestionBankRecentSearch, questionBankDedicatedSearchRecents } from "@/lib/question-bank-recent-searches"
47
+ import { DedicatedSearchRecents } from "@/components/dedicated-search-recents"
48
+ import { DedicatedSearchUrlComposer } from "@/components/dedicated-search-url-composer"
49
+ import { DedicatedSearchLandingTemplate } from "@/components/templates/dedicated-search-landing-template"
50
+ import {
51
+ DedicatedSearchResultsHeaderChrome,
52
+ DEDICATED_SEARCH_RESULTS_OUTER_CONTENT_CLASSNAME,
53
+ } from "@/components/templates/dedicated-search-results-template"
30
54
 
31
55
  const DEFAULT_TABS: ViewTab[] = [
32
56
  {
@@ -52,78 +76,106 @@ const DEFAULT_TABS: ViewTab[] = [
52
76
  },
53
77
  ]
54
78
 
79
+ const SEARCH_LANDING_TABS: ViewTab[] = [DEFAULT_TABS[0]]
80
+
81
+ function ignoreQuestionBankTabsUpdate(_next: ViewTab[]) {}
82
+ function ignoreQuestionBankTabActivation(_id: string) {}
83
+ /** Stable no-op for search-landing branch where manage-access is not available. */
84
+ function noopManageAccess() {}
85
+
55
86
  function questionBankQueryPrefixFromSearchString(qs: string) {
56
87
  return qs ? `?${qs}` : ""
57
88
  }
58
89
 
59
90
  export function QuestionBankClient() {
60
- const pathname = usePathname()
61
91
  const router = useRouter()
62
92
  const searchParams = useSearchParams()
63
- const { openPanel } = useSecondaryPanel()
93
+ const { navState, searchParamsKey, pathname, isHubPath, hubBasePath } = useSecondaryPanelHubNav({
94
+ hubPathname: QUESTION_BANK_LIBRARY_PATH,
95
+ hubPathnames: QUESTION_BANK_LIBRARY_HUB_PATHS,
96
+ panelId: "question-bank",
97
+ parseNav: parseQuestionBankNav,
98
+ canonicalHref: questionBankCanonicalNavHref,
99
+ shouldReopenPanel: isQuestionBankDefaultNav,
100
+ })
101
+ const isDedicatedSearch = isQuestionBankDedicatedSearchPathname(pathname)
102
+ const isHubFindSurface = pathname === QUESTION_BANK_HUB_FIND_PATH
103
+ const dedicatedSearchTitle = isHubFindSurface ? "Discovery search" : "Search Questions"
104
+ const landingFilters = React.useMemo((): QuestionBankLandingFilterState | null => {
105
+ if (!isDedicatedSearch) return null
106
+ const sp = new URLSearchParams(searchParamsKey)
107
+ return {
108
+ hubFreeText: sp.get("q") ?? "",
109
+ favOnly: sp.get("fav") === "1",
110
+ clinicalDeck: sp.get("deck") === "clinical",
111
+ }
112
+ }, [isDedicatedSearch, searchParamsKey])
113
+ const urlToolbarSearchSync = searchParams.get("q") ?? ""
114
+ const hasUrlSearch = Boolean((isDedicatedSearch ? landingFilters?.hubFreeText : urlToolbarSearchSync)?.trim())
64
115
  const [tabs, setTabs] = React.useState<ViewTab[]>(DEFAULT_TABS)
65
116
  const [activeTabId, setActiveTabId] = React.useState(DEFAULT_TABS[0].id)
66
117
 
118
+ // Stable Set of tab ids — defaults are constant so this only updates if tabs change.
67
119
  const tabIds = React.useMemo(() => new Set(tabs.map(t => t.id)), [tabs])
68
120
 
69
- /** String key `useSearchParams()` identity can stay stable when only the query changes. */
70
- const searchParamsKey = searchParams.toString()
71
- const navState = React.useMemo(
72
- () => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
73
- [searchParamsKey],
74
- )
75
-
76
- /** “All questions” hub — keep secondary nav open when scope clears (breadcrumb, All questions link). */
121
+ // Keep the latest pathname / searchParamsKey / tabIds available to the (stable) hashchange
122
+ // listener via refs, so we don't re-subscribe a window listener on every URL change.
123
+ const navRef = React.useRef({ pathname, searchParamsKey, tabIds, hubBasePath })
77
124
  React.useEffect(() => {
78
- if (pathname !== "/question-bank") return
79
- if (navState.scope !== "all") return
80
- openPanel("question-bank")
81
- }, [pathname, navState.scope, openPanel])
125
+ navRef.current = { pathname, searchParamsKey, tabIds, hubBasePath }
126
+ }, [pathname, searchParamsKey, tabIds, hubBasePath])
82
127
 
83
128
  React.useEffect(() => {
84
- if (pathname !== "/question-bank") return
85
- const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
129
+ if (!isHubPath || isDedicatedSearch) return
86
130
  const apply = () => {
131
+ const current = navRef.current
132
+ if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(current.pathname)) return
87
133
  const raw = typeof window !== "undefined" ? window.location.hash.slice(1) : ""
88
134
  let nextId = "questions"
89
135
  if (raw === "panel-view" || raw === "tree-panel") {
90
136
  nextId = raw
91
- } else if (raw && tabIds.has(raw)) {
137
+ } else if (raw && current.tabIds.has(raw)) {
92
138
  nextId = raw
93
139
  }
94
140
  setActiveTabId(nextId)
95
141
  if (nextId === "questions" && raw && raw !== "questions") {
96
- router.replace(`/question-bank${prefix}`, { scroll: false })
142
+ const prefix = questionBankQueryPrefixFromSearchString(current.searchParamsKey)
143
+ router.replace(`${current.hubBasePath}${prefix}`, { scroll: false })
97
144
  }
98
145
  }
99
146
  apply()
100
147
  window.addEventListener("hashchange", apply)
101
148
  return () => window.removeEventListener("hashchange", apply)
102
- }, [pathname, router, tabIds, searchParamsKey])
149
+ // Re-run on pathname changes (mount/unmount); URL search-param changes are read from the ref.
150
+ }, [isHubPath, isDedicatedSearch, router])
103
151
 
104
152
  const onActiveTabChange = React.useCallback(
105
153
  (id: string) => {
154
+ if (isDedicatedSearch) return
106
155
  setActiveTabId(id)
107
- if (pathname !== "/question-bank") return
156
+ if (!isHubPath) return
108
157
  const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
109
158
  if (id === "questions") {
110
- router.replace(`/question-bank${prefix}`, { scroll: false })
159
+ router.replace(`${hubBasePath}${prefix}`, { scroll: false })
111
160
  } else {
112
- router.replace(`/question-bank${prefix}#${id}`, { scroll: false })
161
+ router.replace(`${hubBasePath}${prefix}#${id}`, { scroll: false })
113
162
  }
114
163
  },
115
- [pathname, router, searchParamsKey],
164
+ [hubBasePath, isHubPath, isDedicatedSearch, router, searchParamsKey],
116
165
  )
117
166
 
118
167
  const [exportOpen, setExportOpen] = React.useState(false)
119
168
  const [showMetrics, setShowMetrics] = React.useState(true)
169
+ React.useLayoutEffect(() => {
170
+ if (hasUrlSearch) setShowMetrics(false)
171
+ }, [hasUrlSearch])
120
172
  const tableRef = React.useRef<QuestionBankTableHandle>(null)
121
173
  const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
122
174
  const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
123
175
 
124
176
  const filteredItems = React.useMemo(
125
- () => filterQuestionBankItemsByNav(items, folders, navState),
126
- [items, folders, navState],
177
+ () => applyQuestionBankHubDisplayFilters(items, folders, navState, landingFilters),
178
+ [items, folders, landingFilters, navState],
127
179
  )
128
180
 
129
181
  const count = filteredItems.length
@@ -136,60 +188,220 @@ export function QuestionBankClient() {
136
188
  [folders, navState],
137
189
  )
138
190
 
191
+ const hubTextHadNoMatches = React.useMemo(
192
+ () =>
193
+ isDedicatedSearch &&
194
+ landingFilters != null &&
195
+ questionBankHubTextMatchesNothing(items, folders, navState, landingFilters),
196
+ [folders, isDedicatedSearch, items, landingFilters, navState],
197
+ )
198
+
199
+ if (isDedicatedSearch) {
200
+ const dedicatedReplacePath = isHubFindSurface ? QUESTION_BANK_HUB_FIND_PATH : QUESTION_BANK_LIST_PATH
201
+ const showDedicatedSearchResults = hasUrlSearch
202
+
203
+ return (
204
+ <>
205
+ <QuestionBankFolderBridge
206
+ folders={folders}
207
+ onFoldersChange={setFolders}
208
+ items={items}
209
+ onItemsChange={setItems}
210
+ />
211
+ <QuestionBankAccessBridge openManageAccess={noopManageAccess} />
212
+ <SecondaryPanelHubTemplate
213
+ siteHeader={{
214
+ title: dedicatedSearchTitle,
215
+ breadcrumbs: [{ label: QUESTION_BANK_HUB_BREADCRUMB.label, href: QUESTION_BANK_HUB_BREADCRUMB.href }],
216
+ }}
217
+ contentClassName={
218
+ showDedicatedSearchResults ? DEDICATED_SEARCH_RESULTS_OUTER_CONTENT_CLASSNAME : undefined
219
+ }
220
+ >
221
+ {showDedicatedSearchResults ? (
222
+ <ListPageTemplate
223
+ defaultTabs={DEFAULT_TABS}
224
+ tabs={SEARCH_LANDING_TABS}
225
+ onTabsChange={ignoreQuestionBankTabsUpdate}
226
+ activeTabId={SEARCH_LANDING_TABS[0]!.id}
227
+ onActiveTabChange={ignoreQuestionBankTabActivation}
228
+ hideViewsToolbar
229
+ getTabCount={() => count}
230
+ tablePropertiesRef={tableRef}
231
+ header={(
232
+ <DedicatedSearchResultsHeaderChrome>
233
+ <QuestionBankPageHeader
234
+ variant="default"
235
+ title={dedicatedSearchTitle}
236
+ questionCount={count}
237
+ hideNewQuestion
238
+ onNewQuestion={() => {}}
239
+ onExport={() => setExportOpen(true)}
240
+ />
241
+ <DedicatedSearchUrlComposer
242
+ searchParamsKey={searchParamsKey}
243
+ replacePath={dedicatedReplacePath}
244
+ patchSearchParams={patchQuestionBankDedicatedSearchParams}
245
+ onRecordSubmission={recordQuestionBankRecentSearch}
246
+ layout="default"
247
+ animatedPlaceholders={QUESTION_BANK_DEDICATED_SEARCH_PLACEHOLDERS}
248
+ animatedPlaceholderIntervalMs={4800}
249
+ animatedPlaceholderMaxLines={2}
250
+ placeholder="Search the bank…"
251
+ inputLabel="AI search"
252
+ submitAppearance="search"
253
+ submitButtonAriaLabel="Run AI search"
254
+ srOnlyDescription={
255
+ <>
256
+ Type a plain-language request, then press Enter to filter the question list. This control
257
+ does not open Ask Leo.
258
+ </>
259
+ }
260
+ />
261
+ {hubTextHadNoMatches ? (
262
+ <p className="px-4 pb-3 text-sm text-muted-foreground lg:px-6">
263
+ No questions matched that wording for this scope — showing the list without that text filter.
264
+ </p>
265
+ ) : null}
266
+ </DedicatedSearchResultsHeaderChrome>
267
+ )}
268
+ exportOpen={exportOpen}
269
+ onExportOpenChange={setExportOpen}
270
+ exportTotalRows={count}
271
+ renderContent={(tab, updateTab) => (
272
+ <QuestionBankTable
273
+ key={tab.id}
274
+ ref={tableRef}
275
+ items={items}
276
+ navState={navState}
277
+ urlListSearch={undefined}
278
+ landingFilters={landingFilters}
279
+ searchLanding
280
+ folders={folders}
281
+ onFoldersChange={setFolders}
282
+ onItemsChange={setItems}
283
+ view={tab.viewType}
284
+ onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
285
+ />
286
+ )}
287
+ />
288
+ ) : (
289
+ <DedicatedSearchLandingTemplate
290
+ title={isHubFindSurface ? "Discovery search" : "Search your question bank"}
291
+ composer={(
292
+ <DedicatedSearchUrlComposer
293
+ searchParamsKey={searchParamsKey}
294
+ replacePath={dedicatedReplacePath}
295
+ patchSearchParams={patchQuestionBankDedicatedSearchParams}
296
+ onRecordSubmission={recordQuestionBankRecentSearch}
297
+ layout="hero"
298
+ animatedPlaceholders={QUESTION_BANK_DEDICATED_SEARCH_PLACEHOLDERS}
299
+ animatedPlaceholderIntervalMs={4800}
300
+ animatedPlaceholderMaxLines={2}
301
+ placeholder="Search the bank…"
302
+ inputLabel="AI search"
303
+ submitAppearance="search"
304
+ submitButtonAriaLabel="Run AI search"
305
+ srOnlyDescription={
306
+ <>
307
+ Type a plain-language request, then press Enter to filter the question list. This control does
308
+ not open Ask Leo.
309
+ </>
310
+ }
311
+ />
312
+ )}
313
+ trailing={(
314
+ <DedicatedSearchRecents
315
+ recents={questionBankDedicatedSearchRecents}
316
+ searchParamsKey={searchParamsKey}
317
+ replacePath={dedicatedReplacePath}
318
+ patchSearchParams={patchQuestionBankDedicatedSearchParams}
319
+ />
320
+ )}
321
+ />
322
+ )}
323
+ </SecondaryPanelHubTemplate>
324
+ </>
325
+ )
326
+ }
327
+
139
328
  return (
140
- <PrimaryPageTemplate
141
- beforeSiteHeader={<QuestionBankPanelActivator />}
142
- siteHeader={{
143
- title: hubHeader.title,
144
- breadcrumbs: hubHeader.breadcrumbs,
145
- }}
329
+ <CollaborationAccessFlow
330
+ initialCollaborators={QUESTION_BANK_HEADER_COLLABORATORS}
331
+ resourceLabel={hubHeader.title}
146
332
  >
147
- <ListPageTemplate
148
- defaultTabs={DEFAULT_TABS}
149
- tabs={tabs}
150
- onTabsChange={setTabs}
151
- activeTabId={activeTabId}
152
- onActiveTabChange={onActiveTabChange}
153
- getTabCount={() => count}
154
- tablePropertiesRef={tableRef}
155
- header={(
156
- <QuestionBankPageHeader
157
- variant="collaboration"
158
- title={hubHeader.title}
159
- questionCount={count}
160
- onNewQuestion={() => {}}
161
- onExport={() => setExportOpen(true)}
333
+ {({ collaborators, openInvite }) => (
334
+ <SecondaryPanelHubTemplate
335
+ bridges={(
336
+ <>
337
+ <QuestionBankFolderBridge
338
+ folders={folders}
339
+ onFoldersChange={setFolders}
340
+ items={items}
341
+ onItemsChange={setItems}
342
+ />
343
+ <QuestionBankAccessBridge openManageAccess={openInvite} />
344
+ </>
345
+ )}
346
+ siteHeader={{
347
+ title: hubHeader.title,
348
+ breadcrumbs: hubHeader.breadcrumbs,
349
+ }}
350
+ >
351
+ <ListPageTemplate
352
+ defaultTabs={DEFAULT_TABS}
353
+ tabs={tabs}
354
+ onTabsChange={setTabs}
355
+ activeTabId={activeTabId}
356
+ onActiveTabChange={onActiveTabChange}
357
+ getTabCount={() => count}
358
+ tablePropertiesRef={tableRef}
359
+ header={(
360
+ <QuestionBankPageHeader
361
+ variant="collaboration"
362
+ title={hubHeader.title}
363
+ questionCount={count}
364
+ collaborators={collaborators}
365
+ onNewQuestion={() => {}}
366
+ onExport={() => setExportOpen(true)}
367
+ onAddCollaborator={openInvite}
368
+ onCollaboratorsOpen={openInvite}
369
+ showMetrics={showMetrics}
370
+ onToggleMetrics={() => setShowMetrics(v => !v)}
371
+ />
372
+ )}
373
+ metrics={(
374
+ <KeyMetrics
375
+ variant="flat"
376
+ metrics={metrics}
377
+ insight={insight}
378
+ showHeader={false}
379
+ metricsSingleRow
380
+ />
381
+ )}
162
382
  showMetrics={showMetrics}
163
- onToggleMetrics={() => setShowMetrics(v => !v)}
164
- />
165
- )}
166
- metrics={(
167
- <KeyMetrics
168
- variant="flat"
169
- metrics={metrics}
170
- insight={insight}
171
- showHeader={false}
172
- metricsSingleRow
173
- />
174
- )}
175
- showMetrics={showMetrics}
176
- exportOpen={exportOpen}
177
- onExportOpenChange={setExportOpen}
178
- exportTotalRows={count}
179
- renderContent={(tab, updateTab) => (
180
- <QuestionBankTable
181
- key={tab.id}
182
- ref={tableRef}
183
- items={items}
184
- navState={navState}
185
- folders={folders}
186
- onFoldersChange={setFolders}
187
- onItemsChange={setItems}
188
- view={tab.viewType}
189
- onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
383
+ exportOpen={exportOpen}
384
+ onExportOpenChange={setExportOpen}
385
+ exportTotalRows={count}
386
+ renderContent={(tab, updateTab) => (
387
+ <QuestionBankTable
388
+ key={tab.id}
389
+ ref={tableRef}
390
+ items={items}
391
+ navState={navState}
392
+ urlListSearch={urlToolbarSearchSync}
393
+ landingFilters={null}
394
+ searchLanding={false}
395
+ folders={folders}
396
+ onFoldersChange={setFolders}
397
+ onItemsChange={setItems}
398
+ view={tab.viewType}
399
+ onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
400
+ />
401
+ )}
190
402
  />
191
- )}
192
- />
193
- </PrimaryPageTemplate>
403
+ </SecondaryPanelHubTemplate>
404
+ )}
405
+ </CollaborationAccessFlow>
194
406
  )
195
407
  }
@@ -0,0 +1,174 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Question bank **Data** view — KPI strip + Recharts cards. Loaded via `next/dynamic`
5
+ * from `question-bank-table` so table/list/board/folder routes do not eagerly bundle Recharts.
6
+ */
7
+
8
+ import * as React from "react"
9
+ import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"
10
+ import { ChartCard, ChartDataTable, ChartFigure } from "@/components/charts-overview"
11
+ import { KeyMetrics } from "@/components/key-metrics"
12
+ import {
13
+ ChartContainer,
14
+ ChartTooltip,
15
+ chartTooltipKeyboardSyncProps,
16
+ ChartTooltipContent,
17
+ type ChartConfig,
18
+ } from "@/components/ui/chart"
19
+ import { CHART_KBD_ACTIVE_BAR } from "@/lib/chart-keyboard-selection"
20
+ import type { QuestionBankItem, QuestionBankType } from "@/lib/mock/question-bank"
21
+ import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
22
+
23
+ const BAR_CFG: ChartConfig = {
24
+ count: { label: "Questions", color: "var(--color-chart-2)" },
25
+ }
26
+
27
+ const TYPE_LABEL: Record<QuestionBankType, string> = {
28
+ multiple_choice: "Multiple choice",
29
+ true_false: "True / false",
30
+ short_answer: "Short answer",
31
+ }
32
+
33
+ function aggregateByType(rows: QuestionBankItem[]) {
34
+ const c: Record<QuestionBankType, number> = {
35
+ multiple_choice: 0,
36
+ true_false: 0,
37
+ short_answer: 0,
38
+ }
39
+ for (const r of rows) c[r.type]++
40
+ return (Object.keys(c) as QuestionBankType[]).map(key => ({
41
+ name: TYPE_LABEL[key],
42
+ value: c[key],
43
+ key,
44
+ }))
45
+ }
46
+
47
+ function aggregateByTopic(rows: QuestionBankItem[]) {
48
+ const map = new Map<string, number>()
49
+ for (const r of rows) map.set(r.topic, (map.get(r.topic) ?? 0) + 1)
50
+ return [...map.entries()]
51
+ .map(([name, value]) => ({ name: name.length > 20 ? `${name.slice(0, 18)}…` : name, value }))
52
+ .sort((a, b) => b.value - a.value)
53
+ .slice(0, 8)
54
+ }
55
+
56
+ function QuestionsByTypeChart({ rows }: { rows: QuestionBankItem[] }) {
57
+ const data = React.useMemo(() => aggregateByType(rows), [rows])
58
+ if (rows.length === 0) {
59
+ return (
60
+ <div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground" role="status">
61
+ No questions in this view.
62
+ </div>
63
+ )
64
+ }
65
+ const summary = `Item types: ${data.map(d => `${d.name} ${d.value}`).join(", ")}. Total ${rows.length}.`
66
+ return (
67
+ <ChartFigure label="Questions by item type" summary={summary} dataLength={data.length}>
68
+ {(activeIndex) => (
69
+ <>
70
+ <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
71
+ <BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
72
+ <CartesianGrid vertical={false} strokeDasharray="3 3" className="stroke-border" />
73
+ <XAxis dataKey="name" tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
74
+ <YAxis allowDecimals={false} width={32} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
75
+ <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
76
+ <Bar
77
+ dataKey="value"
78
+ fill="var(--color-chart-2)"
79
+ radius={[4, 4, 0, 0]}
80
+ maxBarSize={40}
81
+ activeBar={CHART_KBD_ACTIVE_BAR}
82
+ activeIndex={activeIndex ?? undefined}
83
+ >
84
+ {data.map((_, i) => (
85
+ <Cell key={i} fill="var(--color-chart-2)" />
86
+ ))}
87
+ </Bar>
88
+ </BarChart>
89
+ </ChartContainer>
90
+ <ChartDataTable
91
+ caption="Questions by item type"
92
+ headers={["Type", "Count"]}
93
+ rows={data.map(d => [d.name, d.value])}
94
+ />
95
+ </>
96
+ )}
97
+ </ChartFigure>
98
+ )
99
+ }
100
+
101
+ function QuestionsByTopicChart({ rows }: { rows: QuestionBankItem[] }) {
102
+ const data = React.useMemo(() => aggregateByTopic(rows), [rows])
103
+ if (rows.length === 0) {
104
+ return (
105
+ <div className="flex h-[200px] items-center justify-center text-sm text-muted-foreground" role="status">
106
+ No questions in this view.
107
+ </div>
108
+ )
109
+ }
110
+ const summary = `${data.length} topics shown. Total ${rows.length} questions.`
111
+ return (
112
+ <ChartFigure label="Questions by topic" summary={summary} dataLength={data.length}>
113
+ {(activeIndex) => (
114
+ <>
115
+ <ChartContainer config={BAR_CFG} className="h-[220px] w-full">
116
+ <BarChart data={data} layout="vertical" margin={{ top: 8, right: 8, left: 4, bottom: 0 }}>
117
+ <CartesianGrid horizontal={false} strokeDasharray="3 3" className="stroke-border" />
118
+ <XAxis type="number" allowDecimals={false} tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
119
+ <YAxis type="category" dataKey="name" width={100} tick={{ fontSize: 11 }} tickLine={false} axisLine={false} />
120
+ <ChartTooltip key={chartTooltipKeyboardSyncProps(activeIndex).key} {...chartTooltipKeyboardSyncProps(activeIndex).props} content={<ChartTooltipContent />} />
121
+ <Bar
122
+ dataKey="value"
123
+ fill="var(--color-chart-4)"
124
+ radius={[0, 4, 4, 0]}
125
+ maxBarSize={22}
126
+ activeBar={CHART_KBD_ACTIVE_BAR}
127
+ activeIndex={activeIndex ?? undefined}
128
+ >
129
+ {data.map((_, i) => (
130
+ <Cell key={i} fill="var(--color-chart-4)" />
131
+ ))}
132
+ </Bar>
133
+ </BarChart>
134
+ </ChartContainer>
135
+ <ChartDataTable
136
+ caption="Questions by topic"
137
+ headers={["Topic", "Count"]}
138
+ rows={data.map(d => [d.name, d.value])}
139
+ />
140
+ </>
141
+ )}
142
+ </ChartFigure>
143
+ )
144
+ }
145
+
146
+ export function QuestionBankDashboardChartsSection({ rows }: { rows: QuestionBankItem[] }) {
147
+ const kpi = React.useMemo(
148
+ () => ({
149
+ metrics: questionBankKpiMetrics(rows),
150
+ insight: questionBankKpiInsight(rows),
151
+ }),
152
+ [rows],
153
+ )
154
+
155
+ return (
156
+ <div className="flex min-h-0 flex-1 flex-col gap-4 pb-6">
157
+ <KeyMetrics
158
+ variant="flat"
159
+ metrics={kpi.metrics}
160
+ insight={kpi.insight}
161
+ showHeader={false}
162
+ metricsSingleRow
163
+ />
164
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
165
+ <ChartCard variant="normal" title="By item type" description="Filtered question set">
166
+ <QuestionsByTypeChart rows={rows} />
167
+ </ChartCard>
168
+ <ChartCard variant="normal" title="By topic" description="Up to eight topics">
169
+ <QuestionsByTopicChart rows={rows} />
170
+ </ChartCard>
171
+ </div>
172
+ </div>
173
+ )
174
+ }
@@ -0,0 +1,46 @@
1
+ "use client"
2
+
3
+ import type { QuestionBankItem } from "@/lib/mock/question-bank"
4
+ import { Button } from "@/components/ui/button"
5
+ import { Tip } from "@/components/ui/tip"
6
+ import { cn } from "@/lib/utils"
7
+ import { isQuestionBankItemFavorite } from "@/lib/question-bank-nav"
8
+
9
+ /** Parent must use this class so non-favorited stars show on row/cell hover (`group-hover/favcell`). */
10
+ export const QUESTION_BANK_FAVORITE_HOVER_GROUP = "group/favcell"
11
+
12
+ export function QuestionBankFavoriteButton({
13
+ row,
14
+ onToggleFavorite,
15
+ stopPropagation = true,
16
+ }: {
17
+ row: QuestionBankItem
18
+ onToggleFavorite: (row: QuestionBankItem) => void
19
+ stopPropagation?: boolean
20
+ }) {
21
+ const fav = isQuestionBankItemFavorite(row)
22
+ const label = fav ? "Remove from favorites" : "Add to favorites"
23
+ return (
24
+ <Tip side="top" label={label}>
25
+ <Button
26
+ type="button"
27
+ size="icon-sm"
28
+ variant="ghost"
29
+ aria-pressed={fav}
30
+ aria-label={label}
31
+ className={cn(
32
+ "shrink-0 rounded-md transition-opacity duration-150",
33
+ fav
34
+ ? "text-amber-600 opacity-100 hover:bg-amber-500/15 hover:text-amber-700"
35
+ : "text-muted-foreground opacity-0 hover:bg-muted hover:text-amber-600 group-hover/favcell:opacity-100 focus-visible:opacity-100",
36
+ )}
37
+ onClick={e => {
38
+ if (stopPropagation) e.stopPropagation()
39
+ onToggleFavorite(row)
40
+ }}
41
+ >
42
+ <i className={cn("text-sm", fav ? "fa-solid fa-star" : "fa-light fa-star")} aria-hidden />
43
+ </Button>
44
+ </Tip>
45
+ )
46
+ }