@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
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from '@tuturuuu/icons';
|
|
31
31
|
import {
|
|
32
32
|
getWorkspaceTask,
|
|
33
|
+
listWorkspaceTaskBoardViewableMembers,
|
|
33
34
|
listWorkspaceTaskLists,
|
|
34
35
|
listWorkspaceTaskProjects,
|
|
35
36
|
removeCurrentUserTaskPersonalPlacement,
|
|
@@ -50,7 +51,10 @@ import {
|
|
|
50
51
|
import { useCalendarPreferences } from '@tuturuuu/ui/hooks/use-calendar-preferences';
|
|
51
52
|
import { useTaskActions } from '@tuturuuu/ui/hooks/use-task-actions';
|
|
52
53
|
import { useUserBooleanConfig } from '@tuturuuu/ui/hooks/use-user-config';
|
|
53
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
useWorkspaceMembers,
|
|
56
|
+
type WorkspaceMember,
|
|
57
|
+
} from '@tuturuuu/ui/hooks/use-workspace-members';
|
|
54
58
|
import {
|
|
55
59
|
HoverCard,
|
|
56
60
|
HoverCardContent,
|
|
@@ -152,6 +156,11 @@ import {
|
|
|
152
156
|
import { getTaskCardVisibilityState } from './task-card-visibility';
|
|
153
157
|
import { TaskSchedulingBadge } from './task-scheduling-badge';
|
|
154
158
|
|
|
159
|
+
export type TaskCardAssigneeMemberSource =
|
|
160
|
+
| 'workspace'
|
|
161
|
+
| 'board'
|
|
162
|
+
| 'workspace-and-board';
|
|
163
|
+
|
|
155
164
|
export interface TaskCardProps {
|
|
156
165
|
task: Task;
|
|
157
166
|
boardId: string;
|
|
@@ -163,6 +172,8 @@ export interface TaskCardProps {
|
|
|
163
172
|
isSelected?: boolean;
|
|
164
173
|
isMultiSelectMode?: boolean;
|
|
165
174
|
isPersonalWorkspace?: boolean;
|
|
175
|
+
canUseBoardAssignees?: boolean;
|
|
176
|
+
assigneeMemberSource?: TaskCardAssigneeMemberSource;
|
|
166
177
|
onSelect?: (taskId: string, event: React.MouseEvent) => void;
|
|
167
178
|
onClearSelection?: () => void;
|
|
168
179
|
dragDisabled?: boolean;
|
|
@@ -307,6 +318,8 @@ function TaskCardInner({
|
|
|
307
318
|
isSelected = false,
|
|
308
319
|
isMultiSelectMode = false,
|
|
309
320
|
isPersonalWorkspace = false,
|
|
321
|
+
canUseBoardAssignees,
|
|
322
|
+
assigneeMemberSource,
|
|
310
323
|
onSelect,
|
|
311
324
|
onClearSelection,
|
|
312
325
|
dragDisabled: dragDisabledProp = false,
|
|
@@ -444,12 +457,59 @@ function TaskCardInner({
|
|
|
444
457
|
isMultiSelectMode,
|
|
445
458
|
onClearSelection,
|
|
446
459
|
});
|
|
460
|
+
const shouldUseBoardAssignees = canUseBoardAssignees ?? !isPersonalWorkspace;
|
|
461
|
+
const effectiveAssigneeMemberSource =
|
|
462
|
+
assigneeMemberSource ?? (isPersonalWorkspace ? 'board' : 'workspace');
|
|
463
|
+
const shouldLoadWorkspaceMembers =
|
|
464
|
+
shouldUseBoardAssignees && effectiveAssigneeMemberSource !== 'board';
|
|
465
|
+
const shouldLoadBoardViewableMembers =
|
|
466
|
+
shouldUseBoardAssignees && effectiveAssigneeMemberSource !== 'workspace';
|
|
447
467
|
|
|
448
468
|
// Fetch workspace members
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
469
|
+
const normalMembersQuery = useWorkspaceMembers(effectiveWorkspaceId, {
|
|
470
|
+
enabled: !!effectiveWorkspaceId && shouldLoadWorkspaceMembers,
|
|
471
|
+
});
|
|
472
|
+
const boardViewableMembersQuery = useQuery({
|
|
473
|
+
queryKey: ['task-board-viewable-members', effectiveWorkspaceId, boardId],
|
|
474
|
+
queryFn: async (): Promise<WorkspaceMember[]> => {
|
|
475
|
+
if (!effectiveWorkspaceId || !boardId) return [];
|
|
476
|
+
|
|
477
|
+
const payload = await listWorkspaceTaskBoardViewableMembers(
|
|
478
|
+
effectiveWorkspaceId,
|
|
479
|
+
boardId
|
|
480
|
+
);
|
|
481
|
+
const members = Array.isArray(payload?.members) ? payload.members : [];
|
|
482
|
+
|
|
483
|
+
return members.map((member) => ({
|
|
484
|
+
id: member.user_id,
|
|
485
|
+
user_id: member.user_id,
|
|
486
|
+
workspace_id: effectiveWorkspaceId,
|
|
487
|
+
display_name: member.display_name ?? member.email ?? member.user_id,
|
|
488
|
+
email: member.email ?? undefined,
|
|
489
|
+
avatar_url: member.avatar_url ?? undefined,
|
|
490
|
+
}));
|
|
491
|
+
},
|
|
492
|
+
enabled:
|
|
493
|
+
!!effectiveWorkspaceId && !!boardId && shouldLoadBoardViewableMembers,
|
|
494
|
+
staleTime: 5 * 60 * 1000,
|
|
495
|
+
});
|
|
496
|
+
const normalWorkspaceMembers = normalMembersQuery.data ?? [];
|
|
497
|
+
const boardViewableMembers = boardViewableMembersQuery.data ?? [];
|
|
498
|
+
const workspaceMembers = useMemo(() => {
|
|
499
|
+
const seen = new Set<string>();
|
|
500
|
+
const merged: WorkspaceMember[] = [];
|
|
501
|
+
|
|
502
|
+
for (const member of [...normalWorkspaceMembers, ...boardViewableMembers]) {
|
|
503
|
+
const memberId = member.user_id ?? member.id;
|
|
504
|
+
if (!memberId || seen.has(memberId)) continue;
|
|
505
|
+
seen.add(memberId);
|
|
506
|
+
merged.push(member);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return merged;
|
|
510
|
+
}, [boardViewableMembers, normalWorkspaceMembers]);
|
|
511
|
+
const membersLoading =
|
|
512
|
+
normalMembersQuery.isLoading || boardViewableMembersQuery.isLoading;
|
|
453
513
|
|
|
454
514
|
const relationshipSummary =
|
|
455
515
|
task.relationship_summary ??
|
|
@@ -1034,6 +1094,12 @@ function TaskCardInner({
|
|
|
1034
1094
|
task,
|
|
1035
1095
|
boardId,
|
|
1036
1096
|
availableLists,
|
|
1097
|
+
canUseBoardAssignees: task.source_workspace_id
|
|
1098
|
+
? true
|
|
1099
|
+
: shouldUseBoardAssignees,
|
|
1100
|
+
assigneeMemberSource: task.source_workspace_id
|
|
1101
|
+
? 'workspace'
|
|
1102
|
+
: effectiveAssigneeMemberSource,
|
|
1037
1103
|
effectiveWorkspaceId,
|
|
1038
1104
|
isPersonalWorkspace,
|
|
1039
1105
|
})
|
|
@@ -1042,6 +1108,8 @@ function TaskCardInner({
|
|
|
1042
1108
|
task,
|
|
1043
1109
|
boardId,
|
|
1044
1110
|
availableLists,
|
|
1111
|
+
shouldUseBoardAssignees,
|
|
1112
|
+
effectiveAssigneeMemberSource,
|
|
1045
1113
|
effectiveWorkspaceId,
|
|
1046
1114
|
isPersonalWorkspace,
|
|
1047
1115
|
openTaskById,
|
|
@@ -1075,6 +1143,8 @@ function TaskCardInner({
|
|
|
1075
1143
|
openTask(task, boardId, availableLists, false, {
|
|
1076
1144
|
taskWsId: effectiveWorkspaceId,
|
|
1077
1145
|
taskWorkspacePersonal: isPersonalWorkspace,
|
|
1146
|
+
canUseBoardAssignees: shouldUseBoardAssignees,
|
|
1147
|
+
assigneeMemberSource: effectiveAssigneeMemberSource,
|
|
1078
1148
|
});
|
|
1079
1149
|
}
|
|
1080
1150
|
},
|
|
@@ -1082,7 +1152,9 @@ function TaskCardInner({
|
|
|
1082
1152
|
task,
|
|
1083
1153
|
boardId,
|
|
1084
1154
|
effectiveWorkspaceId,
|
|
1155
|
+
effectiveAssigneeMemberSource,
|
|
1085
1156
|
isPersonalWorkspace,
|
|
1157
|
+
shouldUseBoardAssignees,
|
|
1086
1158
|
isPersonalExternalTask,
|
|
1087
1159
|
isMultiSelectMode,
|
|
1088
1160
|
availableLists,
|
|
@@ -2274,8 +2346,8 @@ function TaskCardInner({
|
|
|
2274
2346
|
</>
|
|
2275
2347
|
)}
|
|
2276
2348
|
|
|
2277
|
-
{/* Assignee Actions
|
|
2278
|
-
{
|
|
2349
|
+
{/* Assignee Actions */}
|
|
2350
|
+
{shouldUseBoardAssignees && (
|
|
2279
2351
|
<TaskAssigneesMenu
|
|
2280
2352
|
taskAssignees={displayAssignees}
|
|
2281
2353
|
availableMembers={workspaceMembers}
|
|
@@ -2345,7 +2417,7 @@ function TaskCardInner({
|
|
|
2345
2417
|
)}
|
|
2346
2418
|
</div>
|
|
2347
2419
|
{/* Assignee: left, not cut off */}
|
|
2348
|
-
{
|
|
2420
|
+
{shouldUseBoardAssignees && (
|
|
2349
2421
|
<div className="flex flex-none items-start justify-start">
|
|
2350
2422
|
<AssigneeSelect
|
|
2351
2423
|
taskId={task.id}
|
|
@@ -10,6 +10,7 @@ import { useTranslations } from 'next-intl';
|
|
|
10
10
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
11
|
import type { DragPreviewPosition } from './kanban/dnd/use-kanban-dnd';
|
|
12
12
|
import { MeasuredTaskCard } from './task';
|
|
13
|
+
import type { TaskCardAssigneeMemberSource } from './task-card/task-card';
|
|
13
14
|
|
|
14
15
|
const VIRTUALIZE_THRESHOLD = 60; // only virtualize for fairly large lists
|
|
15
16
|
const ESTIMATED_ITEM_HEIGHT = 96; // px including margin (space-y-2 gap)
|
|
@@ -33,6 +34,8 @@ interface VirtualizedTaskListProps {
|
|
|
33
34
|
isMultiSelectMode?: boolean;
|
|
34
35
|
selectedTasks?: Set<string>;
|
|
35
36
|
isPersonalWorkspace?: boolean;
|
|
37
|
+
canUseBoardAssignees?: boolean;
|
|
38
|
+
assigneeMemberSource?: TaskCardAssigneeMemberSource;
|
|
36
39
|
onTaskSelect?: (taskId: string, event: React.MouseEvent) => void;
|
|
37
40
|
onClearSelection?: () => void;
|
|
38
41
|
dragPreviewPosition?: DragPreviewPosition | null;
|
|
@@ -56,6 +59,8 @@ interface TaskListContentProps {
|
|
|
56
59
|
isMultiSelectMode?: boolean;
|
|
57
60
|
selectedTasks?: Set<string>;
|
|
58
61
|
isPersonalWorkspace?: boolean;
|
|
62
|
+
canUseBoardAssignees?: boolean;
|
|
63
|
+
assigneeMemberSource?: TaskCardAssigneeMemberSource;
|
|
59
64
|
onTaskSelect?: (taskId: string, event: React.MouseEvent) => void;
|
|
60
65
|
onClearSelection?: () => void;
|
|
61
66
|
dragPreviewPosition?: DragPreviewPosition | null;
|
|
@@ -125,6 +130,8 @@ function TaskListContent({
|
|
|
125
130
|
isMultiSelectMode,
|
|
126
131
|
selectedTasks,
|
|
127
132
|
isPersonalWorkspace,
|
|
133
|
+
canUseBoardAssignees,
|
|
134
|
+
assigneeMemberSource,
|
|
128
135
|
onTaskSelect,
|
|
129
136
|
onClearSelection,
|
|
130
137
|
dragPreviewPosition,
|
|
@@ -165,6 +172,8 @@ function TaskListContent({
|
|
|
165
172
|
)}
|
|
166
173
|
isMultiSelectMode={isMultiSelectMode}
|
|
167
174
|
isPersonalWorkspace={isPersonalWorkspace}
|
|
175
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
176
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
168
177
|
onSelect={onTaskSelect}
|
|
169
178
|
onClearSelection={onClearSelection}
|
|
170
179
|
suppressSortableTransform={suppressSortableTransform}
|
|
@@ -232,6 +241,8 @@ function VirtualizedTaskListInner({
|
|
|
232
241
|
isMultiSelectMode,
|
|
233
242
|
selectedTasks,
|
|
234
243
|
isPersonalWorkspace,
|
|
244
|
+
canUseBoardAssignees,
|
|
245
|
+
assigneeMemberSource,
|
|
235
246
|
onTaskSelect,
|
|
236
247
|
onClearSelection,
|
|
237
248
|
dragPreviewPosition,
|
|
@@ -470,6 +481,8 @@ function VirtualizedTaskListInner({
|
|
|
470
481
|
isMultiSelectMode={isMultiSelectMode}
|
|
471
482
|
selectedTasks={selectedTasks}
|
|
472
483
|
isPersonalWorkspace={isPersonalWorkspace}
|
|
484
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
485
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
473
486
|
onTaskSelect={onTaskSelect}
|
|
474
487
|
onClearSelection={onClearSelection}
|
|
475
488
|
dragPreviewPosition={dragPreviewPosition}
|
|
@@ -500,6 +513,8 @@ function VirtualizedTaskListInner({
|
|
|
500
513
|
isMultiSelectMode={isMultiSelectMode}
|
|
501
514
|
selectedTasks={selectedTasks}
|
|
502
515
|
isPersonalWorkspace={isPersonalWorkspace}
|
|
516
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
517
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
503
518
|
onTaskSelect={onTaskSelect}
|
|
504
519
|
onClearSelection={onClearSelection}
|
|
505
520
|
dragPreviewPosition={dragPreviewPosition}
|
|
@@ -28,6 +28,9 @@ interface TimelineGridProps {
|
|
|
28
28
|
localTasks: Task[];
|
|
29
29
|
boardId?: string;
|
|
30
30
|
wsId?: string;
|
|
31
|
+
isPersonalWorkspace?: boolean;
|
|
32
|
+
canUseBoardAssignees?: boolean;
|
|
33
|
+
assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
|
|
31
34
|
dayWidth: number;
|
|
32
35
|
sidebarWidth: number;
|
|
33
36
|
timelineWidth: number;
|
|
@@ -71,6 +74,9 @@ export function TimelineGrid({
|
|
|
71
74
|
localTasks,
|
|
72
75
|
boardId,
|
|
73
76
|
wsId,
|
|
77
|
+
isPersonalWorkspace,
|
|
78
|
+
canUseBoardAssignees,
|
|
79
|
+
assigneeMemberSource,
|
|
74
80
|
dayWidth,
|
|
75
81
|
sidebarWidth,
|
|
76
82
|
timelineWidth,
|
|
@@ -223,6 +229,9 @@ export function TimelineGrid({
|
|
|
223
229
|
)}
|
|
224
230
|
boardId={boardId}
|
|
225
231
|
wsId={wsId}
|
|
232
|
+
isPersonalWorkspace={isPersonalWorkspace}
|
|
233
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
234
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
226
235
|
dayWidth={dayWidth}
|
|
227
236
|
timelineWidth={timelineWidth}
|
|
228
237
|
sidebarWidth={sidebarWidth}
|
|
@@ -39,6 +39,9 @@ interface TimelineTaskRowProps {
|
|
|
39
39
|
lists: TaskList[];
|
|
40
40
|
boardId?: string;
|
|
41
41
|
wsId?: string;
|
|
42
|
+
isPersonalWorkspace?: boolean;
|
|
43
|
+
canUseBoardAssignees?: boolean;
|
|
44
|
+
assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
|
|
42
45
|
dayWidth: number;
|
|
43
46
|
timelineWidth: number;
|
|
44
47
|
sidebarWidth: number;
|
|
@@ -75,6 +78,9 @@ export function TimelineTaskRow({
|
|
|
75
78
|
lists,
|
|
76
79
|
boardId,
|
|
77
80
|
wsId,
|
|
81
|
+
isPersonalWorkspace,
|
|
82
|
+
canUseBoardAssignees,
|
|
83
|
+
assigneeMemberSource,
|
|
78
84
|
dayWidth,
|
|
79
85
|
timelineWidth,
|
|
80
86
|
sidebarWidth,
|
|
@@ -235,6 +241,9 @@ export function TimelineTaskRow({
|
|
|
235
241
|
boardId={boardId}
|
|
236
242
|
workspaceId={wsId}
|
|
237
243
|
lists={lists}
|
|
244
|
+
isPersonalWorkspace={isPersonalWorkspace}
|
|
245
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
246
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
238
247
|
onUpdate={onActionsUpdate ?? (() => undefined)}
|
|
239
248
|
open={actionsMenu.open}
|
|
240
249
|
onOpenChange={(open) =>
|
|
@@ -34,6 +34,9 @@ interface TimelineToolbarProps {
|
|
|
34
34
|
lists: TaskList[];
|
|
35
35
|
boardId?: string;
|
|
36
36
|
wsId?: string;
|
|
37
|
+
isPersonalWorkspace?: boolean;
|
|
38
|
+
canUseBoardAssignees?: boolean;
|
|
39
|
+
assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
|
|
37
40
|
primaryCreateListId: string | null;
|
|
38
41
|
dayWidth: number;
|
|
39
42
|
setDayWidth: (value: number) => void;
|
|
@@ -66,6 +69,9 @@ export function TimelineToolbar({
|
|
|
66
69
|
lists,
|
|
67
70
|
boardId,
|
|
68
71
|
wsId,
|
|
72
|
+
isPersonalWorkspace,
|
|
73
|
+
canUseBoardAssignees,
|
|
74
|
+
assigneeMemberSource,
|
|
69
75
|
primaryCreateListId,
|
|
70
76
|
dayWidth,
|
|
71
77
|
setDayWidth,
|
|
@@ -221,6 +227,9 @@ export function TimelineToolbar({
|
|
|
221
227
|
boardId={boardId}
|
|
222
228
|
workspaceId={wsId}
|
|
223
229
|
lists={lists}
|
|
230
|
+
isPersonalWorkspace={isPersonalWorkspace}
|
|
231
|
+
canUseBoardAssignees={canUseBoardAssignees}
|
|
232
|
+
assigneeMemberSource={assigneeMemberSource}
|
|
224
233
|
onUpdate={onActionsUpdate ?? (() => undefined)}
|
|
225
234
|
open={openTaskMenu?.taskId === task.id}
|
|
226
235
|
onOpenChange={(open) =>
|
|
@@ -65,6 +65,9 @@ export interface TimelineProps {
|
|
|
65
65
|
lists: TaskList[];
|
|
66
66
|
boardId?: string;
|
|
67
67
|
wsId?: string;
|
|
68
|
+
isPersonalWorkspace?: boolean;
|
|
69
|
+
canUseBoardAssignees?: boolean;
|
|
70
|
+
assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
|
|
68
71
|
className?: string;
|
|
69
72
|
onTaskPartialUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
|
70
73
|
}
|
|
@@ -110,6 +113,9 @@ export function TimelineBoard({
|
|
|
110
113
|
lists,
|
|
111
114
|
boardId,
|
|
112
115
|
wsId,
|
|
116
|
+
isPersonalWorkspace = false,
|
|
117
|
+
canUseBoardAssignees,
|
|
118
|
+
assigneeMemberSource,
|
|
113
119
|
className,
|
|
114
120
|
onTaskPartialUpdate,
|
|
115
121
|
}: TimelineProps) {
|
|
@@ -227,6 +233,9 @@ export function TimelineBoard({
|
|
|
227
233
|
(task: Task) => task.source_workspace_id ?? wsId ?? null,
|
|
228
234
|
[wsId]
|
|
229
235
|
);
|
|
236
|
+
const showAssignees = canUseBoardAssignees ?? !isPersonalWorkspace;
|
|
237
|
+
const effectiveAssigneeMemberSource =
|
|
238
|
+
assigneeMemberSource ?? (isPersonalWorkspace ? 'board' : 'workspace');
|
|
230
239
|
|
|
231
240
|
const openTimelineTask = useCallback(
|
|
232
241
|
(task: Task) => {
|
|
@@ -240,7 +249,13 @@ export function TimelineBoard({
|
|
|
240
249
|
boardId,
|
|
241
250
|
availableLists: lists,
|
|
242
251
|
effectiveWorkspaceId: wsId,
|
|
243
|
-
isPersonalWorkspace
|
|
252
|
+
isPersonalWorkspace,
|
|
253
|
+
canUseBoardAssignees: task.source_workspace_id
|
|
254
|
+
? true
|
|
255
|
+
: showAssignees,
|
|
256
|
+
assigneeMemberSource: task.source_workspace_id
|
|
257
|
+
? 'workspace'
|
|
258
|
+
: effectiveAssigneeMemberSource,
|
|
244
259
|
})
|
|
245
260
|
);
|
|
246
261
|
return;
|
|
@@ -248,10 +263,21 @@ export function TimelineBoard({
|
|
|
248
263
|
|
|
249
264
|
openTask(task, boardId, lists, false, {
|
|
250
265
|
taskWsId: wsId,
|
|
251
|
-
taskWorkspacePersonal:
|
|
266
|
+
taskWorkspacePersonal: isPersonalWorkspace,
|
|
267
|
+
canUseBoardAssignees: showAssignees,
|
|
268
|
+
assigneeMemberSource: effectiveAssigneeMemberSource,
|
|
252
269
|
});
|
|
253
270
|
},
|
|
254
|
-
[
|
|
271
|
+
[
|
|
272
|
+
boardId,
|
|
273
|
+
effectiveAssigneeMemberSource,
|
|
274
|
+
isPersonalWorkspace,
|
|
275
|
+
lists,
|
|
276
|
+
openTask,
|
|
277
|
+
openTaskById,
|
|
278
|
+
showAssignees,
|
|
279
|
+
wsId,
|
|
280
|
+
]
|
|
255
281
|
);
|
|
256
282
|
|
|
257
283
|
const clearDraft = useCallback((taskId: string) => {
|
|
@@ -674,6 +700,9 @@ export function TimelineBoard({
|
|
|
674
700
|
<TimelineToolbar
|
|
675
701
|
boardId={boardId}
|
|
676
702
|
wsId={wsId}
|
|
703
|
+
isPersonalWorkspace={isPersonalWorkspace}
|
|
704
|
+
canUseBoardAssignees={showAssignees}
|
|
705
|
+
assigneeMemberSource={effectiveAssigneeMemberSource}
|
|
677
706
|
dayWidth={dayWidth}
|
|
678
707
|
density={density}
|
|
679
708
|
formatLongDate={formatLongDate}
|
|
@@ -703,6 +732,9 @@ export function TimelineBoard({
|
|
|
703
732
|
barHeight={densityConfig.barHeight}
|
|
704
733
|
boardId={boardId}
|
|
705
734
|
wsId={wsId}
|
|
735
|
+
isPersonalWorkspace={isPersonalWorkspace}
|
|
736
|
+
canUseBoardAssignees={showAssignees}
|
|
737
|
+
assigneeMemberSource={effectiveAssigneeMemberSource}
|
|
706
738
|
dayWidth={dayWidth}
|
|
707
739
|
draggedUnscheduledTaskId={draggedUnscheduledTaskId}
|
|
708
740
|
dropPreview={dropPreview}
|
|
@@ -190,6 +190,46 @@ describe('useTaskLabelManagement', () => {
|
|
|
190
190
|
);
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('updates full-board and task detail caches without invalidating visible board queries', async () => {
|
|
194
|
+
queryClient.setQueryData(['tasks', 'board-1'], [mockTask]);
|
|
195
|
+
queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [mockTask]);
|
|
196
|
+
queryClient.setQueryData(['task', 'task-1'], mockTask);
|
|
197
|
+
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
198
|
+
|
|
199
|
+
const { result } = renderHook(
|
|
200
|
+
() =>
|
|
201
|
+
useTaskLabelManagement({
|
|
202
|
+
task: mockTask,
|
|
203
|
+
boardId: 'board-1',
|
|
204
|
+
workspaceLabels: mockWorkspaceLabels,
|
|
205
|
+
workspaceId: 'ws-1',
|
|
206
|
+
taskId: 'task-1',
|
|
207
|
+
}),
|
|
208
|
+
{ wrapper }
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
await act(async () => {
|
|
212
|
+
await result.current.toggleTaskLabel('label-3');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(
|
|
216
|
+
queryClient
|
|
217
|
+
.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
|
|
218
|
+
?.labels?.some((label) => label.id === 'label-3')
|
|
219
|
+
).toBe(true);
|
|
220
|
+
expect(
|
|
221
|
+
queryClient
|
|
222
|
+
.getQueryData<Task>(['task', 'task-1'])
|
|
223
|
+
?.labels?.some((label) => label.id === 'label-3')
|
|
224
|
+
).toBe(true);
|
|
225
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
226
|
+
queryKey: ['tasks', 'board-1'],
|
|
227
|
+
});
|
|
228
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
229
|
+
queryKey: ['tasks-full', 'board-1'],
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
193
233
|
it('should rollback on error and show toast', async () => {
|
|
194
234
|
mockRemoveWorkspaceTaskLabel.mockRejectedValueOnce(
|
|
195
235
|
new Error('Database error')
|
|
@@ -197,6 +237,8 @@ describe('useTaskLabelManagement', () => {
|
|
|
197
237
|
|
|
198
238
|
const originalTasks = [mockTask];
|
|
199
239
|
queryClient.setQueryData(['tasks', 'board-1'], originalTasks);
|
|
240
|
+
queryClient.setQueryData(['tasks-full', 'board-1', 'all'], originalTasks);
|
|
241
|
+
queryClient.setQueryData(['task', 'task-1'], mockTask);
|
|
200
242
|
|
|
201
243
|
const { result } = renderHook(
|
|
202
244
|
() =>
|
|
@@ -219,6 +261,12 @@ describe('useTaskLabelManagement', () => {
|
|
|
219
261
|
'board-1',
|
|
220
262
|
]);
|
|
221
263
|
expect(cachedTasks).toEqual(originalTasks);
|
|
264
|
+
expect(
|
|
265
|
+
queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])
|
|
266
|
+
).toEqual(originalTasks);
|
|
267
|
+
expect(queryClient.getQueryData<Task>(['task', 'task-1'])).toEqual(
|
|
268
|
+
mockTask
|
|
269
|
+
);
|
|
222
270
|
|
|
223
271
|
// Verify error toast was shown with the correct format
|
|
224
272
|
expect(mockToast.error).toHaveBeenCalledWith('Error', {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
6
|
+
import { act, renderHook } from '@testing-library/react';
|
|
7
|
+
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
import { useTaskProjectManagement } from '../useTaskProjectManagement';
|
|
11
|
+
|
|
12
|
+
vi.mock('@tuturuuu/ui/sonner', () => ({
|
|
13
|
+
toast: {
|
|
14
|
+
error: vi.fn(),
|
|
15
|
+
success: vi.fn(),
|
|
16
|
+
warning: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@tuturuuu/internal-api/tasks', () => ({
|
|
21
|
+
createWorkspaceTaskProject: vi.fn(),
|
|
22
|
+
updateWorkspaceTask: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe('useTaskProjectManagement', () => {
|
|
26
|
+
let queryClient: QueryClient;
|
|
27
|
+
let mockUpdateWorkspaceTask: any;
|
|
28
|
+
|
|
29
|
+
const project = {
|
|
30
|
+
id: 'project-1',
|
|
31
|
+
name: 'Roadmap',
|
|
32
|
+
status: 'active',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockTask = {
|
|
36
|
+
id: 'task-1',
|
|
37
|
+
name: 'Test Task',
|
|
38
|
+
list_id: 'list-1',
|
|
39
|
+
display_number: 1,
|
|
40
|
+
created_at: '2026-01-01T00:00:00.000Z',
|
|
41
|
+
projects: [],
|
|
42
|
+
} satisfies Task;
|
|
43
|
+
|
|
44
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
45
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
queryClient = new QueryClient({
|
|
50
|
+
defaultOptions: {
|
|
51
|
+
queries: { retry: false },
|
|
52
|
+
mutations: { retry: false },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const { updateWorkspaceTask } = await import(
|
|
57
|
+
'@tuturuuu/internal-api/tasks'
|
|
58
|
+
);
|
|
59
|
+
mockUpdateWorkspaceTask = updateWorkspaceTask as any;
|
|
60
|
+
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
mockUpdateWorkspaceTask.mockResolvedValue({ task: { id: 'task-1' } });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('updates full-board and task detail caches without invalidating visible board queries', async () => {
|
|
66
|
+
queryClient.setQueryData(['tasks', 'board-1'], [mockTask]);
|
|
67
|
+
queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [mockTask]);
|
|
68
|
+
queryClient.setQueryData(['task', 'task-1'], mockTask);
|
|
69
|
+
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
70
|
+
|
|
71
|
+
const { result } = renderHook(
|
|
72
|
+
() =>
|
|
73
|
+
useTaskProjectManagement({
|
|
74
|
+
task: mockTask,
|
|
75
|
+
boardId: 'board-1',
|
|
76
|
+
workspaceProjects: [project],
|
|
77
|
+
workspaceId: 'ws-1',
|
|
78
|
+
taskId: 'task-1',
|
|
79
|
+
}),
|
|
80
|
+
{ wrapper }
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await act(async () => {
|
|
84
|
+
await result.current.toggleTaskProject('project-1');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
queryClient
|
|
89
|
+
.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
|
|
90
|
+
?.projects?.some((entry) => entry.id === 'project-1')
|
|
91
|
+
).toBe(true);
|
|
92
|
+
expect(
|
|
93
|
+
queryClient
|
|
94
|
+
.getQueryData<Task>(['task', 'task-1'])
|
|
95
|
+
?.projects?.some((entry) => entry.id === 'project-1')
|
|
96
|
+
).toBe(true);
|
|
97
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
98
|
+
queryKey: ['tasks', 'board-1'],
|
|
99
|
+
});
|
|
100
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
101
|
+
queryKey: ['tasks-full', 'board-1'],
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rolls back every visible cache when the project update fails', async () => {
|
|
106
|
+
const taskWithProject = {
|
|
107
|
+
...mockTask,
|
|
108
|
+
projects: [project],
|
|
109
|
+
} as Task;
|
|
110
|
+
mockUpdateWorkspaceTask.mockRejectedValueOnce(new Error('Database error'));
|
|
111
|
+
queryClient.setQueryData(['tasks', 'board-1'], [taskWithProject]);
|
|
112
|
+
queryClient.setQueryData(
|
|
113
|
+
['tasks-full', 'board-1', 'all'],
|
|
114
|
+
[taskWithProject]
|
|
115
|
+
);
|
|
116
|
+
queryClient.setQueryData(['task', 'task-1'], taskWithProject);
|
|
117
|
+
|
|
118
|
+
const { result } = renderHook(
|
|
119
|
+
() =>
|
|
120
|
+
useTaskProjectManagement({
|
|
121
|
+
task: taskWithProject,
|
|
122
|
+
boardId: 'board-1',
|
|
123
|
+
workspaceProjects: [project],
|
|
124
|
+
workspaceId: 'ws-1',
|
|
125
|
+
taskId: 'task-1',
|
|
126
|
+
}),
|
|
127
|
+
{ wrapper }
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
await result.current.toggleTaskProject('project-1');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(queryClient.getQueryData<Task[]>(['tasks', 'board-1'])).toEqual([
|
|
135
|
+
taskWithProject,
|
|
136
|
+
]);
|
|
137
|
+
expect(
|
|
138
|
+
queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])
|
|
139
|
+
).toEqual([taskWithProject]);
|
|
140
|
+
expect(queryClient.getQueryData<Task>(['task', 'task-1'])).toEqual(
|
|
141
|
+
taskWithProject
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -4,6 +4,7 @@ import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
|
4
4
|
import type { TaskFilters } from '@tuturuuu/ui/tu-do/boards/boardId/task-filter';
|
|
5
5
|
import {
|
|
6
6
|
type PendingRelationshipType,
|
|
7
|
+
type TaskAssigneeMemberSource,
|
|
7
8
|
useTaskDialogContext,
|
|
8
9
|
} from '../providers/task-dialog-provider';
|
|
9
10
|
import type { SharedTaskContext } from '../shared/task-edit-dialog/hooks/use-task-data';
|
|
@@ -50,6 +51,10 @@ export function useTaskDialog(): {
|
|
|
50
51
|
taskWsId?: string;
|
|
51
52
|
/** Whether the task's workspace is personal (affects realtime features) */
|
|
52
53
|
taskWorkspacePersonal?: boolean;
|
|
54
|
+
/** Whether the board context should expose assignee controls */
|
|
55
|
+
canUseBoardAssignees?: boolean;
|
|
56
|
+
/** Where assignee candidates should be loaded from */
|
|
57
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
53
58
|
}
|
|
54
59
|
) => void;
|
|
55
60
|
openTaskById: (
|
|
@@ -62,6 +67,8 @@ export function useTaskDialog(): {
|
|
|
62
67
|
taskWsId?: string;
|
|
63
68
|
taskWorkspacePersonal?: boolean;
|
|
64
69
|
taskWorkspaceTier?: WorkspaceProductTier;
|
|
70
|
+
canUseBoardAssignees?: boolean;
|
|
71
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
65
72
|
initialSharedContext?: SharedTaskContext;
|
|
66
73
|
}
|
|
67
74
|
) => Promise<boolean>;
|