@umituz/web-dashboard 6.8.0 → 6.10.0
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/package.json +5 -1
- package/src/domains/layouts/components/DashboardLayout.test.tsx +24 -0
- package/src/domains/layouts/components/DashboardLayout.tsx +20 -2
- package/src/domains/layouts/types/layout.ts +3 -0
- package/src/domains/search/index.ts +2 -0
- package/src/domains/search/searchItems.test.ts +42 -0
- package/src/domains/search/searchItems.ts +36 -0
- package/src/domains/timing/hooks/index.ts +1 -0
- package/src/domains/timing/hooks/useCountdown.test.ts +107 -0
- package/src/domains/timing/hooks/useCountdown.ts +75 -0
- package/src/domains/timing/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-dashboard",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"description": "Installable app skeleton for the fleet — layouts, analytics (dependency-free SVG charts), calendar, onboarding flow, i18n via @umituz/web-localization, permissions/module registry; udb-* stylesheet on design-system tokens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"./settings/hooks": "./src/domains/settings/hooks/index.ts",
|
|
40
40
|
"./settings/types": "./src/domains/settings/types/index.ts",
|
|
41
41
|
"./settings/utils": "./src/domains/settings/utils/index.ts",
|
|
42
|
+
"./timing": "./src/domains/timing/index.ts",
|
|
43
|
+
"./timing/hooks": "./src/domains/timing/hooks/index.ts",
|
|
44
|
+
"./search": "./src/domains/search/index.ts",
|
|
45
|
+
"./search/utils": "./src/domains/search/searchItems.ts",
|
|
42
46
|
"./styles.css": "./src/styles/dashboard.css"
|
|
43
47
|
},
|
|
44
48
|
"files": [
|
|
@@ -98,4 +98,28 @@ describe("DashboardLayout (command palette)", () => {
|
|
|
98
98
|
expect(onActivate).toHaveBeenCalledTimes(1);
|
|
99
99
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
it("derives navigation groups from the sidebar when includeSidebar is set", async () => {
|
|
103
|
+
renderAt("/", undefined, {
|
|
104
|
+
...config,
|
|
105
|
+
commandPalette: {
|
|
106
|
+
includeSidebar: true,
|
|
107
|
+
groups: [{ heading: "Quick", items: [{ id: "q", label: "Quick action", onSelect: () => {} }] }],
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
111
|
+
expect(await screen.findByRole("option", { name: "Quick action" })).toBeTruthy();
|
|
112
|
+
// The custom group has no "Home" — the one present is derived from the sidebar,
|
|
113
|
+
// carrying the path as its hint (accessible name "Home /").
|
|
114
|
+
const derived = screen.getByRole("option", { name: /home/i });
|
|
115
|
+
expect(derived.textContent).toContain("/");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("omits sidebar-derived items from the palette by default", async () => {
|
|
119
|
+
renderAt("/", undefined, paletteConfig());
|
|
120
|
+
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
121
|
+
// Only the palette's own "Home" (no path hint) — nothing derived from the sidebar.
|
|
122
|
+
const own = await screen.findByRole("option", { name: "Home" });
|
|
123
|
+
expect(own.textContent).toBe("Home");
|
|
124
|
+
});
|
|
101
125
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useEffect, type ReactNode } from "react";
|
|
2
|
-
import { useLocation, Outlet, Navigate } from "react-router-dom";
|
|
2
|
+
import { useLocation, useNavigate, Outlet, Navigate } from "react-router-dom";
|
|
3
3
|
import { useT } from "@umituz/web-localization/react";
|
|
4
4
|
import { Skeleton } from "@umituz/web-design-system/atoms";
|
|
5
5
|
import { CommandPalette } from "@umituz/web-design-system/organisms";
|
|
@@ -61,6 +61,7 @@ export const DashboardLayout = ({
|
|
|
61
61
|
skipToContentLabel,
|
|
62
62
|
}: DashboardLayoutProps) => {
|
|
63
63
|
const location = useLocation();
|
|
64
|
+
const navigate = useNavigate();
|
|
64
65
|
const t = useT();
|
|
65
66
|
const [collapsed, setCollapsed] = useState(() => readCollapsed(collapsedStorageKey));
|
|
66
67
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
@@ -132,6 +133,23 @@ export const DashboardLayout = ({
|
|
|
132
133
|
|
|
133
134
|
const currentTitle = getTitle();
|
|
134
135
|
|
|
136
|
+
// Sidebar-derived navigation groups — one nav definition feeds both chrome surfaces.
|
|
137
|
+
const paletteGroups = palette?.includeSidebar
|
|
138
|
+
? [
|
|
139
|
+
...palette.groups,
|
|
140
|
+
...config.sidebarGroups.map((group) => ({
|
|
141
|
+
heading: group.title,
|
|
142
|
+
items: group.items.map((item) => ({
|
|
143
|
+
id: `nav-${item.path}`,
|
|
144
|
+
label: item.raw ? item.label : t(item.label),
|
|
145
|
+
icon: <item.icon size={16} aria-hidden="true" />,
|
|
146
|
+
hint: item.path,
|
|
147
|
+
onSelect: () => navigate(item.path),
|
|
148
|
+
})),
|
|
149
|
+
})),
|
|
150
|
+
]
|
|
151
|
+
: undefined;
|
|
152
|
+
|
|
135
153
|
const sidebarProps = {
|
|
136
154
|
sidebarGroups: config.sidebarGroups,
|
|
137
155
|
brandName: config.brandName,
|
|
@@ -225,7 +243,7 @@ export const DashboardLayout = ({
|
|
|
225
243
|
<CommandPalette
|
|
226
244
|
open={paletteOpen}
|
|
227
245
|
onOpenChange={setPaletteOpen}
|
|
228
|
-
groups={palette.groups}
|
|
246
|
+
groups={paletteGroups ?? palette.groups}
|
|
229
247
|
placeholder={palette.placeholder}
|
|
230
248
|
emptyLabel={palette.emptyLabel}
|
|
231
249
|
label={palette.label}
|
|
@@ -42,6 +42,9 @@ export interface CommandPaletteConfig {
|
|
|
42
42
|
label?: string;
|
|
43
43
|
/** Bind ⌘K/Ctrl+K to open the palette (default true). */
|
|
44
44
|
shortcut?: boolean;
|
|
45
|
+
/** Append the sidebar groups as navigation groups — derived from
|
|
46
|
+
* sidebarGroups and routed internally, so apps never map their nav twice. */
|
|
47
|
+
includeSidebar?: boolean;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
export interface DashboardSidebarProps {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { searchItems } from './searchItems';
|
|
3
|
+
|
|
4
|
+
interface Course {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
blurb: string;
|
|
8
|
+
level: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const COURSES: Course[] = [
|
|
12
|
+
{ id: 'c1', name: 'Present perfect workshop', blurb: 'Have you ever…', level: 'B1' },
|
|
13
|
+
{ id: 'c2', name: 'IELTS reading sprint', blurb: 'Skimming strategies', level: 'C1' },
|
|
14
|
+
{ id: 'c3', name: 'Everyday vocabulary', blurb: 'Present simple review', level: 'A1' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const toHit = (c: Course) => ({ id: c.id, title: c.name, subtitle: c.blurb, category: c.level, to: `/courses/${c.id}` });
|
|
18
|
+
|
|
19
|
+
describe('searchItems', () => {
|
|
20
|
+
it('returns nothing for empty or blank queries', () => {
|
|
21
|
+
expect(searchItems(COURSES, '', toHit)).toEqual([]);
|
|
22
|
+
expect(searchItems(COURSES, ' ', toHit)).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('matches titles case-insensitively and returns route hits', () => {
|
|
26
|
+
const hits = searchItems(COURSES, 'ielts', toHit);
|
|
27
|
+
expect(hits).toHaveLength(1);
|
|
28
|
+
expect(hits[0]).toMatchObject({ id: 'c2', title: 'IELTS reading sprint', to: '/courses/c2' });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('ranks title matches above body-only matches', () => {
|
|
32
|
+
// "present" is in c1's title and c3's blurb — title wins.
|
|
33
|
+
const hits = searchItems(COURSES, 'present', toHit);
|
|
34
|
+
expect(hits[0]?.id).toBe('c1');
|
|
35
|
+
expect(hits).toHaveLength(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('scores multi-term queries across fields', () => {
|
|
39
|
+
const hits = searchItems(COURSES, 'reading strategies', toHit);
|
|
40
|
+
expect(hits[0]?.id).toBe('c2');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SearchHit {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
subtitle: string;
|
|
5
|
+
category: string;
|
|
6
|
+
to: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Case-insensitive substring scoring across a haystack of fields.
|
|
10
|
+
* Title matches weigh 3×; every additional field hit adds 1. Empty
|
|
11
|
+
* queries return no hits. Results are sorted by descending score. */
|
|
12
|
+
export function searchItems<T extends object>(
|
|
13
|
+
items: readonly T[],
|
|
14
|
+
query: string,
|
|
15
|
+
toHit: (item: T) => Omit<SearchHit, 'id'> & { id?: string } & Record<string, unknown>,
|
|
16
|
+
): SearchHit[] {
|
|
17
|
+
const q = query.trim().toLowerCase();
|
|
18
|
+
if (!q) return [];
|
|
19
|
+
const terms = q.split(/\s+/);
|
|
20
|
+
const hits: { hit: SearchHit; score: number }[] = [];
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
const built = toHit(item);
|
|
23
|
+
const haystack =
|
|
24
|
+
`${built.title} ${built.subtitle} ${built.category} ${Object.values(item).filter((v) => typeof v === 'string').join(' ')}`.toLowerCase();
|
|
25
|
+
let score = 0;
|
|
26
|
+
for (const term of terms) {
|
|
27
|
+
if (built.title.toLowerCase().includes(term)) score += 3;
|
|
28
|
+
if (haystack.includes(term)) score += 1;
|
|
29
|
+
}
|
|
30
|
+
if (score > 0) {
|
|
31
|
+
const { title, subtitle, category, to, ...rest } = built;
|
|
32
|
+
hits.push({ hit: { id: (rest.id as string) ?? title, title, subtitle, category, to }, score });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return hits.sort((a, b) => b.score - a.score).map((h) => h.hit);
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useCountdown, useStopwatch } from './useCountdown';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useCountdown, useStopwatch } from './useCountdown';
|
|
4
|
+
|
|
5
|
+
describe('useCountdown', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.useRealTimers();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('ticks down once per second while running', () => {
|
|
14
|
+
const { result } = renderHook(() => useCountdown(3));
|
|
15
|
+
expect(result.current.remaining).toBe(3);
|
|
16
|
+
act(() => {
|
|
17
|
+
vi.advanceTimersByTime(2000);
|
|
18
|
+
});
|
|
19
|
+
expect(result.current.remaining).toBe(1);
|
|
20
|
+
expect(result.current.running).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('fires onFinish exactly once at zero and stops', () => {
|
|
24
|
+
const onFinish = vi.fn();
|
|
25
|
+
const { result } = renderHook(() => useCountdown(2, onFinish));
|
|
26
|
+
act(() => {
|
|
27
|
+
vi.advanceTimersByTime(2000);
|
|
28
|
+
});
|
|
29
|
+
expect(result.current.remaining).toBe(0);
|
|
30
|
+
expect(result.current.running).toBe(false);
|
|
31
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
32
|
+
act(() => {
|
|
33
|
+
vi.advanceTimersByTime(5000);
|
|
34
|
+
});
|
|
35
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('restart rewinds the clock and re-arms completion', () => {
|
|
39
|
+
const onFinish = vi.fn();
|
|
40
|
+
const { result } = renderHook(() => useCountdown(1, onFinish));
|
|
41
|
+
act(() => {
|
|
42
|
+
vi.advanceTimersByTime(1000);
|
|
43
|
+
});
|
|
44
|
+
expect(onFinish).toHaveBeenCalledTimes(1);
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.restart();
|
|
47
|
+
});
|
|
48
|
+
expect(result.current.remaining).toBe(1);
|
|
49
|
+
expect(result.current.running).toBe(true);
|
|
50
|
+
act(() => {
|
|
51
|
+
vi.advanceTimersByTime(1000);
|
|
52
|
+
});
|
|
53
|
+
expect(onFinish).toHaveBeenCalledTimes(2);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('pause halts the tick and start resumes it', () => {
|
|
57
|
+
const { result } = renderHook(() => useCountdown(5));
|
|
58
|
+
// state change and time travel must be separate acts — the interval
|
|
59
|
+
// effect only re-registers after the pause() commit
|
|
60
|
+
act(() => {
|
|
61
|
+
result.current.pause();
|
|
62
|
+
});
|
|
63
|
+
act(() => {
|
|
64
|
+
vi.advanceTimersByTime(3000);
|
|
65
|
+
});
|
|
66
|
+
expect(result.current.remaining).toBe(5);
|
|
67
|
+
act(() => {
|
|
68
|
+
result.current.start();
|
|
69
|
+
});
|
|
70
|
+
act(() => {
|
|
71
|
+
vi.advanceTimersByTime(1000);
|
|
72
|
+
});
|
|
73
|
+
expect(result.current.remaining).toBe(4);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('useStopwatch', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
vi.useFakeTimers();
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.useRealTimers();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('counts up after start and freezes on stop', () => {
|
|
86
|
+
const { result } = renderHook(() => useStopwatch());
|
|
87
|
+
expect(result.current.elapsed).toBe(0);
|
|
88
|
+
act(() => {
|
|
89
|
+
result.current.start();
|
|
90
|
+
});
|
|
91
|
+
act(() => {
|
|
92
|
+
vi.advanceTimersByTime(3000);
|
|
93
|
+
});
|
|
94
|
+
expect(result.current.elapsed).toBe(3);
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.stop();
|
|
97
|
+
});
|
|
98
|
+
act(() => {
|
|
99
|
+
vi.advanceTimersByTime(3000);
|
|
100
|
+
});
|
|
101
|
+
expect(result.current.elapsed).toBe(3);
|
|
102
|
+
act(() => {
|
|
103
|
+
result.current.reset();
|
|
104
|
+
});
|
|
105
|
+
expect(result.current.elapsed).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Ticking countdown in seconds; fires onFinish once at zero. */
|
|
4
|
+
export function useCountdown(seconds: number | null, onFinish?: () => void) {
|
|
5
|
+
const [remaining, setRemaining] = useState(seconds ?? 0);
|
|
6
|
+
const [running, setRunning] = useState(seconds != null);
|
|
7
|
+
const finishedRef = useRef(false);
|
|
8
|
+
const onFinishRef = useRef(onFinish);
|
|
9
|
+
onFinishRef.current = onFinish;
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
setRemaining(seconds ?? 0);
|
|
13
|
+
setRunning(seconds != null);
|
|
14
|
+
finishedRef.current = false;
|
|
15
|
+
}, [seconds]);
|
|
16
|
+
|
|
17
|
+
const restart = () => {
|
|
18
|
+
finishedRef.current = false;
|
|
19
|
+
setRemaining(seconds ?? 0);
|
|
20
|
+
setRunning(seconds != null);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!running) return;
|
|
25
|
+
const id = window.setInterval(() => {
|
|
26
|
+
setRemaining((r) => (r <= 1 ? 0 : r - 1));
|
|
27
|
+
}, 1000);
|
|
28
|
+
return () => window.clearInterval(id);
|
|
29
|
+
}, [running]);
|
|
30
|
+
|
|
31
|
+
// Completion fires in an effect, not inside the setRemaining updater —
|
|
32
|
+
// updaters must stay pure (StrictMode double-invokes them).
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (running && remaining === 0 && !finishedRef.current) {
|
|
35
|
+
finishedRef.current = true;
|
|
36
|
+
setRunning(false);
|
|
37
|
+
onFinishRef.current?.();
|
|
38
|
+
}
|
|
39
|
+
}, [running, remaining]);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
remaining,
|
|
43
|
+
running,
|
|
44
|
+
start: () => setRunning(true),
|
|
45
|
+
pause: () => setRunning(false),
|
|
46
|
+
stop: () => {
|
|
47
|
+
setRunning(false);
|
|
48
|
+
finishedRef.current = true;
|
|
49
|
+
},
|
|
50
|
+
restart,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Stopwatch for open practice (writing time, speaking response windows). */
|
|
55
|
+
export function useStopwatch() {
|
|
56
|
+
const [elapsed, setElapsed] = useState(0);
|
|
57
|
+
const [running, setRunning] = useState(false);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!running) return;
|
|
61
|
+
const id = window.setInterval(() => setElapsed((e) => e + 1), 1000);
|
|
62
|
+
return () => window.clearInterval(id);
|
|
63
|
+
}, [running]);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
elapsed,
|
|
67
|
+
running,
|
|
68
|
+
start: () => setRunning(true),
|
|
69
|
+
stop: () => setRunning(false),
|
|
70
|
+
reset: () => {
|
|
71
|
+
setRunning(false);
|
|
72
|
+
setElapsed(0);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './hooks';
|