@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.
Files changed (94) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +6 -5
  3. package/src/components/ui/custom/__tests__/settings-dialog-search.test.ts +78 -0
  4. package/src/components/ui/custom/__tests__/settings-dialog-shell-compile-graph.test.ts +76 -0
  5. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +27 -1
  6. package/src/components/ui/custom/nav-link.test.tsx +165 -0
  7. package/src/components/ui/custom/nav-link.tsx +69 -11
  8. package/src/components/ui/custom/navigation.tsx +1 -0
  9. package/src/components/ui/custom/settings/task-settings.tsx +104 -0
  10. package/src/components/ui/custom/settings-dialog-search-loader.d.ts +5 -0
  11. package/src/components/ui/custom/settings-dialog-search-loader.js +3 -0
  12. package/src/components/ui/custom/settings-dialog-search.ts +75 -0
  13. package/src/components/ui/custom/settings-dialog-shell.tsx +63 -27
  14. package/src/components/ui/custom/workspace-select-helpers.ts +23 -0
  15. package/src/components/ui/custom/workspace-select.tsx +17 -16
  16. package/src/components/ui/tu-do/boards/__tests__/board-share-dialog.test.tsx +16 -0
  17. package/src/components/ui/tu-do/boards/__tests__/task-board-form.test.tsx +12 -0
  18. package/src/components/ui/tu-do/boards/board-share-dialog.tsx +4 -328
  19. package/src/components/ui/tu-do/boards/board-share-settings-panel.tsx +351 -0
  20. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +50 -37
  21. package/src/components/ui/tu-do/boards/boardId/enhanced-task-list.tsx +7 -0
  22. package/src/components/ui/tu-do/boards/boardId/kanban/bulk/bulk-operation-types.ts +3 -3
  23. package/src/components/ui/tu-do/boards/boardId/kanban/data/use-bulk-resources.ts +59 -5
  24. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/drag-preview.tsx +20 -1
  25. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +263 -21
  26. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +133 -14
  27. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-deadline-panels.tsx +112 -54
  28. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-skeleton.tsx +8 -2
  29. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +29 -14
  30. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +24 -1
  31. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +7 -0
  32. package/src/components/ui/tu-do/boards/boardId/task-card/measured-task-card.tsx +7 -1
  33. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +20 -0
  34. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +10 -0
  35. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +80 -8
  36. package/src/components/ui/tu-do/boards/boardId/task-list.tsx +15 -0
  37. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +9 -0
  38. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +9 -0
  39. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-toolbar.tsx +9 -0
  40. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +35 -3
  41. package/src/components/ui/tu-do/boards/form.tsx +1 -1
  42. package/src/components/ui/tu-do/hooks/__tests__/useTaskLabelManagement.test.tsx +48 -0
  43. package/src/components/ui/tu-do/hooks/__tests__/useTaskProjectManagement.test.tsx +144 -0
  44. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +7 -0
  45. package/src/components/ui/tu-do/hooks/useTaskLabelManagement.ts +115 -106
  46. package/src/components/ui/tu-do/hooks/useTaskProjectManagement.ts +115 -122
  47. package/src/components/ui/tu-do/progress/task-progress-import-panel.tsx +60 -0
  48. package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +156 -0
  49. package/src/components/ui/tu-do/progress/task-progress-page.tsx +348 -0
  50. package/src/components/ui/tu-do/progress/task-progress-panels.tsx +301 -0
  51. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +26 -0
  52. package/src/components/ui/tu-do/shared/__tests__/assignee-select.test.tsx +81 -10
  53. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +116 -1
  54. package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +38 -0
  55. package/src/components/ui/tu-do/shared/__tests__/board-switcher.test.tsx +128 -7
  56. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +222 -9
  57. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +21 -0
  58. package/src/components/ui/tu-do/shared/__tests__/task-cache-patches.test.ts +147 -0
  59. package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +3 -0
  60. package/src/components/ui/tu-do/shared/assignee-select.tsx +77 -26
  61. package/src/components/ui/tu-do/shared/board-client.tsx +14 -4
  62. package/src/components/ui/tu-do/shared/board-header.tsx +8 -1
  63. package/src/components/ui/tu-do/shared/board-switcher.tsx +70 -38
  64. package/src/components/ui/tu-do/shared/board-user-presence-avatars.tsx +18 -12
  65. package/src/components/ui/tu-do/shared/board-views.tsx +49 -69
  66. package/src/components/ui/tu-do/shared/list-view.tsx +21 -3
  67. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
  68. package/src/components/ui/tu-do/shared/task-cache-patches.ts +394 -0
  69. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +21 -1
  70. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +5 -1
  71. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +25 -2
  72. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +7 -1
  73. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-data.ts +79 -10
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +76 -77
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.test.tsx +63 -0
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-relationships.ts +78 -69
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/personal-overrides-section.tsx +28 -8
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/dependencies-section.tsx +14 -3
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/parent-section.tsx +6 -1
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/related-section.tsx +6 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/subtasks-section.tsx +6 -1
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/relationships/types/task-relationships.types.ts +8 -0
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +8 -1
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +150 -0
  85. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +61 -35
  86. package/src/components/ui/tu-do/shared/task-edit-dialog/task-relationships-properties.tsx +44 -2
  87. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +9 -0
  88. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +11 -0
  89. package/src/components/ui/tu-do/shared/use-progressive-board-loader.ts +2 -0
  90. package/src/hooks/__tests__/useBoardPresence.test.tsx +191 -0
  91. package/src/hooks/__tests__/useBoardRealtime.test.tsx +24 -144
  92. package/src/hooks/useBoardPresence.ts +364 -0
  93. package/src/hooks/useBoardRealtimeEventHandler.ts +34 -90
  94. 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.9.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.6.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.10.0",
91
+ "@tuturuuu/internal-api": "0.11.0",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
- "@tuturuuu/utils": "0.9.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.11.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 { mergeWorkspaceSelectWorkspaces } from '../workspace-select-helpers';
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 getComparablePath(target?: string) {
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
- return new URL(target, base).pathname;
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 { title, icon, href, children, newTab, onClick: onLinkClick } = link;
73
- const hasChildren = children && children.length > 0;
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(getComparablePath)
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
- child?.matchExact ? pathname === target : pathname.startsWith(target)
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
- link.matchExact ? pathname === target : pathname.startsWith(target)
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
- {(hasChildren || archivedItems.length > 0) &&
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 (hasChildren) {
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();
@@ -30,6 +30,7 @@ export interface NavLink {
30
30
  tempDisabled?: boolean;
31
31
  isBack?: boolean;
32
32
  onClick?: () => void;
33
+ openSettingsDialog?: boolean | { tab?: string };
33
34
  children?: (NavLink | null)[];
34
35
  aliases?: string[];
35
36
  requiredWorkspaceTier?: {