@exxatdesignux/ui 0.2.8 → 0.2.10
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 +17 -4
- 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/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 +0 -1
|
@@ -7,34 +7,61 @@
|
|
|
7
7
|
|
|
8
8
|
import * as React from "react"
|
|
9
9
|
import Link from "next/link"
|
|
10
|
-
import { usePathname, useSearchParams } from "next/navigation"
|
|
10
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
11
11
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
|
|
12
|
+
import { Button } from "@/components/ui/button"
|
|
13
|
+
import {
|
|
14
|
+
Dialog,
|
|
15
|
+
DialogContent,
|
|
16
|
+
DialogDescription,
|
|
17
|
+
DialogFooter,
|
|
18
|
+
DialogHeader,
|
|
19
|
+
DialogTitle,
|
|
20
|
+
} from "@/components/ui/dialog"
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuItem,
|
|
25
|
+
DropdownMenuSeparator,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
Shortcut,
|
|
28
|
+
} from "@/components/ui/dropdown-menu"
|
|
29
|
+
import { Kbd, KbdGroup } from "@/components/ui/kbd"
|
|
12
30
|
import { Tip } from "@/components/ui/tip"
|
|
13
31
|
import { cn } from "@/lib/utils"
|
|
14
32
|
import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
|
|
15
33
|
import { QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
|
|
16
|
-
import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
|
|
34
|
+
import { DEFAULT_QUESTION_BANK_FOLDERS, newFolderId, collectFolderDescendantIds } from "@/lib/mock/question-bank-folders"
|
|
17
35
|
import { useSecondaryPanel } from "@/components/secondary-panel"
|
|
36
|
+
import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
|
|
18
37
|
import {
|
|
38
|
+
isQuestionBankDedicatedSearchPathname,
|
|
19
39
|
parseQuestionBankNav,
|
|
20
|
-
|
|
40
|
+
QUESTION_BANK_FAVORITES_FOLDER_ID,
|
|
41
|
+
QUESTION_BANK_LIBRARY_HUB_PATHS,
|
|
42
|
+
isQuestionBankSearchNavActive,
|
|
43
|
+
questionBankFavoritesFolderHref,
|
|
44
|
+
questionBankHubScopeHref,
|
|
45
|
+
questionBankSearchLandingNavHref,
|
|
21
46
|
type QuestionBankNavScope,
|
|
22
47
|
} from "@/lib/question-bank-nav"
|
|
23
48
|
|
|
24
|
-
function matchesQuery(q: string, ...parts: (string | undefined)[]) {
|
|
25
|
-
if (!q) return true
|
|
26
|
-
return parts.some(p => p && p.toLowerCase().includes(q))
|
|
27
|
-
}
|
|
28
|
-
|
|
29
49
|
function isNavActive(
|
|
30
50
|
pathname: string,
|
|
31
51
|
nav: ReturnType<typeof parseQuestionBankNav>,
|
|
32
52
|
scope: QuestionBankNavScope,
|
|
33
53
|
folderId?: string | null,
|
|
34
54
|
) {
|
|
35
|
-
if (pathname
|
|
36
|
-
|
|
37
|
-
if (scope === "
|
|
55
|
+
if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(pathname)) return false
|
|
56
|
+
// Dedicated search shells (list / hub-find) use the “Search” row for All/My — not these rows.
|
|
57
|
+
if (scope === "all") {
|
|
58
|
+
if (isQuestionBankDedicatedSearchPathname(pathname)) return false
|
|
59
|
+
return nav.scope === "all"
|
|
60
|
+
}
|
|
61
|
+
if (scope === "my") {
|
|
62
|
+
if (isQuestionBankDedicatedSearchPathname(pathname)) return false
|
|
63
|
+
return nav.scope === "my"
|
|
64
|
+
}
|
|
38
65
|
if (scope === "folder" && folderId) {
|
|
39
66
|
return nav.scope === "folder" && nav.folderId === folderId
|
|
40
67
|
}
|
|
@@ -60,6 +87,7 @@ function NavRow({
|
|
|
60
87
|
<Tip label={label} side="right">
|
|
61
88
|
<Link
|
|
62
89
|
href={href}
|
|
90
|
+
scroll={false}
|
|
63
91
|
onClick={() => onClick?.()}
|
|
64
92
|
aria-current={active ? "page" : undefined}
|
|
65
93
|
className={cn(
|
|
@@ -80,43 +108,99 @@ function NavRow({
|
|
|
80
108
|
)
|
|
81
109
|
}
|
|
82
110
|
|
|
111
|
+
/** Icon-rail row — matches primary sidebar collapsed hit target (`size-9`). */
|
|
112
|
+
function IconNavRow({
|
|
113
|
+
href,
|
|
114
|
+
active,
|
|
115
|
+
iconClass,
|
|
116
|
+
label,
|
|
117
|
+
onClick,
|
|
118
|
+
}: {
|
|
119
|
+
href: string
|
|
120
|
+
active: boolean
|
|
121
|
+
iconClass: string
|
|
122
|
+
label: string
|
|
123
|
+
onClick?: () => void
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<li className="flex w-full justify-center" role="none">
|
|
127
|
+
<Tip label={label} side="right">
|
|
128
|
+
<Link
|
|
129
|
+
href={href}
|
|
130
|
+
scroll={false}
|
|
131
|
+
onClick={() => onClick?.()}
|
|
132
|
+
aria-current={active ? "page" : undefined}
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex size-9 shrink-0 items-center justify-center rounded-md transition-colors",
|
|
135
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
136
|
+
active
|
|
137
|
+
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
138
|
+
: "text-sidebar-foreground hover:bg-sidebar-accent/50",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
<span className="text-center text-[15px] leading-none" aria-hidden>
|
|
142
|
+
<i className={cn(active ? "fa-solid" : "fa-light", iconClass)} aria-hidden />
|
|
143
|
+
</span>
|
|
144
|
+
</Link>
|
|
145
|
+
</Tip>
|
|
146
|
+
</li>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
83
150
|
function PanelFolderBranch({
|
|
84
151
|
folder,
|
|
85
152
|
folders,
|
|
86
|
-
query,
|
|
87
153
|
depth,
|
|
88
154
|
pathname,
|
|
155
|
+
hubSearchParams,
|
|
89
156
|
nav,
|
|
157
|
+
canManageFolders,
|
|
158
|
+
canManageAccess,
|
|
159
|
+
onAddSubfolder,
|
|
160
|
+
onCustomizeFolder,
|
|
161
|
+
onManageAccess,
|
|
162
|
+
onDeleteFolder,
|
|
90
163
|
}: {
|
|
91
164
|
folder: QuestionBankFolder
|
|
92
165
|
folders: QuestionBankFolder[]
|
|
93
|
-
query: string
|
|
94
166
|
depth: number
|
|
95
167
|
pathname: string
|
|
168
|
+
hubSearchParams: URLSearchParams
|
|
96
169
|
nav: ReturnType<typeof parseQuestionBankNav>
|
|
170
|
+
canManageFolders: boolean
|
|
171
|
+
canManageAccess: boolean
|
|
172
|
+
onAddSubfolder: (parentId: string) => void
|
|
173
|
+
onCustomizeFolder: (folder: QuestionBankFolder) => void
|
|
174
|
+
onManageAccess: () => void
|
|
175
|
+
onDeleteFolder: (folder: QuestionBankFolder) => void
|
|
97
176
|
}) {
|
|
98
|
-
const childFolders =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
[childFolders, query],
|
|
177
|
+
const childFolders = React.useMemo(
|
|
178
|
+
() =>
|
|
179
|
+
folders
|
|
180
|
+
.filter(f => f.parentId === folder.id)
|
|
181
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
182
|
+
[folders, folder.id],
|
|
105
183
|
)
|
|
106
184
|
|
|
107
185
|
const hasSubfolders = childFolders.length > 0
|
|
108
186
|
const indent = depth * 10
|
|
109
187
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const folderHref = questionBankNavHref({ scope: "folder", folderId: folder.id })
|
|
188
|
+
const folderHref = questionBankHubScopeHref(pathname, hubSearchParams, {
|
|
189
|
+
scope: "folder",
|
|
190
|
+
folderId: folder.id,
|
|
191
|
+
})
|
|
115
192
|
const folderActive = isNavActive(pathname, nav, "folder", folder.id)
|
|
116
193
|
|
|
117
194
|
return (
|
|
118
195
|
<Collapsible defaultOpen={depth < 1} className="group">
|
|
119
|
-
<div
|
|
196
|
+
<div
|
|
197
|
+
className={cn(
|
|
198
|
+
"group/row flex min-w-0 items-center rounded-md px-2 transition-colors",
|
|
199
|
+
folderActive
|
|
200
|
+
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
201
|
+
: "hover:bg-sidebar-accent/50",
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
120
204
|
<div style={{ width: indent }} className="shrink-0" aria-hidden />
|
|
121
205
|
{hasSubfolders ? (
|
|
122
206
|
<CollapsibleTrigger asChild>
|
|
@@ -137,13 +221,12 @@ function PanelFolderBranch({
|
|
|
137
221
|
<Tip label={folder.name} side="right">
|
|
138
222
|
<Link
|
|
139
223
|
href={folderHref}
|
|
224
|
+
scroll={false}
|
|
140
225
|
aria-current={folderActive ? "page" : undefined}
|
|
141
226
|
className={cn(
|
|
142
|
-
"flex min-w-0 flex-1 items-center gap-2 py-1.5
|
|
143
|
-
"rounded-
|
|
144
|
-
folderActive
|
|
145
|
-
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
|
|
146
|
-
: "text-sidebar-foreground hover:bg-sidebar-accent/50",
|
|
227
|
+
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left text-sm transition-colors",
|
|
228
|
+
"rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
|
|
229
|
+
folderActive ? "font-medium" : "text-sidebar-foreground",
|
|
147
230
|
)}
|
|
148
231
|
>
|
|
149
232
|
<i
|
|
@@ -156,18 +239,79 @@ function PanelFolderBranch({
|
|
|
156
239
|
) : null}
|
|
157
240
|
</Link>
|
|
158
241
|
</Tip>
|
|
242
|
+
{canManageFolders ? (
|
|
243
|
+
<DropdownMenu>
|
|
244
|
+
<Tip label={`Folder actions for ${folder.name}`} side="right">
|
|
245
|
+
<DropdownMenuTrigger asChild>
|
|
246
|
+
<Button
|
|
247
|
+
type="button"
|
|
248
|
+
size="icon-xs"
|
|
249
|
+
variant="ghost"
|
|
250
|
+
aria-label={`Folder actions for ${folder.name}`}
|
|
251
|
+
className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100"
|
|
252
|
+
onClick={event => event.stopPropagation()}
|
|
253
|
+
>
|
|
254
|
+
<i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
|
|
255
|
+
</Button>
|
|
256
|
+
</DropdownMenuTrigger>
|
|
257
|
+
</Tip>
|
|
258
|
+
<DropdownMenuContent align="end">
|
|
259
|
+
<DropdownMenuItem
|
|
260
|
+
onSelect={() => {
|
|
261
|
+
window.setTimeout(() => onAddSubfolder(folder.id), 0)
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<i className="fa-light fa-plus text-xs" aria-hidden="true" />
|
|
265
|
+
Add folder
|
|
266
|
+
</DropdownMenuItem>
|
|
267
|
+
<DropdownMenuItem
|
|
268
|
+
onSelect={() => {
|
|
269
|
+
window.setTimeout(() => onCustomizeFolder(folder), 0)
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
|
|
273
|
+
Customize
|
|
274
|
+
</DropdownMenuItem>
|
|
275
|
+
<DropdownMenuItem
|
|
276
|
+
disabled={!canManageAccess}
|
|
277
|
+
onSelect={() => {
|
|
278
|
+
window.setTimeout(() => onManageAccess(), 0)
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<i className="fa-light fa-user-gear text-xs" aria-hidden="true" />
|
|
282
|
+
Manage access
|
|
283
|
+
</DropdownMenuItem>
|
|
284
|
+
<DropdownMenuSeparator />
|
|
285
|
+
<DropdownMenuItem
|
|
286
|
+
variant="destructive"
|
|
287
|
+
onSelect={() => {
|
|
288
|
+
window.setTimeout(() => onDeleteFolder(folder), 0)
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
<i className="fa-light fa-trash text-xs" aria-hidden="true" />
|
|
292
|
+
Delete
|
|
293
|
+
</DropdownMenuItem>
|
|
294
|
+
</DropdownMenuContent>
|
|
295
|
+
</DropdownMenu>
|
|
296
|
+
) : null}
|
|
159
297
|
</div>
|
|
160
298
|
{hasSubfolders ? (
|
|
161
299
|
<CollapsibleContent>
|
|
162
|
-
{
|
|
300
|
+
{childFolders.map(child => (
|
|
163
301
|
<PanelFolderBranch
|
|
164
302
|
key={child.id}
|
|
165
303
|
folder={child}
|
|
166
304
|
folders={folders}
|
|
167
|
-
query={query}
|
|
168
305
|
depth={depth + 1}
|
|
169
306
|
pathname={pathname}
|
|
307
|
+
hubSearchParams={hubSearchParams}
|
|
170
308
|
nav={nav}
|
|
309
|
+
canManageFolders={canManageFolders}
|
|
310
|
+
canManageAccess={canManageAccess}
|
|
311
|
+
onAddSubfolder={onAddSubfolder}
|
|
312
|
+
onCustomizeFolder={onCustomizeFolder}
|
|
313
|
+
onManageAccess={onManageAccess}
|
|
314
|
+
onDeleteFolder={onDeleteFolder}
|
|
171
315
|
/>
|
|
172
316
|
))}
|
|
173
317
|
</CollapsibleContent>
|
|
@@ -176,51 +320,365 @@ function PanelFolderBranch({
|
|
|
176
320
|
)
|
|
177
321
|
}
|
|
178
322
|
|
|
179
|
-
export function QuestionBankSecondaryNav(
|
|
323
|
+
export function QuestionBankSecondaryNav() {
|
|
180
324
|
const pathname = usePathname()
|
|
325
|
+
const router = useRouter()
|
|
181
326
|
const searchParams = useSearchParams()
|
|
182
327
|
const searchParamsKey = searchParams.toString()
|
|
183
|
-
const { openPanel } =
|
|
328
|
+
const { openPanel, questionBankFolderBridge, questionBankAccessBridge, secondaryPanelCompact } =
|
|
329
|
+
useSecondaryPanel()
|
|
330
|
+
const [newFolderOpen, setNewFolderOpen] = React.useState(false)
|
|
331
|
+
const [newFolderParentId, setNewFolderParentId] = React.useState<string | null>(null)
|
|
332
|
+
const [customizingFolder, setCustomizingFolder] = React.useState<QuestionBankFolder | null>(null)
|
|
333
|
+
const [deleteFolder, setDeleteFolder] = React.useState<QuestionBankFolder | null>(null)
|
|
184
334
|
const nav = React.useMemo(
|
|
185
335
|
() => parseQuestionBankNav(new URLSearchParams(searchParamsKey)),
|
|
186
336
|
[searchParamsKey],
|
|
187
337
|
)
|
|
188
338
|
|
|
189
|
-
const folders = DEFAULT_QUESTION_BANK_FOLDERS
|
|
190
|
-
const
|
|
339
|
+
const folders = questionBankFolderBridge?.folders ?? DEFAULT_QUESTION_BANK_FOLDERS
|
|
340
|
+
const canManageFolders = questionBankFolderBridge != null
|
|
341
|
+
const canManageAccess = questionBankAccessBridge != null
|
|
191
342
|
|
|
192
|
-
|
|
193
|
-
|
|
343
|
+
/** Favorites is a primary nav row (with All / My / Search), not under “Folders”. */
|
|
344
|
+
const folderTreeRoots = React.useMemo(
|
|
345
|
+
() =>
|
|
346
|
+
folders
|
|
347
|
+
.filter(f => f.parentId === null && f.id !== QUESTION_BANK_FAVORITES_FOLDER_ID)
|
|
348
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
194
349
|
[folders],
|
|
195
350
|
)
|
|
196
351
|
|
|
352
|
+
const openTopLevelFolder = React.useCallback(() => {
|
|
353
|
+
setCustomizingFolder(null)
|
|
354
|
+
setNewFolderParentId(nav.scope === "folder" ? nav.folderId : null)
|
|
355
|
+
setNewFolderOpen(true)
|
|
356
|
+
}, [nav.folderId, nav.scope])
|
|
357
|
+
|
|
358
|
+
const openSubfolder = React.useCallback((parentId: string) => {
|
|
359
|
+
setCustomizingFolder(null)
|
|
360
|
+
setNewFolderParentId(parentId)
|
|
361
|
+
setNewFolderOpen(true)
|
|
362
|
+
}, [])
|
|
363
|
+
|
|
364
|
+
const openCustomizeFolder = React.useCallback((folder: QuestionBankFolder) => {
|
|
365
|
+
setCustomizingFolder(folder)
|
|
366
|
+
setNewFolderParentId(folder.parentId)
|
|
367
|
+
setNewFolderOpen(true)
|
|
368
|
+
}, [])
|
|
369
|
+
|
|
370
|
+
const openManageAccess = React.useCallback(() => {
|
|
371
|
+
questionBankAccessBridge?.openManageAccess()
|
|
372
|
+
}, [questionBankAccessBridge])
|
|
373
|
+
|
|
374
|
+
const openDeleteFolder = React.useCallback((folder: QuestionBankFolder) => {
|
|
375
|
+
setDeleteFolder(folder)
|
|
376
|
+
}, [])
|
|
377
|
+
|
|
378
|
+
const commitDeleteFolder = React.useCallback(() => {
|
|
379
|
+
if (!deleteFolder || !questionBankFolderBridge) return
|
|
380
|
+
const victim = deleteFolder
|
|
381
|
+
const parent = victim.parentId
|
|
382
|
+
const desc = collectFolderDescendantIds(folders, victim.id)
|
|
383
|
+
const remaining = folders.filter(f => !desc.has(f.id))
|
|
384
|
+
if (remaining.length === 0) {
|
|
385
|
+
setDeleteFolder(null)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
const parentStillExists = parent !== null && remaining.some(f => f.id === parent)
|
|
389
|
+
const fallbackRoot = remaining.find(f => f.parentId === null)?.id
|
|
390
|
+
const reassignTarget =
|
|
391
|
+
parentStillExists ? parent : (fallbackRoot ?? remaining[0]!.id)
|
|
392
|
+
|
|
393
|
+
questionBankFolderBridge.onFoldersChange(remaining)
|
|
394
|
+
questionBankFolderBridge.onItemsChange(prev =>
|
|
395
|
+
prev.map(item => (desc.has(item.folderId) ? { ...item, folderId: reassignTarget } : item)),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if (nav.scope === "folder" && nav.folderId && desc.has(nav.folderId)) {
|
|
399
|
+
router.replace(
|
|
400
|
+
questionBankHubScopeHref(
|
|
401
|
+
pathname,
|
|
402
|
+
new URLSearchParams(searchParamsKey),
|
|
403
|
+
parentStillExists
|
|
404
|
+
? { scope: "folder", folderId: parent! }
|
|
405
|
+
: { scope: "all" },
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
setDeleteFolder(null)
|
|
411
|
+
}, [deleteFolder, folders, nav.folderId, nav.scope, pathname, questionBankFolderBridge, router, searchParamsKey])
|
|
412
|
+
|
|
413
|
+
const sheetParentId = customizingFolder?.parentId ?? newFolderParentId
|
|
414
|
+
|
|
415
|
+
const flattenedFolderLinks = React.useMemo(() => {
|
|
416
|
+
const out: QuestionBankFolder[] = []
|
|
417
|
+
const walk = (folder: QuestionBankFolder) => {
|
|
418
|
+
out.push(folder)
|
|
419
|
+
folders
|
|
420
|
+
.filter(c => c.parentId === folder.id)
|
|
421
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
422
|
+
.forEach(walk)
|
|
423
|
+
}
|
|
424
|
+
folderTreeRoots.forEach(walk)
|
|
425
|
+
return out
|
|
426
|
+
}, [folderTreeRoots, folders])
|
|
427
|
+
|
|
428
|
+
const hubNavModals = (
|
|
429
|
+
<>
|
|
430
|
+
<QuestionBankNewFolderSheet
|
|
431
|
+
open={newFolderOpen}
|
|
432
|
+
onOpenChange={open => {
|
|
433
|
+
setNewFolderOpen(open)
|
|
434
|
+
if (!open) {
|
|
435
|
+
setCustomizingFolder(null)
|
|
436
|
+
setNewFolderParentId(null)
|
|
437
|
+
}
|
|
438
|
+
}}
|
|
439
|
+
parentFolderId={sheetParentId}
|
|
440
|
+
customizingFolder={customizingFolder}
|
|
441
|
+
descriptionText={
|
|
442
|
+
customizingFolder
|
|
443
|
+
? "Update the folder name, color, and icon shown in the navigation and folder views."
|
|
444
|
+
: sheetParentId
|
|
445
|
+
? "The folder is created inside the folder selected in the navigation."
|
|
446
|
+
: "Add a top-level folder to the question bank."
|
|
447
|
+
}
|
|
448
|
+
onCreated={folder => {
|
|
449
|
+
if (customizingFolder) {
|
|
450
|
+
questionBankFolderBridge?.onFoldersChange(prev =>
|
|
451
|
+
prev.map(item =>
|
|
452
|
+
item.id === customizingFolder.id
|
|
453
|
+
? {
|
|
454
|
+
...item,
|
|
455
|
+
name: folder.name,
|
|
456
|
+
icon: folder.icon,
|
|
457
|
+
colorKey: folder.colorKey,
|
|
458
|
+
}
|
|
459
|
+
: item,
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
} else {
|
|
463
|
+
questionBankFolderBridge?.onFoldersChange(prev => [...prev, { ...folder, id: newFolderId() }])
|
|
464
|
+
}
|
|
465
|
+
setNewFolderOpen(false)
|
|
466
|
+
setCustomizingFolder(null)
|
|
467
|
+
setNewFolderParentId(null)
|
|
468
|
+
}}
|
|
469
|
+
/>
|
|
470
|
+
{deleteFolder ? <Shortcut keys="Enter" onInvoke={commitDeleteFolder} /> : null}
|
|
471
|
+
<Dialog open={deleteFolder != null} onOpenChange={open => !open && setDeleteFolder(null)}>
|
|
472
|
+
<DialogContent className="max-w-sm">
|
|
473
|
+
<DialogHeader>
|
|
474
|
+
<DialogTitle>Delete folder?</DialogTitle>
|
|
475
|
+
<DialogDescription>
|
|
476
|
+
{deleteFolder
|
|
477
|
+
? `${deleteFolder.name} and its subfolders will be removed. Questions inside move to the parent folder (or the first top-level folder).`
|
|
478
|
+
: null}
|
|
479
|
+
</DialogDescription>
|
|
480
|
+
</DialogHeader>
|
|
481
|
+
<DialogFooter className="gap-2 sm:gap-0">
|
|
482
|
+
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteFolder(null)}>
|
|
483
|
+
Cancel
|
|
484
|
+
<KbdGroup className="ml-1.5">
|
|
485
|
+
<Kbd variant="bare">Esc</Kbd>
|
|
486
|
+
</KbdGroup>
|
|
487
|
+
</Button>
|
|
488
|
+
<Button type="button" variant="destructive" size="sm" onClick={commitDeleteFolder}>
|
|
489
|
+
Delete
|
|
490
|
+
<KbdGroup className="ml-1.5">
|
|
491
|
+
<Kbd variant="bare">⏎</Kbd>
|
|
492
|
+
</KbdGroup>
|
|
493
|
+
</Button>
|
|
494
|
+
</DialogFooter>
|
|
495
|
+
</DialogContent>
|
|
496
|
+
</Dialog>
|
|
497
|
+
</>
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if (secondaryPanelCompact) {
|
|
501
|
+
return (
|
|
502
|
+
<>
|
|
503
|
+
<nav className="flex min-h-0 flex-1 flex-col" role="navigation" aria-label="Question bank">
|
|
504
|
+
<div className="flex flex-col items-center border-b border-sidebar-border/60 px-1 py-2">
|
|
505
|
+
<Tip label="Show labels" side="right">
|
|
506
|
+
<Button
|
|
507
|
+
type="button"
|
|
508
|
+
size="icon"
|
|
509
|
+
variant="ghost"
|
|
510
|
+
className="size-9 shrink-0"
|
|
511
|
+
aria-label="Show labels"
|
|
512
|
+
onClick={() => openPanel("question-bank")}
|
|
513
|
+
>
|
|
514
|
+
<i className="fa-light fa-angles-right text-[15px]" aria-hidden="true" />
|
|
515
|
+
</Button>
|
|
516
|
+
</Tip>
|
|
517
|
+
</div>
|
|
518
|
+
<ul className="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-1 py-2" role="list">
|
|
519
|
+
<IconNavRow
|
|
520
|
+
href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
|
|
521
|
+
active={isNavActive(pathname, nav, "all")}
|
|
522
|
+
iconClass="fa-table-list"
|
|
523
|
+
label="All questions"
|
|
524
|
+
onClick={() => openPanel("question-bank")}
|
|
525
|
+
/>
|
|
526
|
+
<IconNavRow
|
|
527
|
+
href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
|
|
528
|
+
active={isNavActive(pathname, nav, "my")}
|
|
529
|
+
iconClass="fa-user"
|
|
530
|
+
label="My questions"
|
|
531
|
+
onClick={() => openPanel("question-bank")}
|
|
532
|
+
/>
|
|
533
|
+
<IconNavRow
|
|
534
|
+
href={questionBankFavoritesFolderHref(pathname, searchParams)}
|
|
535
|
+
active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
|
|
536
|
+
iconClass="fa-star"
|
|
537
|
+
label="Favorites"
|
|
538
|
+
onClick={() => openPanel("question-bank")}
|
|
539
|
+
/>
|
|
540
|
+
<IconNavRow
|
|
541
|
+
href={questionBankSearchLandingNavHref(nav, searchParams)}
|
|
542
|
+
active={isQuestionBankSearchNavActive(pathname, nav)}
|
|
543
|
+
iconClass="fa-magnifying-glass"
|
|
544
|
+
label="Search"
|
|
545
|
+
onClick={() => openPanel("question-bank")}
|
|
546
|
+
/>
|
|
547
|
+
<li className="flex w-full justify-center pt-1" role="none">
|
|
548
|
+
<DropdownMenu>
|
|
549
|
+
<DropdownMenuTrigger asChild>
|
|
550
|
+
<Button
|
|
551
|
+
type="button"
|
|
552
|
+
size="icon"
|
|
553
|
+
variant="ghost"
|
|
554
|
+
className="size-9 shrink-0 text-sidebar-foreground"
|
|
555
|
+
aria-label="Folders"
|
|
556
|
+
>
|
|
557
|
+
<i className="fa-light fa-folder-tree text-[15px]" aria-hidden="true" />
|
|
558
|
+
</Button>
|
|
559
|
+
</DropdownMenuTrigger>
|
|
560
|
+
<DropdownMenuContent side="right" align="start" className="max-h-72 overflow-y-auto">
|
|
561
|
+
{flattenedFolderLinks.length === 0 ? (
|
|
562
|
+
<div className="px-2 py-1.5 text-xs text-muted-foreground">No folders</div>
|
|
563
|
+
) : (
|
|
564
|
+
flattenedFolderLinks.map(folder => {
|
|
565
|
+
const href = questionBankHubScopeHref(pathname, searchParams, {
|
|
566
|
+
scope: "folder",
|
|
567
|
+
folderId: folder.id,
|
|
568
|
+
})
|
|
569
|
+
const active = isNavActive(pathname, nav, "folder", folder.id)
|
|
570
|
+
return (
|
|
571
|
+
<DropdownMenuItem key={folder.id} asChild>
|
|
572
|
+
<Link
|
|
573
|
+
href={href}
|
|
574
|
+
scroll={false}
|
|
575
|
+
className={cn(active && "bg-accent")}
|
|
576
|
+
onClick={() => openPanel("question-bank")}
|
|
577
|
+
>
|
|
578
|
+
{folder.name}
|
|
579
|
+
</Link>
|
|
580
|
+
</DropdownMenuItem>
|
|
581
|
+
)
|
|
582
|
+
})
|
|
583
|
+
)}
|
|
584
|
+
</DropdownMenuContent>
|
|
585
|
+
</DropdownMenu>
|
|
586
|
+
</li>
|
|
587
|
+
</ul>
|
|
588
|
+
{canManageFolders ? (
|
|
589
|
+
<div className="flex flex-col items-center border-t border-sidebar-border/60 px-1 py-2">
|
|
590
|
+
<Tip label="Add folder" side="right">
|
|
591
|
+
<Button
|
|
592
|
+
type="button"
|
|
593
|
+
size="icon"
|
|
594
|
+
variant="ghost"
|
|
595
|
+
className="size-9 shrink-0 text-muted-foreground"
|
|
596
|
+
aria-label="Add folder"
|
|
597
|
+
onClick={openTopLevelFolder}
|
|
598
|
+
>
|
|
599
|
+
<i className="fa-light fa-plus text-[15px]" aria-hidden="true" />
|
|
600
|
+
</Button>
|
|
601
|
+
</Tip>
|
|
602
|
+
</div>
|
|
603
|
+
) : null}
|
|
604
|
+
</nav>
|
|
605
|
+
{hubNavModals}
|
|
606
|
+
</>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
197
610
|
return (
|
|
198
|
-
|
|
199
|
-
<
|
|
200
|
-
<
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
611
|
+
<>
|
|
612
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-4" role="navigation" aria-label="Question bank">
|
|
613
|
+
<ul className="space-y-0.5" role="list">
|
|
614
|
+
<NavRow
|
|
615
|
+
href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
|
|
616
|
+
active={isNavActive(pathname, nav, "all")}
|
|
617
|
+
iconClass="fa-table-list"
|
|
618
|
+
label="All questions"
|
|
619
|
+
onClick={() => openPanel("question-bank")}
|
|
620
|
+
/>
|
|
621
|
+
<NavRow
|
|
622
|
+
href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
|
|
623
|
+
active={isNavActive(pathname, nav, "my")}
|
|
624
|
+
iconClass="fa-user"
|
|
625
|
+
label="My questions"
|
|
626
|
+
/>
|
|
627
|
+
<NavRow
|
|
628
|
+
href={questionBankFavoritesFolderHref(pathname, searchParams)}
|
|
629
|
+
active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
|
|
630
|
+
iconClass="fa-star"
|
|
631
|
+
label="Favorites"
|
|
632
|
+
onClick={() => openPanel("question-bank")}
|
|
633
|
+
/>
|
|
634
|
+
<NavRow
|
|
635
|
+
href={questionBankSearchLandingNavHref(nav, searchParams)}
|
|
636
|
+
active={isQuestionBankSearchNavActive(pathname, nav)}
|
|
637
|
+
iconClass="fa-magnifying-glass"
|
|
638
|
+
label="Search"
|
|
639
|
+
onClick={() => openPanel("question-bank")}
|
|
640
|
+
/>
|
|
641
|
+
<li role="presentation" className="select-none">
|
|
642
|
+
<div className="flex items-center justify-between gap-2 px-2 pt-3 pb-1">
|
|
643
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
644
|
+
Folders
|
|
645
|
+
</span>
|
|
646
|
+
<Tip label="Add folder" side="right">
|
|
647
|
+
<Button
|
|
648
|
+
type="button"
|
|
649
|
+
size="icon-xs"
|
|
650
|
+
variant="ghost"
|
|
651
|
+
className="text-muted-foreground"
|
|
652
|
+
aria-label="Add folder"
|
|
653
|
+
disabled={!canManageFolders}
|
|
654
|
+
onClick={openTopLevelFolder}
|
|
655
|
+
>
|
|
656
|
+
<i className="fa-light fa-plus" aria-hidden="true" />
|
|
657
|
+
</Button>
|
|
658
|
+
</Tip>
|
|
659
|
+
</div>
|
|
221
660
|
</li>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
661
|
+
{folderTreeRoots.map(folder => (
|
|
662
|
+
<li key={folder.id} className="min-w-0">
|
|
663
|
+
<PanelFolderBranch
|
|
664
|
+
folder={folder}
|
|
665
|
+
folders={folders}
|
|
666
|
+
depth={0}
|
|
667
|
+
pathname={pathname}
|
|
668
|
+
hubSearchParams={searchParams}
|
|
669
|
+
nav={nav}
|
|
670
|
+
canManageFolders={canManageFolders}
|
|
671
|
+
canManageAccess={canManageAccess}
|
|
672
|
+
onAddSubfolder={openSubfolder}
|
|
673
|
+
onCustomizeFolder={openCustomizeFolder}
|
|
674
|
+
onManageAccess={openManageAccess}
|
|
675
|
+
onDeleteFolder={openDeleteFolder}
|
|
676
|
+
/>
|
|
677
|
+
</li>
|
|
678
|
+
))}
|
|
679
|
+
</ul>
|
|
680
|
+
</div>
|
|
681
|
+
{hubNavModals}
|
|
682
|
+
</>
|
|
225
683
|
)
|
|
226
684
|
}
|