@tuturuuu/ui 0.9.0 → 0.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/CHANGELOG.md +29 -0
- package/package.json +6 -5
- package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
- package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
- package/src/components/ui/custom/nav-link.test.tsx +165 -0
- package/src/components/ui/custom/nav-link.tsx +69 -11
- package/src/components/ui/custom/navigation.tsx +1 -0
- package/src/components/ui/custom/settings/task-settings.tsx +104 -0
- package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
- package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
- package/src/components/ui/custom/settings-dialog-search.ts +75 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
- package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
- package/src/components/ui/custom/workspace-select.tsx +17 -16
- package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
- package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
- package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
- package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
- package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
- package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
- package/src/components/ui/tu-do/boards/form.tsx +1 -1
- package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
- package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
- package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
- package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
- package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
- package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
- package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
- package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
- package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
- package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
- package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
- package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
- package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
- package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
- package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
- package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
- package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
- package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
- package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
- package/src/hooks/useBoardPresence.ts +364 -0
- package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
- package/src/lib/workspace-actions.ts +2 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0](https://github.com/tutur3u/platform/compare/ui-v0.9.0...ui-v0.10.0) (2026-06-26)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **settings:** lazy load searchable settings availability ([78091c0](https://github.com/tutur3u/platform/commit/78091c0acd16609c695be80b7c47f7840a34d74a))
|
|
9
|
+
* **settings:** open settings dialog from sidebar ([f3d223f](https://github.com/tutur3u/platform/commit/f3d223fbe3b4ecc7c37b6f19645f6122ddfdf5b9))
|
|
10
|
+
* **tanstack:** migrate module youtube links ([bcbf192](https://github.com/tutur3u/platform/commit/bcbf1927802283b3447a8851b3208362e0dff822))
|
|
11
|
+
* **tasks:** add task progress tracking ([4cffb0f](https://github.com/tutur3u/platform/commit/4cffb0f041d41e3868481068d64b9e3b85a0011a))
|
|
12
|
+
* **tasks:** stabilize shared board realtime collaboration ([6028caf](https://github.com/tutur3u/platform/commit/6028caf50432fd4dcebdaa5a59284e50972b1aa3))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **settings:** resolve lazy search import for docker build ([8a6dc1b](https://github.com/tutur3u/platform/commit/8a6dc1b39fd675daaa9f9b24c60b975b044db724))
|
|
18
|
+
* **tasks:** align board skeleton and nav highlight ([1f20ad4](https://github.com/tutur3u/platform/commit/1f20ad4c21c607a085de6eb7dff772a077fc0fd1))
|
|
19
|
+
* **tasks:** canonicalize task board entry routes ([6d83864](https://github.com/tutur3u/platform/commit/6d838648e7d817cdfac6ae40f06ce3418a899bf9))
|
|
20
|
+
* **tasks:** default task boards to kanban ([477c12a](https://github.com/tutur3u/platform/commit/477c12aeee7644a28e6266f9c339e7ef5c3228bf))
|
|
21
|
+
* **tasks:** harden board task loading ([459fecf](https://github.com/tutur3u/platform/commit/459fecf66414b83bcb18f98eda9677ec3e220855))
|
|
22
|
+
* **tasks:** improve shared board collaboration ([fa4ca4d](https://github.com/tutur3u/platform/commit/fa4ca4d412c8f8f533ab87639b314fefc70f9107))
|
|
23
|
+
* **tasks:** keep pinned special lists sticky ([83ff72c](https://github.com/tutur3u/platform/commit/83ff72ce9b3fd737db666a6caef96ed2ca026fc2))
|
|
24
|
+
* **tasks:** match board name input surface ([2552733](https://github.com/tutur3u/platform/commit/2552733942f720bb1df0566f27753eb226115f3e))
|
|
25
|
+
* **tasks:** show accessible guest task boards ([3153c48](https://github.com/tutur3u/platform/commit/3153c4814e599905ea3de36b3f63e941d6bc86d9))
|
|
26
|
+
* **tasks:** stabilize task board loading states ([b7f4212](https://github.com/tutur3u/platform/commit/b7f4212981514fdb1f38b83be5baf7a8d2686571))
|
|
27
|
+
* **tasks:** support assignees on shared personal boards ([112a561](https://github.com/tutur3u/platform/commit/112a561f38cd73bf4c928117cb4d2f0b178dc7e4))
|
|
28
|
+
* **tasks:** support board guest assignees ([4e479aa](https://github.com/tutur3u/platform/commit/4e479aac13746a2e7c432be4f36ea87407849472))
|
|
29
|
+
* **teach:** harden lesson save recovery ([d56a010](https://github.com/tutur3u/platform/commit/d56a010db5d22dcfde10fc51fbcf73c7abc62268))
|
|
30
|
+
* **teach:** reconcile lesson editor saves ([6f2cb4f](https://github.com/tutur3u/platform/commit/6f2cb4f38c1f3b68aeadec11215066be4f7fbd78))
|
|
31
|
+
|
|
3
32
|
## [0.9.0](https://github.com/tutur3u/platform/compare/ui-v0.8.0...ui-v0.9.0) (2026-06-24)
|
|
4
33
|
|
|
5
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -85,12 +85,12 @@
|
|
|
85
85
|
"@tiptap/react": "3.27.1",
|
|
86
86
|
"@tiptap/starter-kit": "3.27.1",
|
|
87
87
|
"@tuturuuu/ai": "0.2.2",
|
|
88
|
-
"@tuturuuu/apis": "0.
|
|
88
|
+
"@tuturuuu/apis": "0.7.0",
|
|
89
89
|
"@tuturuuu/hooks": "0.0.2",
|
|
90
90
|
"@tuturuuu/icons": "0.0.6",
|
|
91
|
-
"@tuturuuu/internal-api": "0.
|
|
91
|
+
"@tuturuuu/internal-api": "0.11.0",
|
|
92
92
|
"@tuturuuu/supabase": "0.4.0",
|
|
93
|
-
"@tuturuuu/utils": "0.
|
|
93
|
+
"@tuturuuu/utils": "0.10.0",
|
|
94
94
|
"@types/debug": "^4.1.13",
|
|
95
95
|
"browser-image-compression": "^2.0.2",
|
|
96
96
|
"class-variance-authority": "^0.7.1",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"@tanstack/react-table": "^8.21.3",
|
|
150
150
|
"@testing-library/jest-dom": "^6.9.1",
|
|
151
151
|
"@testing-library/react": "^16.3.2",
|
|
152
|
-
"@tuturuuu/types": "0.
|
|
152
|
+
"@tuturuuu/types": "0.12.0",
|
|
153
153
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
154
154
|
"@types/html2canvas": "^1.0.0",
|
|
155
155
|
"@types/lodash": "^4.17.24",
|
|
@@ -305,6 +305,7 @@
|
|
|
305
305
|
"./tu-do/initiatives/*": "./src/components/ui/tu-do/initiatives/*.tsx",
|
|
306
306
|
"./tu-do/cycles/*": "./src/components/ui/tu-do/cycles/*.tsx",
|
|
307
307
|
"./tu-do/logs/*": "./src/components/ui/tu-do/logs/*.tsx",
|
|
308
|
+
"./tu-do/progress/*": "./src/components/ui/tu-do/progress/*.tsx",
|
|
308
309
|
"./tu-do/templates/types": {
|
|
309
310
|
"types": "./src/components/ui/tu-do/templates/types.ts",
|
|
310
311
|
"import": "./src/components/ui/tu-do/templates/types.ts"
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createSettingsSearchEngine } from '../settings-dialog-search';
|
|
3
|
+
import type { SettingsNavGroup } from '../settings-dialog-shell';
|
|
4
|
+
|
|
5
|
+
function TestIcon() {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const navItems: SettingsNavGroup[] = [
|
|
10
|
+
{
|
|
11
|
+
label: 'Workspace',
|
|
12
|
+
items: [
|
|
13
|
+
{
|
|
14
|
+
aliases: ['organization'],
|
|
15
|
+
description: 'Manage workspace profile and branding',
|
|
16
|
+
icon: TestIcon,
|
|
17
|
+
keywords: ['general'],
|
|
18
|
+
label: 'General',
|
|
19
|
+
name: 'workspace_general',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
description: 'Invite people and manage roles',
|
|
23
|
+
icon: TestIcon,
|
|
24
|
+
keywords: ['team'],
|
|
25
|
+
label: 'Members',
|
|
26
|
+
name: 'workspace_members',
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: 'Developer',
|
|
32
|
+
items: [
|
|
33
|
+
{
|
|
34
|
+
description: 'Secret keys for external apps',
|
|
35
|
+
icon: TestIcon,
|
|
36
|
+
label: 'API Keys',
|
|
37
|
+
name: 'api_keys',
|
|
38
|
+
searchLabels: ['sdk tokens'],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
disabled: true,
|
|
42
|
+
icon: TestIcon,
|
|
43
|
+
label: 'Secrets',
|
|
44
|
+
name: 'secrets',
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe('createSettingsSearchEngine', () => {
|
|
51
|
+
it('matches labels, group labels, descriptions, aliases, and keywords', () => {
|
|
52
|
+
const engine = createSettingsSearchEngine(navItems);
|
|
53
|
+
|
|
54
|
+
expect(engine.search('members')[0]?.items.map((item) => item.name)).toEqual(
|
|
55
|
+
['workspace_members']
|
|
56
|
+
);
|
|
57
|
+
expect(
|
|
58
|
+
engine.search('organization')[0]?.items.map((item) => item.name)
|
|
59
|
+
).toEqual(['workspace_general']);
|
|
60
|
+
expect(engine.search('sdk')[0]?.items.map((item) => item.name)).toEqual([
|
|
61
|
+
'api_keys',
|
|
62
|
+
]);
|
|
63
|
+
expect(
|
|
64
|
+
engine.search('developer')[0]?.items.map((item) => item.name)
|
|
65
|
+
).toEqual(['api_keys', 'secrets']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('normalizes accents and excludes disabled items from enabled traversal', () => {
|
|
69
|
+
const engine = createSettingsSearchEngine(navItems);
|
|
70
|
+
|
|
71
|
+
expect(engine.search('generál')[0]?.items.map((item) => item.name)).toEqual(
|
|
72
|
+
['workspace_general']
|
|
73
|
+
);
|
|
74
|
+
expect(
|
|
75
|
+
engine.getEnabledItems('developer').map((item) => item.name)
|
|
76
|
+
).toEqual(['api_keys']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const settingsDialogShellPath = [
|
|
6
|
+
join(
|
|
7
|
+
process.cwd(),
|
|
8
|
+
'packages/ui/src/components/ui/custom/settings-dialog-shell.tsx'
|
|
9
|
+
),
|
|
10
|
+
join(process.cwd(), 'src/components/ui/custom/settings-dialog-shell.tsx'),
|
|
11
|
+
].find((path) => existsSync(path));
|
|
12
|
+
|
|
13
|
+
if (!settingsDialogShellPath) {
|
|
14
|
+
throw new Error('Unable to locate settings-dialog-shell.tsx');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const settingsDialogShellSource = readFileSync(settingsDialogShellPath, {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
});
|
|
20
|
+
const settingsDialogSearchLoaderPath = [
|
|
21
|
+
join(
|
|
22
|
+
process.cwd(),
|
|
23
|
+
'packages/ui/src/components/ui/custom/settings-dialog-search-loader.js'
|
|
24
|
+
),
|
|
25
|
+
join(
|
|
26
|
+
process.cwd(),
|
|
27
|
+
'src/components/ui/custom/settings-dialog-search-loader.js'
|
|
28
|
+
),
|
|
29
|
+
].find((path) => existsSync(path));
|
|
30
|
+
|
|
31
|
+
if (!settingsDialogSearchLoaderPath) {
|
|
32
|
+
throw new Error('Unable to locate settings-dialog-search-loader.js');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const settingsDialogSearchLoaderSource = readFileSync(
|
|
36
|
+
settingsDialogSearchLoaderPath,
|
|
37
|
+
{
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
const runtimeSource = settingsDialogShellSource.replace(
|
|
42
|
+
/^\s*import\s+type\b[\s\S]*?\sfrom\s+['"][^'"]+['"];?/gmu,
|
|
43
|
+
''
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function staticImportPattern(modulePath: string) {
|
|
47
|
+
const escapedModulePath = modulePath.replace(
|
|
48
|
+
/[.*+?^${}()|[\]\\]/gu,
|
|
49
|
+
String.raw`\$&`
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return new RegExp(
|
|
53
|
+
String.raw`^\s*import\s+(?!type\b)[\s\S]*?\sfrom\s+['"]${escapedModulePath}['"];`,
|
|
54
|
+
'mu'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('settings dialog shell compile graph', () => {
|
|
59
|
+
it('keeps the settings search engine behind a dynamic import', () => {
|
|
60
|
+
expect(runtimeSource).not.toMatch(
|
|
61
|
+
staticImportPattern('./settings-dialog-search')
|
|
62
|
+
);
|
|
63
|
+
expect(runtimeSource).not.toMatch(
|
|
64
|
+
staticImportPattern('@tuturuuu/utils/text-helper')
|
|
65
|
+
);
|
|
66
|
+
expect(settingsDialogSearchLoaderSource).toMatch(
|
|
67
|
+
/import\(['"]\.\/settings-dialog-search['"]\)/u
|
|
68
|
+
);
|
|
69
|
+
expect(settingsDialogShellSource).not.toContain(
|
|
70
|
+
'settings-dialog-search.js'
|
|
71
|
+
);
|
|
72
|
+
expect(settingsDialogSearchLoaderSource).not.toContain(
|
|
73
|
+
'settings-dialog-search.js'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -2,7 +2,10 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
mergeWorkspaceSelectWorkspaces,
|
|
7
|
+
normalizeWorkspaceSwitchPath,
|
|
8
|
+
} from '../workspace-select-helpers';
|
|
6
9
|
|
|
7
10
|
describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
8
11
|
it('uses the current workspace fallback when the workspace list is unavailable', () => {
|
|
@@ -56,3 +59,26 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
|
56
59
|
);
|
|
57
60
|
});
|
|
58
61
|
});
|
|
62
|
+
|
|
63
|
+
describe('normalizeWorkspaceSwitchPath', () => {
|
|
64
|
+
it('lands on tasks when switching workspace from a task board detail route', () => {
|
|
65
|
+
expect(
|
|
66
|
+
normalizeWorkspaceSwitchPath('/personal/tasks/boards/board-1', 'personal')
|
|
67
|
+
).toBe('/personal/tasks');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('lands on tasks when switching workspace from the task boards index', () => {
|
|
71
|
+
expect(
|
|
72
|
+
normalizeWorkspaceSwitchPath('/personal/tasks/boards', 'personal')
|
|
73
|
+
).toBe('/personal/tasks');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('preserves existing UUID detail-route stripping for non-task-board routes', () => {
|
|
77
|
+
expect(
|
|
78
|
+
normalizeWorkspaceSwitchPath(
|
|
79
|
+
'/workspace-1/users/11111111-1111-4111-8111-111111111111',
|
|
80
|
+
'workspace-1'
|
|
81
|
+
)
|
|
82
|
+
).toBe('/workspace-1/users');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
4
|
+
import type { ComponentProps } from 'react';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { NavLink } from './nav-link';
|
|
7
|
+
|
|
8
|
+
const navigationState = vi.hoisted(() => ({
|
|
9
|
+
pathname: '/personal/tasks',
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('next/navigation', () => ({
|
|
13
|
+
usePathname: () => navigationState.pathname,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('next-intl', () => ({
|
|
17
|
+
useTranslations: () => (key: string) => key,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@tuturuuu/internal-api', () => ({
|
|
21
|
+
getWorkspaceConfigIdList: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
function renderNavLink(
|
|
25
|
+
link: ComponentProps<typeof NavLink>['link'],
|
|
26
|
+
props: Partial<
|
|
27
|
+
Pick<ComponentProps<typeof NavLink>, 'onClick' | 'onSubMenuClick'>
|
|
28
|
+
> = {}
|
|
29
|
+
) {
|
|
30
|
+
const queryClient = new QueryClient({
|
|
31
|
+
defaultOptions: {
|
|
32
|
+
queries: {
|
|
33
|
+
retry: false,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return render(
|
|
39
|
+
<QueryClientProvider client={queryClient}>
|
|
40
|
+
<NavLink
|
|
41
|
+
wsId="personal"
|
|
42
|
+
link={link}
|
|
43
|
+
isCollapsed={false}
|
|
44
|
+
onClick={props.onClick ?? vi.fn()}
|
|
45
|
+
onSubMenuClick={props.onSubMenuClick ?? vi.fn()}
|
|
46
|
+
/>
|
|
47
|
+
</QueryClientProvider>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('NavLink', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
navigationState.pathname = '/personal/tasks';
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('matches wildcard aliases even when the primary route is exact', () => {
|
|
58
|
+
navigationState.pathname = '/personal/tasks/boards/board-1';
|
|
59
|
+
|
|
60
|
+
renderNavLink({
|
|
61
|
+
aliases: ['/personal/tasks/boards/*'],
|
|
62
|
+
href: '/personal/tasks',
|
|
63
|
+
matchExact: true,
|
|
64
|
+
title: 'Tasks',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(screen.getByRole('link', { name: 'Tasks' })).toHaveClass(
|
|
68
|
+
'bg-accent',
|
|
69
|
+
'text-accent-foreground'
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('keeps exact primary routes from matching unrelated subroutes', () => {
|
|
74
|
+
navigationState.pathname = '/personal/tasks/progress';
|
|
75
|
+
|
|
76
|
+
renderNavLink({
|
|
77
|
+
aliases: ['/personal/tasks/boards/*'],
|
|
78
|
+
href: '/personal/tasks',
|
|
79
|
+
matchExact: true,
|
|
80
|
+
title: 'Tasks',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(screen.getByRole('link', { name: 'Tasks' })).not.toHaveClass(
|
|
84
|
+
'bg-accent'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not mark cross-origin links active just because the path matches', () => {
|
|
89
|
+
navigationState.pathname = '/personal';
|
|
90
|
+
|
|
91
|
+
renderNavLink({
|
|
92
|
+
href: 'https://mail.tuturuuu.com/personal',
|
|
93
|
+
title: 'Mail',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(screen.getByRole('link', { name: 'Mail' })).not.toHaveClass(
|
|
97
|
+
'bg-accent'
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('navigates through the parent link instead of opening a single-child submenu', () => {
|
|
102
|
+
const onClick = vi.fn();
|
|
103
|
+
const onSubMenuClick = vi.fn();
|
|
104
|
+
|
|
105
|
+
renderNavLink(
|
|
106
|
+
{
|
|
107
|
+
children: [{ href: '/personal/tasks/boards', title: 'Boards' }],
|
|
108
|
+
href: '/personal/tasks',
|
|
109
|
+
title: 'Tasks',
|
|
110
|
+
},
|
|
111
|
+
{ onClick, onSubMenuClick }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const link = screen.getByRole('link', { name: 'Tasks' });
|
|
115
|
+
link.addEventListener('click', (event) => event.preventDefault());
|
|
116
|
+
fireEvent.click(link);
|
|
117
|
+
|
|
118
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(onSubMenuClick).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('dispatches the settings dialog open intent without navigating', () => {
|
|
123
|
+
const onClick = vi.fn();
|
|
124
|
+
const dispatchEvent = vi.spyOn(window, 'dispatchEvent');
|
|
125
|
+
|
|
126
|
+
renderNavLink(
|
|
127
|
+
{
|
|
128
|
+
openSettingsDialog: true,
|
|
129
|
+
title: 'Settings',
|
|
130
|
+
},
|
|
131
|
+
{ onClick }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
fireEvent.click(screen.getByText('Settings'));
|
|
135
|
+
|
|
136
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(dispatchEvent).toHaveBeenCalledWith(
|
|
138
|
+
expect.objectContaining({
|
|
139
|
+
type: 'tuturuuu:settings-dialog-open-intent',
|
|
140
|
+
})
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('passes the requested tab when opening settings from navigation', () => {
|
|
145
|
+
const dispatchEvent = vi.spyOn(window, 'dispatchEvent');
|
|
146
|
+
|
|
147
|
+
renderNavLink({
|
|
148
|
+
openSettingsDialog: { tab: 'workspace_billing' },
|
|
149
|
+
title: 'Billing',
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
fireEvent.click(screen.getByText('Billing'));
|
|
153
|
+
|
|
154
|
+
const event = dispatchEvent.mock.calls.find(
|
|
155
|
+
([candidate]) =>
|
|
156
|
+
candidate instanceof CustomEvent &&
|
|
157
|
+
candidate.type === 'tuturuuu:settings-dialog-open-intent'
|
|
158
|
+
)?.[0];
|
|
159
|
+
|
|
160
|
+
expect(event).toBeInstanceOf(CustomEvent);
|
|
161
|
+
expect((event as CustomEvent).detail).toEqual({
|
|
162
|
+
settingsTab: 'workspace_billing',
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -38,20 +38,51 @@ function matchesPathPrefix(targetPath: string, pathPrefix: string) {
|
|
|
38
38
|
return targetPath === pathPrefix || targetPath.startsWith(`${pathPrefix}/`);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function
|
|
41
|
+
function isAbsoluteHttpUrl(target: string) {
|
|
42
|
+
return /^https?:\/\//iu.test(target);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getComparablePath(
|
|
46
|
+
target?: string,
|
|
47
|
+
options: { external?: boolean } = {}
|
|
48
|
+
) {
|
|
42
49
|
if (!target) return null;
|
|
50
|
+
if (options.external) return null;
|
|
43
51
|
|
|
44
52
|
try {
|
|
45
53
|
const base =
|
|
46
54
|
typeof window === 'undefined'
|
|
47
55
|
? 'https://tuturuuu.local'
|
|
48
56
|
: window.location.origin;
|
|
49
|
-
|
|
57
|
+
const url = isAbsoluteHttpUrl(target)
|
|
58
|
+
? new URL(target)
|
|
59
|
+
: new URL(target, base);
|
|
60
|
+
|
|
61
|
+
if (isAbsoluteHttpUrl(target) && url.origin !== base) return null;
|
|
62
|
+
|
|
63
|
+
return url.pathname;
|
|
50
64
|
} catch {
|
|
51
65
|
return target.split(/[?#]/u)[0] || target;
|
|
52
66
|
}
|
|
53
67
|
}
|
|
54
68
|
|
|
69
|
+
function matchesNavigationTarget(
|
|
70
|
+
pathname: string,
|
|
71
|
+
target: string,
|
|
72
|
+
matchExact = false
|
|
73
|
+
) {
|
|
74
|
+
const isWildcard = target.endsWith('/*');
|
|
75
|
+
const normalizedTarget = isWildcard
|
|
76
|
+
? target.slice(0, -2).replace(/\/+$/u, '') || '/'
|
|
77
|
+
: target;
|
|
78
|
+
|
|
79
|
+
if (isWildcard) return matchesPathPrefix(pathname, normalizedTarget);
|
|
80
|
+
|
|
81
|
+
return matchExact
|
|
82
|
+
? pathname === normalizedTarget
|
|
83
|
+
: matchesPathPrefix(pathname, normalizedTarget);
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
interface NavLinkProps {
|
|
56
87
|
wsId: string;
|
|
57
88
|
link: NavLinkType;
|
|
@@ -60,6 +91,9 @@ interface NavLinkProps {
|
|
|
60
91
|
onClick: () => void;
|
|
61
92
|
}
|
|
62
93
|
|
|
94
|
+
const SETTINGS_DIALOG_OPEN_INTENT_EVENT =
|
|
95
|
+
'tuturuuu:settings-dialog-open-intent';
|
|
96
|
+
|
|
63
97
|
export function NavLink({
|
|
64
98
|
wsId,
|
|
65
99
|
link,
|
|
@@ -69,8 +103,19 @@ export function NavLink({
|
|
|
69
103
|
}: NavLinkProps) {
|
|
70
104
|
const t = useTranslations();
|
|
71
105
|
const pathname = usePathname();
|
|
72
|
-
const {
|
|
73
|
-
|
|
106
|
+
const {
|
|
107
|
+
title,
|
|
108
|
+
icon,
|
|
109
|
+
href,
|
|
110
|
+
children,
|
|
111
|
+
newTab,
|
|
112
|
+
onClick: onLinkClick,
|
|
113
|
+
openSettingsDialog,
|
|
114
|
+
} = link;
|
|
115
|
+
const childLinks = children?.filter((child): child is NavLinkType =>
|
|
116
|
+
Boolean(child)
|
|
117
|
+
);
|
|
118
|
+
const hasSubMenu = (childLinks?.length ?? 0) > 1;
|
|
74
119
|
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
|
|
75
120
|
|
|
76
121
|
// Recursive function to check if any nested child matches the pathname
|
|
@@ -78,10 +123,12 @@ export function NavLink({
|
|
|
78
123
|
return (
|
|
79
124
|
navLinks?.some((child) => {
|
|
80
125
|
const childTargets = [child?.href, ...(child?.aliases ?? [])]
|
|
81
|
-
.map(
|
|
126
|
+
.map((target) =>
|
|
127
|
+
getComparablePath(target, { external: child?.external })
|
|
128
|
+
)
|
|
82
129
|
.filter((target): target is string => Boolean(target));
|
|
83
130
|
const childMatches = childTargets.some((target) =>
|
|
84
|
-
|
|
131
|
+
matchesNavigationTarget(pathname, target, child?.matchExact)
|
|
85
132
|
);
|
|
86
133
|
|
|
87
134
|
if (childMatches) return true;
|
|
@@ -96,11 +143,11 @@ export function NavLink({
|
|
|
96
143
|
};
|
|
97
144
|
|
|
98
145
|
const activeTargets = [href, ...(link.aliases ?? [])]
|
|
99
|
-
.map(getComparablePath)
|
|
146
|
+
.map((target) => getComparablePath(target, { external: link.external }))
|
|
100
147
|
.filter((target): target is string => Boolean(target));
|
|
101
148
|
const isActive =
|
|
102
149
|
activeTargets.some((target) =>
|
|
103
|
-
|
|
150
|
+
matchesNavigationTarget(pathname, target, link.matchExact)
|
|
104
151
|
) ||
|
|
105
152
|
(children && hasActiveChild(children));
|
|
106
153
|
|
|
@@ -190,7 +237,7 @@ export function NavLink({
|
|
|
190
237
|
</Tooltip>
|
|
191
238
|
)}
|
|
192
239
|
|
|
193
|
-
{(
|
|
240
|
+
{(hasSubMenu || archivedItems.length > 0) &&
|
|
194
241
|
!preferenceArchiveAction &&
|
|
195
242
|
!preferenceQuickAction &&
|
|
196
243
|
!isDisabled && (
|
|
@@ -316,9 +363,20 @@ export function NavLink({
|
|
|
316
363
|
}
|
|
317
364
|
if (onLinkClick) {
|
|
318
365
|
onLinkClick();
|
|
319
|
-
} else if (
|
|
366
|
+
} else if (openSettingsDialog) {
|
|
367
|
+
event.preventDefault();
|
|
368
|
+
const detail =
|
|
369
|
+
typeof openSettingsDialog === 'object'
|
|
370
|
+
? { settingsTab: openSettingsDialog.tab }
|
|
371
|
+
: undefined;
|
|
372
|
+
|
|
373
|
+
window.dispatchEvent(
|
|
374
|
+
new CustomEvent(SETTINGS_DIALOG_OPEN_INTENT_EVENT, { detail })
|
|
375
|
+
);
|
|
376
|
+
onClick();
|
|
377
|
+
} else if (hasSubMenu) {
|
|
320
378
|
event.preventDefault();
|
|
321
|
-
onSubMenuClick(children, title);
|
|
379
|
+
onSubMenuClick(children ?? [], title);
|
|
322
380
|
} else if (href) {
|
|
323
381
|
if (shouldResolveQueryParamsFromConfig && !effectiveHref) {
|
|
324
382
|
event.preventDefault();
|