@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.
- package/consumer-extras/cursor-skills/exxat-card-vs-list-rows/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +33 -0
- package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +45 -0
- package/consumer-extras/cursor-skills/exxat-drawer-vs-dialog/SKILL.md +20 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +31 -5
- package/consumer-extras/cursor-skills/exxat-kpi-max-four/SKILL.md +19 -0
- package/consumer-extras/cursor-skills/exxat-kpi-trends/SKILL.md +27 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +1 -0
- package/consumer-extras/patterns/collaboration-access-pattern.md +114 -0
- package/consumer-extras/patterns/data-views-pattern.md +12 -4
- package/package.json +4 -1
- package/src/components/ui/banner.tsx +20 -7
- package/src/components/ui/date-picker-field.tsx +3 -3
- package/src/components/ui/dropdown-menu.tsx +17 -6
- package/src/components/ui/input-group.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/select.tsx +1 -1
- package/src/components/ui/separator.tsx +2 -2
- package/src/components/ui/sidebar.tsx +31 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/globals.css +0 -1
- package/src/index.ts +1 -0
- package/src/lib/date-filter.ts +13 -4
- package/src/lib/dropdown-menu-surface.ts +13 -0
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +27 -9
- package/template/.cursor/rules/exxat-data-tables.mdc +1 -0
- package/template/.nvmrc +1 -1
- package/template/AGENTS.md +82 -27
- package/template/app/(app)/examples/page.tsx +2 -1
- package/template/app/(app)/help/page.tsx +6 -0
- package/template/app/(app)/layout.tsx +7 -4
- package/template/app/(app)/question-bank/find/page.tsx +12 -0
- package/template/app/(app)/question-bank/layout.tsx +46 -0
- package/template/app/(app)/question-bank/library/page.tsx +11 -0
- package/template/app/(app)/question-bank/list/page.tsx +12 -0
- package/template/app/(app)/question-bank/page.tsx +4 -3
- package/template/app/globals.css +1 -2
- package/template/components/app-sidebar.tsx +51 -13
- package/template/components/ask-leo-composer.tsx +173 -45
- package/template/components/ask-leo-sidebar.tsx +9 -1
- package/template/components/chart-area-interactive.tsx +3 -13
- package/template/components/charts-overview.tsx +33 -6
- package/template/components/collaboration-access-flow.tsx +144 -0
- package/template/components/compliance-page-header.tsx +1 -1
- package/template/components/compliance-table.tsx +2 -2
- package/template/components/dashboard-tabs.tsx +4 -3
- package/template/components/data-list-table-cells.tsx +1 -1
- package/template/components/data-list-table.tsx +1 -1
- package/template/components/data-table/index.tsx +5 -5
- package/template/components/data-table/use-table-state.ts +18 -2
- package/template/components/data-view-dashboard-charts-compliance.tsx +8 -5
- package/template/components/data-view-dashboard-charts-team.tsx +8 -5
- package/template/components/data-view-dashboard-charts.tsx +62 -227
- package/template/components/dedicated-search-recents.tsx +96 -0
- package/template/components/dedicated-search-url-composer.tsx +112 -0
- package/template/components/getting-started.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +10 -26
- package/template/components/invite-collaborators-drawer.tsx +453 -0
- package/template/components/key-metrics.tsx +54 -8
- package/template/components/nav-documents.tsx +1 -1
- package/template/components/new-placement-form.tsx +3 -3
- package/template/components/page-header.tsx +76 -59
- package/template/components/placements-board-view.tsx +3 -3
- package/template/components/placements-page-header.tsx +1 -1
- package/template/components/placements-table-columns.tsx +3 -2
- package/template/components/product-switcher.tsx +0 -1
- package/template/components/question-bank-board-view.tsx +35 -47
- package/template/components/question-bank-client.tsx +293 -81
- package/template/components/question-bank-dashboard-charts.tsx +174 -0
- package/template/components/question-bank-favorite-button.tsx +46 -0
- package/template/components/question-bank-hub-client.tsx +436 -0
- package/template/components/question-bank-list-view.tsx +26 -19
- package/template/components/question-bank-new-folder-sheet.tsx +56 -42
- package/template/components/question-bank-os-folder-view.tsx +3 -14
- package/template/components/question-bank-page-header.tsx +85 -53
- package/template/components/question-bank-panel-activator.tsx +3 -4
- package/template/components/question-bank-secondary-nav.tsx +523 -65
- package/template/components/question-bank-table.tsx +125 -343
- package/template/components/secondary-panel.tsx +130 -63
- package/template/components/settings-client.tsx +3 -1
- package/template/components/sidebar-shell.tsx +2 -0
- package/template/components/sites-all-client.tsx +1 -1
- package/template/components/sites-table.tsx +1 -1
- package/template/components/system-banner-slot.tsx +2 -1
- package/template/components/table-properties/drawer.tsx +3 -3
- package/template/components/table-properties/sort-card.tsx +1 -1
- package/template/components/team-page-header.tsx +1 -1
- package/template/components/team-table.tsx +8 -4
- package/template/components/templates/dedicated-search-landing-template.tsx +58 -0
- package/template/components/templates/dedicated-search-results-template.tsx +19 -0
- package/template/components/templates/discovery-hub-template.tsx +273 -0
- package/template/components/templates/list-page.tsx +11 -4
- package/template/components/templates/nested-secondary-panel-shell.tsx +57 -0
- package/template/components/templates/secondary-panel-hub-template.tsx +54 -0
- package/template/docs/card-vs-rows-pattern.md +36 -0
- package/template/docs/collaboration-access-pattern.md +114 -0
- package/template/docs/data-views-pattern.md +12 -4
- package/template/docs/drawer-vs-dialog-pattern.md +50 -0
- package/template/docs/kpi-strip-max-four-pattern.md +29 -0
- package/template/docs/kpi-trend-pattern.md +43 -0
- package/template/fontawesome-subset.manifest.json +2 -2
- package/template/hooks/use-location-hash.ts +14 -8
- package/template/hooks/use-secondary-panel-hub-nav.ts +98 -0
- package/template/lib/ask-leo-route-context.ts +24 -0
- package/template/lib/collaborator-access.ts +92 -0
- package/template/lib/command-menu-config.ts +8 -1
- package/template/lib/command-menu-search-data.ts +11 -8
- package/template/lib/data-list-display-options.ts +1 -1
- package/template/lib/data-view-dashboard-placements-layout.ts +215 -0
- package/template/lib/date-filter.ts +1 -0
- package/template/lib/dedicated-search-recents.ts +76 -0
- package/template/lib/dedicated-search-url.ts +23 -0
- package/template/lib/discovery-hub.ts +15 -0
- package/template/lib/list-status-badges.ts +1 -21
- package/template/lib/mock/navigation.tsx +4 -2
- package/template/lib/mock/placements.ts +9 -9
- package/template/lib/mock/question-bank-folders.ts +7 -0
- package/template/lib/mock/question-bank-header-collaborators.ts +45 -5
- package/template/lib/mock/question-bank-inspector.ts +1 -2
- package/template/lib/mock/question-bank-kpi.ts +38 -26
- package/template/lib/mock/question-bank.ts +43 -16
- package/template/lib/question-bank-dedicated-search.ts +19 -0
- package/template/lib/question-bank-hub-search.ts +90 -0
- package/template/lib/question-bank-nav.ts +322 -6
- package/template/lib/question-bank-recent-searches.ts +22 -0
- package/template/package.json +1 -2
|
@@ -6,27 +6,51 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as React from "react"
|
|
9
|
-
import {
|
|
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 {
|
|
20
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
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
|
-
|
|
79
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
156
|
+
if (!isHubPath) return
|
|
108
157
|
const prefix = questionBankQueryPrefixFromSearchString(searchParamsKey)
|
|
109
158
|
if (id === "questions") {
|
|
110
|
-
router.replace(
|
|
159
|
+
router.replace(`${hubBasePath}${prefix}`, { scroll: false })
|
|
111
160
|
} else {
|
|
112
|
-
router.replace(
|
|
161
|
+
router.replace(`${hubBasePath}${prefix}#${id}`, { scroll: false })
|
|
113
162
|
}
|
|
114
163
|
},
|
|
115
|
-
[
|
|
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
|
-
() =>
|
|
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
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
title: hubHeader.title,
|
|
144
|
-
breadcrumbs: hubHeader.breadcrumbs,
|
|
145
|
-
}}
|
|
329
|
+
<CollaborationAccessFlow
|
|
330
|
+
initialCollaborators={QUESTION_BANK_HEADER_COLLABORATORS}
|
|
331
|
+
resourceLabel={hubHeader.title}
|
|
146
332
|
>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
</
|
|
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
|
+
}
|