@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
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Drawer vs dialog vs route
|
|
2
|
+
|
|
3
|
+
> **Related:** `docs/data-views-pattern.md` (Page vs drawer), **`AGENTS.md` §6.4**, **`.cursor/rules/exxat-page-vs-drawer.mdc`**. **This doc** splits **drawer/sheet** vs **modal dialog** when both stay on the same route.
|
|
4
|
+
|
|
5
|
+
## Drawer / sheet (side panel)
|
|
6
|
+
|
|
7
|
+
**Use when:**
|
|
8
|
+
|
|
9
|
+
- The **list or hub behind the panel** still matters (user compares, copies, or dismisses and continues browsing).
|
|
10
|
+
- The flow is **medium length** — export options, table/column properties, invite collaborators, filters that mirror the grid.
|
|
11
|
+
- **Width** helps — tables of options, multi-field forms that would feel cramped in a narrow dialog.
|
|
12
|
+
|
|
13
|
+
**Examples in repo:** `TablePropertiesDrawer`, `ExportDrawer`, `InviteCollaboratorsDrawer`, `Drawer` shells that slide from the edge.
|
|
14
|
+
|
|
15
|
+
**Avoid when:** The task is the **only** thing the user should focus on and the parent would distract (prefer **dialog** for a sharp confirm, or **route** for a full wizard).
|
|
16
|
+
|
|
17
|
+
## Dialog (modal)
|
|
18
|
+
|
|
19
|
+
**Use when:**
|
|
20
|
+
|
|
21
|
+
- You need a **hard stop** — user must answer or dismiss before interacting with the page again (confirm delete, acknowledge legal, pick a single blocking choice).
|
|
22
|
+
- The content is **short and focused** — one decision, one form step, or a compact message with primary/secondary actions.
|
|
23
|
+
- **Destructive or irreversible** actions — pair with clear copy; **Esc** / Cancel returns safely.
|
|
24
|
+
|
|
25
|
+
**Examples:** `AlertDialog`, confirm-before-remove, “Save changes?” when navigating away.
|
|
26
|
+
|
|
27
|
+
**Avoid when:** Users need to **reference** the grid or copy values from the page while the panel is open — use a **drawer** or **inline** pattern instead.
|
|
28
|
+
|
|
29
|
+
## Route (new page)
|
|
30
|
+
|
|
31
|
+
Use when the work is **primary**, **long**, **multi-step**, or deserves its **own URL** — see **`exxat-page-vs-drawer.mdc`** and **`AGENTS.md` §6.4**.
|
|
32
|
+
|
|
33
|
+
## Quick matrix
|
|
34
|
+
|
|
35
|
+
| Need | Drawer | Dialog | Route |
|
|
36
|
+
| --- | --- | --- | --- |
|
|
37
|
+
| Keep hub visible | Yes | No (blocks) | No |
|
|
38
|
+
| Short confirm / alert | Rare | Yes | Overkill |
|
|
39
|
+
| Long form / wizard | Cramped | No | Yes |
|
|
40
|
+
| Properties tied to a table | Yes | Too small | Optional |
|
|
41
|
+
|
|
42
|
+
## Accessibility
|
|
43
|
+
|
|
44
|
+
- **Dialog / drawer / sheet:** Must expose a **title** (`DialogTitle`, `SheetTitle`, `DrawerTitle`) — use `sr-only` if visually hidden.
|
|
45
|
+
- **Focus trap** is expected in dialogs; drawers should still **restore focus** on close to the invoking control.
|
|
46
|
+
|
|
47
|
+
## See also
|
|
48
|
+
|
|
49
|
+
- **`.cursor/rules/exxat-drawer-vs-dialog.mdc`**, **`.cursor/skills/exxat-drawer-vs-dialog/SKILL.md`**
|
|
50
|
+
- **`exxat-no-toast.mdc`** — use dialog/banner/inline, not toasts, for outcomes that need acknowledgment.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# KPI strip — maximum four tiles
|
|
2
|
+
|
|
3
|
+
> **Code:** `lib/dashboard-layout-merge.ts` — **`KEY_METRICS_KPI_COUNT_MIN`**, **`KEY_METRICS_KPI_COUNT_MAX`** (4), **`clampKeyMetricsKpiCount`**. **Component:** `KeyMetrics` in `components/key-metrics.tsx`.
|
|
4
|
+
|
|
5
|
+
## Rule
|
|
6
|
+
|
|
7
|
+
On **primary list hubs** (`ListPageTemplate` metrics slot) and on **dashboard “key metrics” cards** (Data tab chart bundles), **show at most four** `MetricItem` tiles at once.
|
|
8
|
+
|
|
9
|
+
## Why four
|
|
10
|
+
|
|
11
|
+
- **Scanning** — More than four headline numbers compete; users miss deltas and period context.
|
|
12
|
+
- **Layout** — `KeyMetrics` wraps to multiple rows; four keeps one or two clean rows on common breakpoints (including **`metricsHalfWidthLayout`** on span-1 cards).
|
|
13
|
+
- **Persistence** — Dashboard layout already stores **`keyMetricsKpiCount`** in **`1…4`**; list-page KPI helpers should **not** return a fifth tile expecting it to display.
|
|
14
|
+
|
|
15
|
+
## Implementation
|
|
16
|
+
|
|
17
|
+
1. **KPI builders** (`lib/mock/*-kpi.ts`) — Return **≤ 4** items, or **`.slice(0, 4)`** after prioritizing (hero total + top three drivers). Merge extras into **`MetricInsight`** copy instead of a fifth tile when possible.
|
|
18
|
+
2. **Dashboard canvas** — Never raise **`KEY_METRICS_KPI_COUNT_MAX`**; use **`clampKeyMetricsKpiCount`** when reading saved JSON.
|
|
19
|
+
3. **Full-page dashboards** — If more summaries are needed, add **sections** (charts, tables, secondary cards), not a fifth KPI in the same strip.
|
|
20
|
+
|
|
21
|
+
## MUST NOT
|
|
22
|
+
|
|
23
|
+
- Ship **five+** `MetricItem` entries in a single **`KeyMetrics`** band meant as the **primary** KPI row for a hub or the **key-metrics** dashboard card.
|
|
24
|
+
- Duplicate the same metric as two tiles to pad count — prefer **insight rail** or **`MetricInsight`**.
|
|
25
|
+
|
|
26
|
+
## See also
|
|
27
|
+
|
|
28
|
+
- **`docs/kpi-trend-pattern.md`** — deltas, arrows, **`trendPolarity`**.
|
|
29
|
+
- **`.cursor/rules/exxat-kpi-max-four.mdc`**, **`.cursor/skills/exxat-kpi-max-four/SKILL.md`**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# KPI trend arrows and deltas (`KeyMetrics`)
|
|
2
|
+
|
|
3
|
+
> **Handbook:** [`AGENTS.md`](../AGENTS.md) (mock KPI helpers, `KeyMetrics`). **Component:** [`components/key-metrics.tsx`](../components/key-metrics.tsx). **Cursor:** [`.cursor/rules/exxat-kpi-trends.mdc`](../../.cursor/rules/exxat-kpi-trends.mdc). **Skill:** [`.cursor/skills/exxat-kpi-trends/SKILL.md`](../../.cursor/skills/exxat-kpi-trends/SKILL.md).
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
|
|
7
|
+
1. **Contextual** — The **label**, **value format** (count, %, currency, days), and **comparison period** (e.g. “vs last week”) must read as one story. Do not paste a generic “+12%” without tying it to what moved.
|
|
8
|
+
2. **Honest direction** — **`trend`** (`up` | `down` | `neutral`) always matches the **signed change** in the underlying metric so the **arrow** reflects reality.
|
|
9
|
+
3. **Correct sentiment** — **`trendPolarity`** decides whether “up” is **good news** (tint + assistive copy), **bad news**, or **informational** (muted — direction only).
|
|
10
|
+
|
|
11
|
+
## `MetricItem` fields
|
|
12
|
+
|
|
13
|
+
| Field | Role |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `value` | Current bucket total or rate (formatted string or number). |
|
|
16
|
+
| `delta` | Human-readable delta for the selected period, e.g. `+5`, `-3`, `+12%`, `—` when no comparison. |
|
|
17
|
+
| `trend` | **Visual direction** of that delta: more → `up`, less → `down`, flat / N/A → `neutral`. |
|
|
18
|
+
| `trendPolarity` | Optional. **`higher_is_better`** (default) \| **`lower_is_better`** \| **`informational`**. |
|
|
19
|
+
|
|
20
|
+
## Polarity cheat sheet
|
|
21
|
+
|
|
22
|
+
| `trendPolarity` | Use when | Up arrow tint | Down arrow tint |
|
|
23
|
+
| --- | --- | --- | --- |
|
|
24
|
+
| **`higher_is_better`** (default) | Revenue, pass rate, completions, enrolled count, positive CSAT | Favorable (brand / chart positive token) | Unfavorable (destructive) |
|
|
25
|
+
| **`lower_is_better`** | Error rate, overdue tasks, **low PBI / item quality flags**, time-on-task when minimizing, spend when cutting cost | Unfavorable | Favorable |
|
|
26
|
+
| **`informational`** | Library size, mix %, neutral volume | Muted | Muted |
|
|
27
|
+
|
|
28
|
+
**Psychometrics example:** Point-biserial (PBI) **dropping** usually helps discrimination — often good (`lower_is_better` on a *“low quality” count* is clearer: **count of items below a review threshold** rising → `trend: "up"` + `trendPolarity: "lower_is_better"` → arrow up with **unfavorable** tint).
|
|
29
|
+
|
|
30
|
+
## Accessibility
|
|
31
|
+
|
|
32
|
+
- **Never colour alone:** `KeyMetrics` keeps **icon + numeric delta**; `aria-label` on the chip uses **`metricTrendAriaQualifier`** (e.g. “increased, unfavorable +1”).
|
|
33
|
+
- **Decorative icons** stay `aria-hidden`; meaning lives in the chip’s **`aria-label`** and visible delta text.
|
|
34
|
+
|
|
35
|
+
## Anti-patterns
|
|
36
|
+
|
|
37
|
+
- Forcing **`trend: "up"`** green because “up feels good” when the metric is **defects** or **flags** — set **`lower_is_better`** instead.
|
|
38
|
+
- Hiding a worsening metric by flipping the arrow without changing **`trend`** — arrows must match the data.
|
|
39
|
+
- Using **`informational`** for KPIs that **do** have an agreed quality bar — pick a polarity or omit the trend chip (`delta: "—"`, `trend: "neutral"`).
|
|
40
|
+
|
|
41
|
+
## Related surfaces
|
|
42
|
+
|
|
43
|
+
- **`ChartCard`** `miniMetrics` / `kpi-chart` variant — optional **`trendPolarity`** on each mini metric; uses the same **`metricTrendTone`** helper from `key-metrics.tsx`.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"iconCount": 166,
|
|
15
15
|
"icons": [
|
|
16
16
|
"arrow-down",
|
|
17
|
-
"arrow-down-
|
|
17
|
+
"arrow-down-a-z",
|
|
18
18
|
"arrow-down-to-line",
|
|
19
19
|
"arrow-left",
|
|
20
20
|
"arrow-left-to-line",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"arrow-trend-up",
|
|
27
27
|
"arrow-up",
|
|
28
28
|
"arrow-up-arrow-down",
|
|
29
|
-
"arrow-up-
|
|
29
|
+
"arrow-up-a-z",
|
|
30
30
|
"arrow-up-right",
|
|
31
31
|
"arrow-up-right-from-square",
|
|
32
32
|
"arrows-rotate",
|
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
|
|
5
|
+
function subscribeHash(callback: () => void) {
|
|
6
|
+
window.addEventListener("hashchange", callback)
|
|
7
|
+
return () => window.removeEventListener("hashchange", callback)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getHashSnapshot() {
|
|
11
|
+
return window.location.hash
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getServerHashSnapshot() {
|
|
15
|
+
return ""
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
/** Current `window.location.hash` (including `#`), updated on `hashchange`. */
|
|
6
19
|
export function useLocationHash(): string {
|
|
7
|
-
|
|
8
|
-
React.useEffect(() => {
|
|
9
|
-
const read = () => setHash(typeof window !== "undefined" ? window.location.hash : "")
|
|
10
|
-
read()
|
|
11
|
-
window.addEventListener("hashchange", read)
|
|
12
|
-
return () => window.removeEventListener("hashchange", read)
|
|
13
|
-
}, [])
|
|
14
|
-
return hash
|
|
20
|
+
return React.useSyncExternalStore(subscribeHash, getHashSnapshot, getServerHashSnapshot)
|
|
15
21
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
import { useSecondaryPanel } from "@/components/secondary-panel"
|
|
7
|
+
import { QUESTION_BANK_HUB_FIND_PATH, QUESTION_BANK_LIBRARY_PATH, QUESTION_BANK_LIST_PATH } from "@/lib/question-bank-nav"
|
|
8
|
+
|
|
9
|
+
function rewriteLibraryCanonicalToDedicatedSurface(pathname: string, nextHref: string, hash: string): string {
|
|
10
|
+
if (!nextHref.startsWith(QUESTION_BANK_LIBRARY_PATH)) return `${nextHref}${hash}`
|
|
11
|
+
const tail = nextHref.slice(QUESTION_BANK_LIBRARY_PATH.length)
|
|
12
|
+
if (pathname === QUESTION_BANK_LIST_PATH) return `${QUESTION_BANK_LIST_PATH}${tail}${hash}`
|
|
13
|
+
if (pathname === QUESTION_BANK_HUB_FIND_PATH) return `${QUESTION_BANK_HUB_FIND_PATH}${tail}${hash}`
|
|
14
|
+
return `${nextHref}${hash}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function questionBankSearchParamsEqual(a: URLSearchParams, b: URLSearchParams): boolean {
|
|
18
|
+
const keys = new Set([...a.keys(), ...b.keys()])
|
|
19
|
+
for (const k of keys) {
|
|
20
|
+
const av = a.getAll(k).join("\u0000")
|
|
21
|
+
const bv = b.getAll(k).join("\u0000")
|
|
22
|
+
if (av !== bv) return false
|
|
23
|
+
}
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseSecondaryPanelHubNavOptions<TNav> {
|
|
28
|
+
/** Primary hub pathname (e.g. `/question-bank/library`). */
|
|
29
|
+
hubPathname: string
|
|
30
|
+
/** When set, these pathnames are treated as the same hub (e.g. library + list search surface). */
|
|
31
|
+
hubPathnames?: readonly string[]
|
|
32
|
+
/** `PANELS` / `useAutoPanel` id (e.g. `question-bank`). */
|
|
33
|
+
panelId: string
|
|
34
|
+
parseNav: (searchParams: URLSearchParams) => TNav
|
|
35
|
+
/** When non-null, the hub URL is rewritten (keeps the current hash). */
|
|
36
|
+
canonicalHref?: (searchParams: URLSearchParams) => string | null
|
|
37
|
+
/** Re-open the secondary panel when the user returns to the default scope (e.g. All questions). */
|
|
38
|
+
shouldReopenPanel?: (nav: TNav) => boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* URL scope for a primary hub with a nested secondary panel — shared between panel nav and main content.
|
|
43
|
+
*/
|
|
44
|
+
export function useSecondaryPanelHubNav<TNav>({
|
|
45
|
+
hubPathname,
|
|
46
|
+
hubPathnames,
|
|
47
|
+
panelId,
|
|
48
|
+
parseNav,
|
|
49
|
+
canonicalHref,
|
|
50
|
+
shouldReopenPanel,
|
|
51
|
+
}: UseSecondaryPanelHubNavOptions<TNav>) {
|
|
52
|
+
const pathname = usePathname()
|
|
53
|
+
const router = useRouter()
|
|
54
|
+
const searchParams = useSearchParams()
|
|
55
|
+
const { openPanel, activePanel } = useSecondaryPanel()
|
|
56
|
+
|
|
57
|
+
const hubPaths = React.useMemo(
|
|
58
|
+
() => (hubPathnames?.length ? [...hubPathnames] : [hubPathname]),
|
|
59
|
+
[hubPathname, hubPathnames],
|
|
60
|
+
)
|
|
61
|
+
const isHubPath = hubPaths.includes(pathname)
|
|
62
|
+
const hubBasePath = hubPaths.includes(pathname) ? pathname : hubPathname
|
|
63
|
+
|
|
64
|
+
const searchParamsKey = searchParams.toString()
|
|
65
|
+
const navState = React.useMemo(
|
|
66
|
+
() => parseNav(new URLSearchParams(searchParamsKey)),
|
|
67
|
+
[parseNav, searchParamsKey],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
if (!isHubPath || !canonicalHref) return
|
|
72
|
+
const nextHref = canonicalHref(new URLSearchParams(searchParamsKey))
|
|
73
|
+
if (!nextHref) return
|
|
74
|
+
const hash = typeof window !== "undefined" ? window.location.hash : ""
|
|
75
|
+
let target = `${nextHref}${hash}`
|
|
76
|
+
if (pathname === QUESTION_BANK_LIST_PATH || pathname === QUESTION_BANK_HUB_FIND_PATH) {
|
|
77
|
+
target = rewriteLibraryCanonicalToDedicatedSurface(pathname, nextHref, hash)
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"
|
|
81
|
+
const u = new URL(target, origin)
|
|
82
|
+
const want = u.searchParams
|
|
83
|
+
const cur = new URLSearchParams(searchParamsKey)
|
|
84
|
+
if (u.pathname === pathname && questionBankSearchParamsEqual(want, cur)) return
|
|
85
|
+
} catch {
|
|
86
|
+
/* ignore parse errors — fall through to replace */
|
|
87
|
+
}
|
|
88
|
+
router.replace(target, { scroll: false })
|
|
89
|
+
}, [canonicalHref, isHubPath, pathname, router, searchParamsKey])
|
|
90
|
+
|
|
91
|
+
React.useEffect(() => {
|
|
92
|
+
if (!isHubPath || !shouldReopenPanel?.(navState)) return
|
|
93
|
+
if (activePanel === panelId) return
|
|
94
|
+
openPanel(panelId)
|
|
95
|
+
}, [activePanel, isHubPath, navState, openPanel, panelId, shouldReopenPanel])
|
|
96
|
+
|
|
97
|
+
return { navState, searchParamsKey, hubPathname, hubBasePath, pathname, isHubPath }
|
|
98
|
+
}
|
|
@@ -78,6 +78,30 @@ export function getAskLeoRouteContext(pathname: string | null): AskLeoRouteConte
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
if (pathname.startsWith("/question-bank/library") || pathname.startsWith("/question-bank/list") || pathname.startsWith("/question-bank/find")) {
|
|
82
|
+
return {
|
|
83
|
+
title: "Question library",
|
|
84
|
+
description: "Browse folders, views, and mock assessment items.",
|
|
85
|
+
suggestions: [
|
|
86
|
+
"Summarize questions in the active folder scope",
|
|
87
|
+
"Suggest folders for a new pediatrics module",
|
|
88
|
+
"How do panel and tree views relate to the same dataset?",
|
|
89
|
+
],
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (pathname.startsWith("/question-bank")) {
|
|
94
|
+
return {
|
|
95
|
+
title: "Question bank",
|
|
96
|
+
description: "Search in plain language, draft items with AI, or open the full library.",
|
|
97
|
+
suggestions: [
|
|
98
|
+
"Draft a multiple-choice question on clinical reasoning",
|
|
99
|
+
"Outline a new question bank for a course module",
|
|
100
|
+
"Rewrite this stem for clarity and bias-free wording",
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
if (pathname.startsWith("/help")) {
|
|
82
106
|
return {
|
|
83
107
|
title: "Get help",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** Library access roles for shared hubs — see `docs/collaboration-access-pattern.md`. */
|
|
2
|
+
export type CollaboratorAccessRole = "owner" | "editor" | "commenter" | "viewer"
|
|
3
|
+
|
|
4
|
+
export const COLLABORATOR_ACCESS_LABELS: Record<CollaboratorAccessRole, string> = {
|
|
5
|
+
owner: "Owner",
|
|
6
|
+
editor: "Editor",
|
|
7
|
+
commenter: "Commenter",
|
|
8
|
+
viewer: "Viewer",
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Decorative FA icon names (`fa-light fa-*`) for library access chips and menus. */
|
|
12
|
+
export const COLLABORATOR_ACCESS_ICON_LIGHT: Record<CollaboratorAccessRole, string> = {
|
|
13
|
+
owner: "fa-crown",
|
|
14
|
+
editor: "fa-pen-to-square",
|
|
15
|
+
commenter: "fa-comment",
|
|
16
|
+
viewer: "fa-eye",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type InviteCollaboratorAccessRole = Exclude<CollaboratorAccessRole, "owner">
|
|
20
|
+
|
|
21
|
+
export const INVITE_COLLABORATOR_ACCESS_OPTIONS: {
|
|
22
|
+
value: InviteCollaboratorAccessRole
|
|
23
|
+
label: string
|
|
24
|
+
description: string
|
|
25
|
+
}[] = [
|
|
26
|
+
{
|
|
27
|
+
value: "editor",
|
|
28
|
+
label: "Editor",
|
|
29
|
+
description: "Can add and edit questions in this library.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
value: "commenter",
|
|
33
|
+
label: "Commenter",
|
|
34
|
+
description: "Can review and comment without editing.",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
value: "viewer",
|
|
38
|
+
label: "Viewer",
|
|
39
|
+
description: "Can view questions only.",
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
/** Roster access menu — includes Owner for existing collaborators. */
|
|
44
|
+
export const ROSTER_COLLABORATOR_ACCESS_OPTIONS: {
|
|
45
|
+
value: CollaboratorAccessRole
|
|
46
|
+
label: string
|
|
47
|
+
}[] = (["owner", "editor", "commenter", "viewer"] as const).map(value => ({
|
|
48
|
+
value,
|
|
49
|
+
label: COLLABORATOR_ACCESS_LABELS[value],
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
export function rosterOwnerCount(collaborators: readonly { access?: CollaboratorAccessRole }[]) {
|
|
53
|
+
return collaborators.filter(person => person.access === "owner").length
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function canRemoveCollaboratorFromRoster(
|
|
57
|
+
person: { access?: CollaboratorAccessRole },
|
|
58
|
+
collaborators: readonly { access?: CollaboratorAccessRole }[],
|
|
59
|
+
) {
|
|
60
|
+
if (person.access !== "owner") return true
|
|
61
|
+
return rosterOwnerCount(collaborators) > 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function canSetCollaboratorAccessRole(
|
|
65
|
+
person: { access?: CollaboratorAccessRole },
|
|
66
|
+
collaborators: readonly { access?: CollaboratorAccessRole }[],
|
|
67
|
+
nextAccess: CollaboratorAccessRole,
|
|
68
|
+
) {
|
|
69
|
+
if (person.access === nextAccess) return false
|
|
70
|
+
if (person.access === "owner" && rosterOwnerCount(collaborators) === 1 && nextAccess !== "owner") {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function collaboratorRemoveBlockedReason(
|
|
77
|
+
person: { access?: CollaboratorAccessRole },
|
|
78
|
+
collaborators: readonly { access?: CollaboratorAccessRole }[],
|
|
79
|
+
) {
|
|
80
|
+
if (canRemoveCollaboratorFromRoster(person, collaborators)) return undefined
|
|
81
|
+
return "This person is the only owner."
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Default label for the collaboration header when the roster is empty. */
|
|
85
|
+
export const COLLABORATION_HEADER_ADD_LABEL = "Add collaborator"
|
|
86
|
+
|
|
87
|
+
export function displayNameFromInviteEmail(email: string) {
|
|
88
|
+
const local = email.split("@")[0] ?? email
|
|
89
|
+
return local
|
|
90
|
+
.replace(/[._-]+/g, " ")
|
|
91
|
+
.replace(/\b\w/g, char => char.toUpperCase())
|
|
92
|
+
}
|
|
@@ -99,7 +99,14 @@ const STATIC_COMMAND_GROUPS: CommandMenuGroup[] = [
|
|
|
99
99
|
icon: "fa-light fa-books",
|
|
100
100
|
label: "Question bank",
|
|
101
101
|
href: "/question-bank",
|
|
102
|
-
keywords: "
|
|
102
|
+
keywords: "search ai create ask leo discovery hub",
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "nav-question-bank-library",
|
|
106
|
+
icon: "fa-light fa-table-list",
|
|
107
|
+
label: "Question library",
|
|
108
|
+
href: "/question-bank/library",
|
|
109
|
+
keywords: "folders assessment items tree panel table",
|
|
103
110
|
},
|
|
104
111
|
{ id: "nav-data-list", icon: "fa-light fa-table", label: "List hub", href: "/data-list" },
|
|
105
112
|
{ id: "nav-settings", icon: "fa-light fa-gear", label: "Settings", href: "/settings" },
|
|
@@ -32,14 +32,17 @@ function sampleRowSearchItems(): CommandMenuItem[] {
|
|
|
32
32
|
})
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Built once at module load — avoids remapping all placement rows on every layout render. */
|
|
36
|
+
export const COMMAND_MENU_SEARCH_DATA_GROUPS: CommandMenuGroup[] = [
|
|
37
|
+
{
|
|
38
|
+
id: "sample-rows",
|
|
39
|
+
heading: "Demo rows",
|
|
40
|
+
items: sampleRowSearchItems(),
|
|
41
|
+
searchOnly: true,
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
35
45
|
/** Demo rows for the list hub — search-only so the palette stays lightweight on open. */
|
|
36
46
|
export function getCommandMenuSearchDataGroups(): CommandMenuGroup[] {
|
|
37
|
-
return
|
|
38
|
-
{
|
|
39
|
-
id: "sample-rows",
|
|
40
|
-
heading: "Demo rows",
|
|
41
|
-
items: sampleRowSearchItems(),
|
|
42
|
-
searchOnly: true,
|
|
43
|
-
},
|
|
44
|
-
]
|
|
47
|
+
return COMMAND_MENU_SEARCH_DATA_GROUPS
|
|
45
48
|
}
|
|
@@ -25,7 +25,7 @@ export interface DataListDisplayOptions {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const DEFAULT_DATA_LIST_DISPLAY_OPTIONS: DataListDisplayOptions = {
|
|
28
|
-
boardGroupByColumnKey: "
|
|
28
|
+
boardGroupByColumnKey: "topic",
|
|
29
29
|
boardLineCount: 2,
|
|
30
30
|
showViewTitle: true,
|
|
31
31
|
showColumnLabels: true,
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placements **Data** view dashboard — card ids, defaults, and persistence.
|
|
3
|
+
* Split from `data-view-dashboard-charts.tsx` so list-hub code can import layout
|
|
4
|
+
* without pulling Recharts / chart canvas into the main chunk.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
9
|
+
mergeDashboardLayoutGeneric,
|
|
10
|
+
} from "@/lib/dashboard-layout-merge"
|
|
11
|
+
import {
|
|
12
|
+
loadDataViewLayout as loadStoredDataViewLayout,
|
|
13
|
+
saveDataViewLayout as saveStoredDataViewLayout,
|
|
14
|
+
} from "@/lib/data-view-dashboard-storage"
|
|
15
|
+
|
|
16
|
+
export type ChartType =
|
|
17
|
+
| "bar"
|
|
18
|
+
| "horizontal-bar"
|
|
19
|
+
| "pie"
|
|
20
|
+
| "area"
|
|
21
|
+
| "line"
|
|
22
|
+
| "radial"
|
|
23
|
+
| "stacked-bar"
|
|
24
|
+
|
|
25
|
+
export interface ChartTypeOption {
|
|
26
|
+
type: ChartType
|
|
27
|
+
label: string
|
|
28
|
+
icon: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DashboardCardDef {
|
|
32
|
+
id: string
|
|
33
|
+
title: string
|
|
34
|
+
description: string
|
|
35
|
+
defaultSpan: 1 | 2
|
|
36
|
+
defaultChartType: ChartType
|
|
37
|
+
chartTypes: ChartTypeOption[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Virtual “card” for the KPI strip — reorderable with charts in edit mode */
|
|
41
|
+
export const KEY_METRICS_CARD_ID = "key-metrics"
|
|
42
|
+
|
|
43
|
+
export const ALL_DASHBOARD_CARDS: DashboardCardDef[] = [
|
|
44
|
+
{
|
|
45
|
+
id: KEY_METRICS_CARD_ID,
|
|
46
|
+
title: "Key metrics",
|
|
47
|
+
description: "Summary KPIs for filtered placements",
|
|
48
|
+
defaultSpan: 1,
|
|
49
|
+
defaultChartType: "bar",
|
|
50
|
+
chartTypes: [],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "status-pipeline",
|
|
54
|
+
title: "Status Pipeline",
|
|
55
|
+
description: "Where placements are in the workflow",
|
|
56
|
+
defaultSpan: 1,
|
|
57
|
+
defaultChartType: "bar",
|
|
58
|
+
chartTypes: [
|
|
59
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
60
|
+
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
61
|
+
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "program-mix",
|
|
66
|
+
title: "Placements by Program",
|
|
67
|
+
description: "Distribution across active programs",
|
|
68
|
+
defaultSpan: 1,
|
|
69
|
+
defaultChartType: "pie",
|
|
70
|
+
chartTypes: [
|
|
71
|
+
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
72
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
73
|
+
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "compliance-status",
|
|
78
|
+
title: "Compliance Status",
|
|
79
|
+
description: "Document readiness for upcoming placements",
|
|
80
|
+
defaultSpan: 1,
|
|
81
|
+
defaultChartType: "horizontal-bar",
|
|
82
|
+
chartTypes: [
|
|
83
|
+
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
84
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
85
|
+
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: "readiness-overview",
|
|
90
|
+
title: "Student Readiness",
|
|
91
|
+
description: "How prepared students are for their placements",
|
|
92
|
+
defaultSpan: 1,
|
|
93
|
+
defaultChartType: "bar",
|
|
94
|
+
chartTypes: [
|
|
95
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
96
|
+
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
97
|
+
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
id: "progress-tracker",
|
|
102
|
+
title: "Ongoing Progress",
|
|
103
|
+
description: "How far along each ongoing placement is",
|
|
104
|
+
defaultSpan: 2,
|
|
105
|
+
defaultChartType: "stacked-bar",
|
|
106
|
+
chartTypes: [
|
|
107
|
+
{ type: "stacked-bar", label: "Stacked Bar", icon: "fa-light fa-layer-group" },
|
|
108
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "site-utilisation",
|
|
113
|
+
title: "Site Utilisation",
|
|
114
|
+
description: "Which clinical sites have the most placements",
|
|
115
|
+
defaultSpan: 1,
|
|
116
|
+
defaultChartType: "horizontal-bar",
|
|
117
|
+
chartTypes: [
|
|
118
|
+
{ type: "horizontal-bar", label: "Horizontal Bar", icon: "fa-light fa-chart-bar fa-rotate-90" },
|
|
119
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
120
|
+
{ type: "pie", label: "Donut", icon: "fa-light fa-chart-pie" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: "completion-outcomes",
|
|
125
|
+
title: "Completion Outcomes",
|
|
126
|
+
description: "Pass rate and average ratings for completed placements",
|
|
127
|
+
defaultSpan: 1,
|
|
128
|
+
defaultChartType: "radial",
|
|
129
|
+
chartTypes: [
|
|
130
|
+
{ type: "radial", label: "Radial", icon: "fa-light fa-circle-notch" },
|
|
131
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
id: "upcoming-timeline",
|
|
136
|
+
title: "Upcoming Start Dates",
|
|
137
|
+
description: "New placements starting in the next 8 weeks",
|
|
138
|
+
defaultSpan: 2,
|
|
139
|
+
defaultChartType: "area",
|
|
140
|
+
chartTypes: [
|
|
141
|
+
{ type: "area", label: "Area", icon: "fa-light fa-chart-area" },
|
|
142
|
+
{ type: "line", label: "Line", icon: "fa-light fa-chart-line" },
|
|
143
|
+
{ type: "bar", label: "Bar", icon: "fa-light fa-chart-bar" },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
export const DEFAULT_VISIBLE_CARDS = ALL_DASHBOARD_CARDS.map(c => c.id)
|
|
149
|
+
export const DEFAULT_SPANS: Record<string, 1 | 2> = Object.fromEntries(
|
|
150
|
+
ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultSpan]),
|
|
151
|
+
)
|
|
152
|
+
export const DEFAULT_CHART_TYPES: Record<string, ChartType> = Object.fromEntries(
|
|
153
|
+
ALL_DASHBOARD_CARDS.map(c => [c.id, c.defaultChartType]),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
export interface DashboardLayout {
|
|
157
|
+
visible: string[]
|
|
158
|
+
order: string[]
|
|
159
|
+
spans?: Record<string, 1 | 2>
|
|
160
|
+
chartTypes?: Record<string, ChartType>
|
|
161
|
+
keyMetricsKpiCount?: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function loadDashboardLayout(): DashboardLayout | null {
|
|
165
|
+
const v = loadStoredDataViewLayout("placements")
|
|
166
|
+
if (!v) return null
|
|
167
|
+
return {
|
|
168
|
+
visible: v.visible,
|
|
169
|
+
order: v.order,
|
|
170
|
+
spans: v.spans,
|
|
171
|
+
chartTypes: v.chartTypes as Record<string, ChartType> | undefined,
|
|
172
|
+
keyMetricsKpiCount: v.keyMetricsKpiCount,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function mergeDashboardLayout(saved: DashboardLayout | null): DashboardLayout {
|
|
177
|
+
const defaults = {
|
|
178
|
+
visible: [...DEFAULT_VISIBLE_CARDS],
|
|
179
|
+
order: ALL_DASHBOARD_CARDS.map(c => c.id),
|
|
180
|
+
spans: { ...DEFAULT_SPANS },
|
|
181
|
+
chartTypes: { ...DEFAULT_CHART_TYPES } as Record<string, string>,
|
|
182
|
+
keyMetricsKpiCount: KEY_METRICS_KPI_COUNT_DEFAULT,
|
|
183
|
+
}
|
|
184
|
+
const ids = ALL_DASHBOARD_CARDS.map(c => c.id)
|
|
185
|
+
const m = mergeDashboardLayoutGeneric(saved, defaults, ids)
|
|
186
|
+
return {
|
|
187
|
+
visible: m.visible,
|
|
188
|
+
order: m.order,
|
|
189
|
+
spans: m.spans as Record<string, 1 | 2>,
|
|
190
|
+
chartTypes: m.chartTypes as Record<string, ChartType>,
|
|
191
|
+
keyMetricsKpiCount: m.keyMetricsKpiCount,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function saveDashboardLayout(layout: DashboardLayout) {
|
|
196
|
+
saveStoredDataViewLayout("placements", {
|
|
197
|
+
visible: layout.visible,
|
|
198
|
+
order: layout.order,
|
|
199
|
+
spans: layout.spans,
|
|
200
|
+
chartTypes: layout.chartTypes as Record<string, string> | undefined,
|
|
201
|
+
keyMetricsKpiCount: layout.keyMetricsKpiCount,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function applyVisibleReorder(
|
|
206
|
+
fullOrder: string[],
|
|
207
|
+
visible: Set<string>,
|
|
208
|
+
newVisibleOrder: string[],
|
|
209
|
+
): string[] {
|
|
210
|
+
let vi = 0
|
|
211
|
+
return fullOrder.map(id => {
|
|
212
|
+
if (!visible.has(id)) return id
|
|
213
|
+
return newVisibleOrder[vi++]!
|
|
214
|
+
})
|
|
215
|
+
}
|