@tuturuuu/ui 0.9.0 → 0.11.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 +45 -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/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
- package/src/components/ui/finance/invoices/hooks.ts +11 -2
- package/src/components/ui/finance/invoices/internal-api.ts +5 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
- package/src/components/ui/finance/invoices/utils.test.ts +82 -0
- package/src/components/ui/finance/invoices/utils.ts +240 -7
- 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 +123 -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 +58 -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 +16 -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 +58 -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-description-editor.tsx +3 -0
- 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/description-versions.test.ts +97 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
- 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-activity-section.tsx +56 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -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/utils.test.ts +100 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +390 -34
- 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/components/ui/tu-do/templates/task-template-api.ts +142 -0
- package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
- package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
- package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
- 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
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import {
|
|
5
|
+
TASK_NAVIGATION_GOALS_CONFIG_ID,
|
|
6
|
+
TASK_NAVIGATION_IMPORT_CONFIG_ID,
|
|
7
|
+
TASK_NAVIGATION_LEADERBOARDS_CONFIG_ID,
|
|
8
|
+
TASK_NAVIGATION_PROGRESS_CONFIG_ID,
|
|
9
|
+
TASK_NAVIGATION_STATS_CONFIG_ID,
|
|
10
|
+
} from '@tuturuuu/internal-api/users';
|
|
4
11
|
import type { Workspace } from '@tuturuuu/types';
|
|
5
12
|
import { SettingItemTab } from '@tuturuuu/ui/custom/settings-item-tab';
|
|
6
13
|
import {
|
|
@@ -85,6 +92,36 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
85
92
|
isLoading: showReviewDueDatesLoading,
|
|
86
93
|
isPending: showReviewDueDatesPending,
|
|
87
94
|
} = useUserBooleanConfig(TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID, false);
|
|
95
|
+
const {
|
|
96
|
+
value: showTaskProgressNavigation,
|
|
97
|
+
setValue: setShowTaskProgressNavigation,
|
|
98
|
+
isLoading: showTaskProgressNavigationLoading,
|
|
99
|
+
isPending: showTaskProgressNavigationPending,
|
|
100
|
+
} = useUserBooleanConfig(TASK_NAVIGATION_PROGRESS_CONFIG_ID, false);
|
|
101
|
+
const {
|
|
102
|
+
value: showTaskGoalsNavigation,
|
|
103
|
+
setValue: setShowTaskGoalsNavigation,
|
|
104
|
+
isLoading: showTaskGoalsNavigationLoading,
|
|
105
|
+
isPending: showTaskGoalsNavigationPending,
|
|
106
|
+
} = useUserBooleanConfig(TASK_NAVIGATION_GOALS_CONFIG_ID, false);
|
|
107
|
+
const {
|
|
108
|
+
value: showTaskStatsNavigation,
|
|
109
|
+
setValue: setShowTaskStatsNavigation,
|
|
110
|
+
isLoading: showTaskStatsNavigationLoading,
|
|
111
|
+
isPending: showTaskStatsNavigationPending,
|
|
112
|
+
} = useUserBooleanConfig(TASK_NAVIGATION_STATS_CONFIG_ID, false);
|
|
113
|
+
const {
|
|
114
|
+
value: showTaskLeaderboardsNavigation,
|
|
115
|
+
setValue: setShowTaskLeaderboardsNavigation,
|
|
116
|
+
isLoading: showTaskLeaderboardsNavigationLoading,
|
|
117
|
+
isPending: showTaskLeaderboardsNavigationPending,
|
|
118
|
+
} = useUserBooleanConfig(TASK_NAVIGATION_LEADERBOARDS_CONFIG_ID, false);
|
|
119
|
+
const {
|
|
120
|
+
value: showTaskImportNavigation,
|
|
121
|
+
setValue: setShowTaskImportNavigation,
|
|
122
|
+
isLoading: showTaskImportNavigationLoading,
|
|
123
|
+
isPending: showTaskImportNavigationPending,
|
|
124
|
+
} = useUserBooleanConfig(TASK_NAVIGATION_IMPORT_CONFIG_ID, false);
|
|
88
125
|
const {
|
|
89
126
|
value: soundEffectsEnabled,
|
|
90
127
|
setValue: setSoundEffectsEnabled,
|
|
@@ -346,6 +383,73 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
346
383
|
/>
|
|
347
384
|
</SettingItemTab>
|
|
348
385
|
<Separator />
|
|
386
|
+
<SettingItemTab
|
|
387
|
+
title={t('navigation_progress')}
|
|
388
|
+
description={t('navigation_progress_description')}
|
|
389
|
+
>
|
|
390
|
+
<Switch
|
|
391
|
+
checked={showTaskProgressNavigation}
|
|
392
|
+
onCheckedChange={setShowTaskProgressNavigation}
|
|
393
|
+
disabled={
|
|
394
|
+
showTaskProgressNavigationLoading ||
|
|
395
|
+
showTaskProgressNavigationPending
|
|
396
|
+
}
|
|
397
|
+
/>
|
|
398
|
+
</SettingItemTab>
|
|
399
|
+
<Separator />
|
|
400
|
+
<SettingItemTab
|
|
401
|
+
title={t('navigation_goals')}
|
|
402
|
+
description={t('navigation_goals_description')}
|
|
403
|
+
>
|
|
404
|
+
<Switch
|
|
405
|
+
checked={showTaskGoalsNavigation}
|
|
406
|
+
onCheckedChange={setShowTaskGoalsNavigation}
|
|
407
|
+
disabled={
|
|
408
|
+
showTaskGoalsNavigationLoading || showTaskGoalsNavigationPending
|
|
409
|
+
}
|
|
410
|
+
/>
|
|
411
|
+
</SettingItemTab>
|
|
412
|
+
<Separator />
|
|
413
|
+
<SettingItemTab
|
|
414
|
+
title={t('navigation_stats')}
|
|
415
|
+
description={t('navigation_stats_description')}
|
|
416
|
+
>
|
|
417
|
+
<Switch
|
|
418
|
+
checked={showTaskStatsNavigation}
|
|
419
|
+
onCheckedChange={setShowTaskStatsNavigation}
|
|
420
|
+
disabled={
|
|
421
|
+
showTaskStatsNavigationLoading || showTaskStatsNavigationPending
|
|
422
|
+
}
|
|
423
|
+
/>
|
|
424
|
+
</SettingItemTab>
|
|
425
|
+
<Separator />
|
|
426
|
+
<SettingItemTab
|
|
427
|
+
title={t('navigation_leaderboards')}
|
|
428
|
+
description={t('navigation_leaderboards_description')}
|
|
429
|
+
>
|
|
430
|
+
<Switch
|
|
431
|
+
checked={showTaskLeaderboardsNavigation}
|
|
432
|
+
onCheckedChange={setShowTaskLeaderboardsNavigation}
|
|
433
|
+
disabled={
|
|
434
|
+
showTaskLeaderboardsNavigationLoading ||
|
|
435
|
+
showTaskLeaderboardsNavigationPending
|
|
436
|
+
}
|
|
437
|
+
/>
|
|
438
|
+
</SettingItemTab>
|
|
439
|
+
<Separator />
|
|
440
|
+
<SettingItemTab
|
|
441
|
+
title={t('navigation_import')}
|
|
442
|
+
description={t('navigation_import_description')}
|
|
443
|
+
>
|
|
444
|
+
<Switch
|
|
445
|
+
checked={showTaskImportNavigation}
|
|
446
|
+
onCheckedChange={setShowTaskImportNavigation}
|
|
447
|
+
disabled={
|
|
448
|
+
showTaskImportNavigationLoading || showTaskImportNavigationPending
|
|
449
|
+
}
|
|
450
|
+
/>
|
|
451
|
+
</SettingItemTab>
|
|
452
|
+
<Separator />
|
|
349
453
|
<SettingItemTab
|
|
350
454
|
title={t('submit_shortcut')}
|
|
351
455
|
description={t('submit_shortcut_description')}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { removeAccents } from '@tuturuuu/utils/text-helper';
|
|
2
|
+
import type {
|
|
3
|
+
SettingsNavGroup,
|
|
4
|
+
SettingsNavItem,
|
|
5
|
+
} from './settings-dialog-shell';
|
|
6
|
+
|
|
7
|
+
type IndexedSettingsNavItem = {
|
|
8
|
+
groupLabel: string;
|
|
9
|
+
item: SettingsNavItem;
|
|
10
|
+
normalizedSearchText: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function normalizeSearchText(value: string) {
|
|
14
|
+
return removeAccents(value.toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getItemSearchText(groupLabel: string, item: SettingsNavItem) {
|
|
18
|
+
return [
|
|
19
|
+
groupLabel,
|
|
20
|
+
item.name,
|
|
21
|
+
item.label,
|
|
22
|
+
item.description,
|
|
23
|
+
...(item.keywords ?? []),
|
|
24
|
+
...(item.aliases ?? []),
|
|
25
|
+
...(item.searchLabels ?? []),
|
|
26
|
+
]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join(' ');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createSettingsSearchEngine(navItems: SettingsNavGroup[]) {
|
|
32
|
+
const indexedItems: IndexedSettingsNavItem[] = navItems.flatMap((group) =>
|
|
33
|
+
group.items.map((item) => ({
|
|
34
|
+
groupLabel: group.label,
|
|
35
|
+
item,
|
|
36
|
+
normalizedSearchText: normalizeSearchText(
|
|
37
|
+
getItemSearchText(group.label, item)
|
|
38
|
+
),
|
|
39
|
+
}))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const allItems = indexedItems.map(({ item }) => item);
|
|
43
|
+
|
|
44
|
+
function search(query: string) {
|
|
45
|
+
const normalizedQuery = normalizeSearchText(query.trim());
|
|
46
|
+
if (!normalizedQuery) return navItems;
|
|
47
|
+
|
|
48
|
+
const matchesByName = new Set(
|
|
49
|
+
indexedItems
|
|
50
|
+
.filter(({ normalizedSearchText }) =>
|
|
51
|
+
normalizedSearchText.includes(normalizedQuery)
|
|
52
|
+
)
|
|
53
|
+
.map(({ item }) => item.name)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return navItems
|
|
57
|
+
.map((group) => ({
|
|
58
|
+
...group,
|
|
59
|
+
items: group.items.filter((item) => matchesByName.has(item.name)),
|
|
60
|
+
}))
|
|
61
|
+
.filter((group) => group.items.length > 0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getEnabledItems(query = '') {
|
|
65
|
+
return search(query)
|
|
66
|
+
.flatMap((group) => group.items)
|
|
67
|
+
.filter((item) => !item.disabled);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
allItems,
|
|
72
|
+
getEnabledItems,
|
|
73
|
+
search,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -53,10 +53,11 @@ import {
|
|
|
53
53
|
SidebarProvider,
|
|
54
54
|
} from '@tuturuuu/ui/sidebar';
|
|
55
55
|
import { cn } from '@tuturuuu/utils/format';
|
|
56
|
-
import { removeAccents } from '@tuturuuu/utils/text-helper';
|
|
57
56
|
import { useTranslations } from 'next-intl';
|
|
58
57
|
import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
|
|
59
58
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
59
|
+
import type { createSettingsSearchEngine } from './settings-dialog-search';
|
|
60
|
+
import { loadSettingsSearchEngine } from './settings-dialog-search-loader';
|
|
60
61
|
|
|
61
62
|
export interface SettingsNavItem {
|
|
62
63
|
name: string;
|
|
@@ -65,6 +66,8 @@ export interface SettingsNavItem {
|
|
|
65
66
|
description?: string;
|
|
66
67
|
disabled?: boolean;
|
|
67
68
|
keywords?: string[];
|
|
69
|
+
aliases?: string[];
|
|
70
|
+
searchLabels?: string[];
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export interface SettingsNavGroup {
|
|
@@ -93,6 +96,9 @@ export interface SettingsDialogShellProps {
|
|
|
93
96
|
children: ReactNode;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
type SettingsSearchEngine = ReturnType<typeof createSettingsSearchEngine>;
|
|
100
|
+
type SettingsSearchEngineFactory = typeof createSettingsSearchEngine;
|
|
101
|
+
|
|
96
102
|
function isEditableShortcutTarget(target: EventTarget | null) {
|
|
97
103
|
if (!(target instanceof HTMLElement)) return false;
|
|
98
104
|
|
|
@@ -127,12 +133,20 @@ export function SettingsDialogShell({
|
|
|
127
133
|
const isMobile = useIsMobile();
|
|
128
134
|
const desktopSearchInputRef = useRef<HTMLInputElement>(null);
|
|
129
135
|
const mobileSearchInputRef = useRef<HTMLInputElement>(null);
|
|
136
|
+
const searchEngineLoadRef = useRef<Promise<void> | null>(null);
|
|
130
137
|
const [searchQuery, setSearchQuery] = useState('');
|
|
138
|
+
const [searchEngineFactory, setSearchEngineFactory] =
|
|
139
|
+
useState<SettingsSearchEngineFactory | null>(null);
|
|
131
140
|
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
132
141
|
|
|
142
|
+
const searchEngine = useMemo<SettingsSearchEngine | null>(
|
|
143
|
+
() => searchEngineFactory?.(navItems) ?? null,
|
|
144
|
+
[navItems, searchEngineFactory]
|
|
145
|
+
);
|
|
146
|
+
|
|
133
147
|
const allNavItems = useMemo(
|
|
134
|
-
() => navItems.flatMap((group) => group.items),
|
|
135
|
-
[navItems]
|
|
148
|
+
() => searchEngine?.allItems ?? navItems.flatMap((group) => group.items),
|
|
149
|
+
[navItems, searchEngine]
|
|
136
150
|
);
|
|
137
151
|
|
|
138
152
|
const activeGroup = navItems.find((group) =>
|
|
@@ -144,26 +158,39 @@ export function SettingsDialogShell({
|
|
|
144
158
|
allNavItems.find((item) => !item.disabled) ||
|
|
145
159
|
allNavItems[0];
|
|
146
160
|
|
|
147
|
-
const
|
|
148
|
-
.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
removeAccents(keyword.toLowerCase()).includes(normalizedQuery)
|
|
159
|
-
)
|
|
160
|
-
);
|
|
161
|
-
return { ...group, items: filteredItems };
|
|
162
|
-
})
|
|
163
|
-
.filter((group) => group.items.length > 0);
|
|
161
|
+
const ensureSearchEngine = useCallback(() => {
|
|
162
|
+
if (searchEngineFactory || searchEngineLoadRef.current) return;
|
|
163
|
+
|
|
164
|
+
searchEngineLoadRef.current = loadSettingsSearchEngine()
|
|
165
|
+
.then((module) => {
|
|
166
|
+
setSearchEngineFactory(() => module.createSettingsSearchEngine);
|
|
167
|
+
})
|
|
168
|
+
.finally(() => {
|
|
169
|
+
searchEngineLoadRef.current = null;
|
|
170
|
+
});
|
|
171
|
+
}, [searchEngineFactory]);
|
|
164
172
|
|
|
165
|
-
const
|
|
166
|
-
|
|
173
|
+
const filteredNavItems = useMemo(
|
|
174
|
+
() =>
|
|
175
|
+
searchQuery ? (searchEngine?.search(searchQuery) ?? navItems) : navItems,
|
|
176
|
+
[navItems, searchEngine, searchQuery]
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const filteredEnabledItems = useMemo(
|
|
180
|
+
() =>
|
|
181
|
+
searchEngine?.getEnabledItems(searchQuery) ??
|
|
182
|
+
filteredNavItems.flatMap((group) =>
|
|
183
|
+
group.items.filter((item) => !item.disabled)
|
|
184
|
+
),
|
|
185
|
+
[filteredNavItems, searchEngine, searchQuery]
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const updateSearchQuery = useCallback(
|
|
189
|
+
(value: string) => {
|
|
190
|
+
if (value) ensureSearchEngine();
|
|
191
|
+
setSearchQuery(value);
|
|
192
|
+
},
|
|
193
|
+
[ensureSearchEngine]
|
|
167
194
|
);
|
|
168
195
|
|
|
169
196
|
const isGroupExpandedByDefault = (groupLabel: string, index: number) => {
|
|
@@ -175,14 +202,16 @@ export function SettingsDialogShell({
|
|
|
175
202
|
const focusSearch = useCallback(() => {
|
|
176
203
|
if (isMobile) {
|
|
177
204
|
setMobileNavOpen(true);
|
|
205
|
+
ensureSearchEngine();
|
|
178
206
|
requestAnimationFrame(() => {
|
|
179
207
|
mobileSearchInputRef.current?.focus();
|
|
180
208
|
});
|
|
181
209
|
return;
|
|
182
210
|
}
|
|
183
211
|
|
|
212
|
+
ensureSearchEngine();
|
|
184
213
|
desktopSearchInputRef.current?.focus();
|
|
185
|
-
}, [isMobile]);
|
|
214
|
+
}, [ensureSearchEngine, isMobile]);
|
|
186
215
|
|
|
187
216
|
const changeActiveItem = useCallback(
|
|
188
217
|
(targetIndex: number) => {
|
|
@@ -305,7 +334,8 @@ export function SettingsDialogShell({
|
|
|
305
334
|
placeholder={t('settings.search_settings_placeholder')}
|
|
306
335
|
className="bg-background pl-8"
|
|
307
336
|
value={searchQuery}
|
|
308
|
-
onChange={(e) =>
|
|
337
|
+
onChange={(e) => updateSearchQuery(e.target.value)}
|
|
338
|
+
onFocus={ensureSearchEngine}
|
|
309
339
|
/>
|
|
310
340
|
</div>
|
|
311
341
|
</SidebarHeader>
|
|
@@ -403,22 +433,28 @@ export function SettingsDialogShell({
|
|
|
403
433
|
{t('search.search')}
|
|
404
434
|
</DrawerDescription>
|
|
405
435
|
</DrawerHeader>
|
|
406
|
-
<Command
|
|
436
|
+
<Command
|
|
437
|
+
className="rounded-none border-0"
|
|
438
|
+
shouldFilter={false}
|
|
439
|
+
>
|
|
407
440
|
<CommandInput
|
|
408
441
|
ref={mobileSearchInputRef}
|
|
442
|
+
value={searchQuery}
|
|
443
|
+
onFocus={ensureSearchEngine}
|
|
444
|
+
onValueChange={updateSearchQuery}
|
|
409
445
|
placeholder={t('settings.search_settings_placeholder')}
|
|
410
446
|
/>
|
|
411
447
|
<CommandList className="max-h-[50vh]">
|
|
412
448
|
<CommandEmpty>
|
|
413
449
|
{t('common.no_results_found')}
|
|
414
450
|
</CommandEmpty>
|
|
415
|
-
{
|
|
451
|
+
{filteredNavItems.map((group) => (
|
|
416
452
|
<CommandGroup key={group.label} heading={group.label}>
|
|
417
453
|
{group.items.map((item) => (
|
|
418
454
|
<CommandItem
|
|
419
455
|
disabled={item.disabled}
|
|
420
456
|
key={item.name}
|
|
421
|
-
value={`${group.label} ${item.label} ${item.keywords?.join(' ') || ''}`}
|
|
457
|
+
value={`${group.label} ${item.label} ${item.description || ''} ${item.keywords?.join(' ') || ''} ${item.aliases?.join(' ') || ''} ${item.searchLabels?.join(' ') || ''}`}
|
|
422
458
|
onSelect={() => {
|
|
423
459
|
if (item.disabled) return;
|
|
424
460
|
onActiveTabChange(item.name);
|
|
@@ -18,3 +18,26 @@ export function mergeWorkspaceSelectWorkspaces(
|
|
|
18
18
|
|
|
19
19
|
return [...workspaceList, currentWorkspaceFallback];
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
export function normalizeWorkspaceSwitchPath(
|
|
23
|
+
pathname: string,
|
|
24
|
+
nextSlug: string
|
|
25
|
+
) {
|
|
26
|
+
const taskBoardsPath = `/${nextSlug}/tasks/boards`;
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
pathname === taskBoardsPath ||
|
|
30
|
+
pathname.startsWith(`${taskBoardsPath}/`)
|
|
31
|
+
) {
|
|
32
|
+
return `/${nextSlug}/tasks`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const uuidRegex =
|
|
36
|
+
/\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
|
37
|
+
|
|
38
|
+
if (uuidRegex.test(pathname) && pathname !== `/${nextSlug}`) {
|
|
39
|
+
return pathname.replace(uuidRegex, '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return pathname;
|
|
43
|
+
}
|
|
@@ -66,7 +66,10 @@ import {
|
|
|
66
66
|
import { Input } from '../input';
|
|
67
67
|
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
|
68
68
|
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
69
|
-
import {
|
|
69
|
+
import {
|
|
70
|
+
mergeWorkspaceSelectWorkspaces,
|
|
71
|
+
normalizeWorkspaceSwitchPath,
|
|
72
|
+
} from './workspace-select-helpers';
|
|
70
73
|
|
|
71
74
|
const FormSchema = z.object({
|
|
72
75
|
name: z.string().min(1).max(100),
|
|
@@ -438,22 +441,20 @@ export function WorkspaceSelect({
|
|
|
438
441
|
const selectedTeam = groups
|
|
439
442
|
.flatMap((group) => group.teams)
|
|
440
443
|
.find((team) => team.value === nextSlug);
|
|
441
|
-
|
|
442
|
-
selectedTeam?.accessType === 'guest' && selectedTeam.guestLandingPath
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
444
|
+
const usesGuestLandingPath =
|
|
445
|
+
selectedTeam?.accessType === 'guest' && selectedTeam.guestLandingPath;
|
|
446
|
+
let newPathname = usesGuestLandingPath
|
|
447
|
+
? `/${nextSlug}${selectedTeam.guestLandingPath}`
|
|
448
|
+
: pathname
|
|
449
|
+
? (resolveNextPathname?.({
|
|
450
|
+
currentPathname: pathname,
|
|
451
|
+
nextSlug,
|
|
452
|
+
}) ?? pathname.replace(/^\/[^/]+/, `/${nextSlug}`))
|
|
453
|
+
: undefined;
|
|
454
|
+
if (newPathname && !usesGuestLandingPath) {
|
|
455
|
+
newPathname = normalizeWorkspaceSwitchPath(newPathname, nextSlug);
|
|
456
|
+
}
|
|
450
457
|
if (newPathname) {
|
|
451
|
-
// Regex to match a UUID at the end of the string, with or without dashes
|
|
452
|
-
const uuidRegex =
|
|
453
|
-
/\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
|
|
454
|
-
// Remove the UUID if present, and the current path is not /:wsId
|
|
455
|
-
if (uuidRegex.test(newPathname) && newPathname !== `/${nextSlug}`)
|
|
456
|
-
newPathname = newPathname.replace(uuidRegex, '');
|
|
457
458
|
router.push(newPathname);
|
|
458
459
|
}
|
|
459
460
|
};
|
|
@@ -54,6 +54,8 @@ interface SubscriptionAttendanceSummaryProps {
|
|
|
54
54
|
};
|
|
55
55
|
totalSessions: number;
|
|
56
56
|
attendanceRate: number;
|
|
57
|
+
coverageRangeLabel: string;
|
|
58
|
+
prepaidMonthCount: number;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export function SubscriptionAttendanceSummary({
|
|
@@ -73,6 +75,8 @@ export function SubscriptionAttendanceSummary({
|
|
|
73
75
|
attendanceStats,
|
|
74
76
|
totalSessions,
|
|
75
77
|
attendanceRate,
|
|
78
|
+
coverageRangeLabel,
|
|
79
|
+
prepaidMonthCount,
|
|
76
80
|
}: SubscriptionAttendanceSummaryProps): React.ReactElement {
|
|
77
81
|
const t = useTranslations();
|
|
78
82
|
|
|
@@ -82,6 +86,8 @@ export function SubscriptionAttendanceSummary({
|
|
|
82
86
|
? selectedMonth
|
|
83
87
|
: (availableMonths[0]?.value ?? selectedMonth);
|
|
84
88
|
const monthLabel = formatMonthLabel(resolvedSelectedMonth, locale);
|
|
89
|
+
const displayedRangeLabel =
|
|
90
|
+
prepaidMonthCount > 1 ? coverageRangeLabel : monthLabel;
|
|
85
91
|
|
|
86
92
|
return (
|
|
87
93
|
<Card>
|
|
@@ -98,17 +104,25 @@ export function SubscriptionAttendanceSummary({
|
|
|
98
104
|
<CardDescription className="flex flex-col gap-1">
|
|
99
105
|
{selectedGroupIds.length === 1
|
|
100
106
|
? t('ws-invoices.attendance_for_month', {
|
|
101
|
-
month:
|
|
107
|
+
month: displayedRangeLabel,
|
|
102
108
|
})
|
|
103
109
|
: t('ws-invoices.combined_attendance_for_month', {
|
|
104
110
|
count: selectedGroupIds.length,
|
|
105
|
-
month:
|
|
106
|
-
default: `Combined attendance from ${selectedGroupIds.length} groups for ${
|
|
111
|
+
month: displayedRangeLabel,
|
|
112
|
+
default: `Combined attendance from ${selectedGroupIds.length} groups for ${displayedRangeLabel}`,
|
|
107
113
|
})}
|
|
108
114
|
<span className="text-muted-foreground text-xs">
|
|
109
115
|
{isSelectedMonthPaid
|
|
110
|
-
? t(
|
|
111
|
-
|
|
116
|
+
? t(
|
|
117
|
+
prepaidMonthCount > 1
|
|
118
|
+
? 'ws-invoices.all_groups_paid_for_range'
|
|
119
|
+
: 'ws-invoices.all_groups_paid_for_month'
|
|
120
|
+
)
|
|
121
|
+
: t(
|
|
122
|
+
prepaidMonthCount > 1
|
|
123
|
+
? 'ws-invoices.some_groups_unpaid_for_range'
|
|
124
|
+
: 'ws-invoices.some_groups_unpaid_for_month'
|
|
125
|
+
)}
|
|
112
126
|
</span>
|
|
113
127
|
</CardDescription>
|
|
114
128
|
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { SubscriptionPrepaidControls } from './subscription-prepaid-controls';
|
|
4
|
+
|
|
5
|
+
vi.mock('next-intl', () => ({
|
|
6
|
+
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
|
|
7
|
+
const messages: Record<string, string> = {
|
|
8
|
+
'ws-invoices.coverage_range': 'Coverage',
|
|
9
|
+
'ws-invoices.prepaid_month_count': 'Months to cover',
|
|
10
|
+
'ws-invoices.prepaid_month_count_many': '{count} months',
|
|
11
|
+
'ws-invoices.prepaid_month_count_one': '1 month',
|
|
12
|
+
'ws-invoices.prepaid_months': 'Prepaid months',
|
|
13
|
+
'ws-invoices.prepaid_months_description':
|
|
14
|
+
'Choose how many months this subscription invoice should cover.',
|
|
15
|
+
'ws-invoices.valid_until': 'Valid until',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return (messages[key] ?? key).replace(
|
|
19
|
+
'{count}',
|
|
20
|
+
String(values?.count ?? '')
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('SubscriptionPrepaidControls', () => {
|
|
26
|
+
it('renders the prepaid count selector and coverage summary', () => {
|
|
27
|
+
render(
|
|
28
|
+
<SubscriptionPrepaidControls
|
|
29
|
+
coverageRangeLabel="June 2026 - August 2026"
|
|
30
|
+
prepaidMonthCount={3}
|
|
31
|
+
validUntilLabel="September 2026"
|
|
32
|
+
onPrepaidMonthCountChange={vi.fn()}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByText('Prepaid months')).toBeInTheDocument();
|
|
37
|
+
expect(screen.getByText('Months to cover')).toBeInTheDocument();
|
|
38
|
+
expect(screen.getByText('June 2026 - August 2026')).toBeInTheDocument();
|
|
39
|
+
expect(screen.getByText('September 2026')).toBeInTheDocument();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardContent,
|
|
6
|
+
CardDescription,
|
|
7
|
+
CardHeader,
|
|
8
|
+
CardTitle,
|
|
9
|
+
} from '@tuturuuu/ui/card';
|
|
10
|
+
import { Label } from '@tuturuuu/ui/label';
|
|
11
|
+
import {
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from '@tuturuuu/ui/select';
|
|
18
|
+
import { useTranslations } from 'next-intl';
|
|
19
|
+
import type React from 'react';
|
|
20
|
+
import { MAX_PREPAID_MONTH_COUNT } from '../utils';
|
|
21
|
+
|
|
22
|
+
interface SubscriptionPrepaidControlsProps {
|
|
23
|
+
coverageRangeLabel: string;
|
|
24
|
+
onPrepaidMonthCountChange: (monthCount: number) => void;
|
|
25
|
+
prepaidMonthCount: number;
|
|
26
|
+
validUntilLabel: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PREPAID_MONTH_OPTIONS = Array.from(
|
|
30
|
+
{ length: MAX_PREPAID_MONTH_COUNT },
|
|
31
|
+
(_, index) => index + 1
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
export function SubscriptionPrepaidControls({
|
|
35
|
+
coverageRangeLabel,
|
|
36
|
+
onPrepaidMonthCountChange,
|
|
37
|
+
prepaidMonthCount,
|
|
38
|
+
validUntilLabel,
|
|
39
|
+
}: SubscriptionPrepaidControlsProps): React.ReactElement {
|
|
40
|
+
const t = useTranslations();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Card>
|
|
44
|
+
<CardHeader>
|
|
45
|
+
<CardTitle>{t('ws-invoices.prepaid_months')}</CardTitle>
|
|
46
|
+
<CardDescription>
|
|
47
|
+
{t('ws-invoices.prepaid_months_description')}
|
|
48
|
+
</CardDescription>
|
|
49
|
+
</CardHeader>
|
|
50
|
+
<CardContent className="space-y-4">
|
|
51
|
+
<div className="space-y-2">
|
|
52
|
+
<Label htmlFor="prepaid-month-count">
|
|
53
|
+
{t('ws-invoices.prepaid_month_count')}
|
|
54
|
+
</Label>
|
|
55
|
+
<Select
|
|
56
|
+
value={String(prepaidMonthCount)}
|
|
57
|
+
onValueChange={(value) => onPrepaidMonthCountChange(Number(value))}
|
|
58
|
+
>
|
|
59
|
+
<SelectTrigger id="prepaid-month-count">
|
|
60
|
+
<SelectValue />
|
|
61
|
+
</SelectTrigger>
|
|
62
|
+
<SelectContent>
|
|
63
|
+
{PREPAID_MONTH_OPTIONS.map((monthCount) => (
|
|
64
|
+
<SelectItem key={monthCount} value={String(monthCount)}>
|
|
65
|
+
{monthCount === 1
|
|
66
|
+
? t('ws-invoices.prepaid_month_count_one')
|
|
67
|
+
: t('ws-invoices.prepaid_month_count_many', {
|
|
68
|
+
count: monthCount,
|
|
69
|
+
})}
|
|
70
|
+
</SelectItem>
|
|
71
|
+
))}
|
|
72
|
+
</SelectContent>
|
|
73
|
+
</Select>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 text-sm">
|
|
77
|
+
<div className="flex items-center justify-between gap-3">
|
|
78
|
+
<span className="text-muted-foreground">
|
|
79
|
+
{t('ws-invoices.coverage_range')}
|
|
80
|
+
</span>
|
|
81
|
+
<span className="text-right font-medium">{coverageRangeLabel}</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="flex items-center justify-between gap-3">
|
|
84
|
+
<span className="text-muted-foreground">
|
|
85
|
+
{t('ws-invoices.valid_until')}
|
|
86
|
+
</span>
|
|
87
|
+
<span className="text-right font-medium">{validUntilLabel}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</CardContent>
|
|
91
|
+
</Card>
|
|
92
|
+
);
|
|
93
|
+
}
|