@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
@@ -6,13 +6,17 @@ import {
6
6
  import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
7
7
  import type { CalendarHoursType } from '@tuturuuu/types/primitives/Task';
8
8
  import { useToast } from '@tuturuuu/ui/hooks/use-toast';
9
- import { invalidateTaskCaches } from '@tuturuuu/utils/task-helper';
10
9
  import { useCallback, useState } from 'react';
11
10
  import {
12
11
  type BoardBroadcastFn,
13
12
  getActiveBroadcast,
14
13
  useBoardBroadcast,
15
14
  } from '../../board-broadcast-context';
15
+ import {
16
+ patchTaskInVisibleCaches,
17
+ restoreVisibleTaskCaches,
18
+ snapshotVisibleTaskCaches,
19
+ } from '../../task-cache-patches';
16
20
  import { updateWorkspaceTask } from './task-api';
17
21
 
18
22
  export interface SchedulingSettings {
@@ -109,17 +113,17 @@ export function useTaskMutations({
109
113
  return;
110
114
  }
111
115
  setEstimationSaving(true);
116
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
117
+ taskId,
118
+ ]);
112
119
 
113
120
  // Optimistic update - prevents flicker by updating cache immediately
114
- queryClient.setQueryData(
115
- ['tasks', boardId],
116
- (oldTasks: any[] | undefined) => {
117
- if (!oldTasks) return oldTasks;
118
- return oldTasks.map((task) =>
119
- task.id === taskId ? { ...task, estimation_points: points } : task
120
- );
121
- }
122
- );
121
+ patchTaskInVisibleCaches({
122
+ queryClient,
123
+ boardId,
124
+ taskId,
125
+ updater: (task) => ({ ...task, estimation_points: points }),
126
+ });
123
127
 
124
128
  try {
125
129
  const { task } = await updateWorkspaceTask(wsId, taskId, {
@@ -135,7 +139,7 @@ export function useTaskMutations({
135
139
  } catch (e: any) {
136
140
  console.error('Failed updating estimation', e);
137
141
  // Revert optimistic update on error
138
- await invalidateTaskCaches(queryClient, boardId);
142
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
139
143
  toast({
140
144
  title: 'Failed to update estimation',
141
145
  description: e.message || 'Please try again',
@@ -166,17 +170,17 @@ export function useTaskMutations({
166
170
  if (isCreateMode || !taskId || taskId === 'new') {
167
171
  return;
168
172
  }
173
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
174
+ taskId,
175
+ ]);
169
176
 
170
177
  // Optimistic update - prevents flicker
171
- queryClient.setQueryData(
172
- ['tasks', boardId],
173
- (oldTasks: any[] | undefined) => {
174
- if (!oldTasks) return oldTasks;
175
- return oldTasks.map((task) =>
176
- task.id === taskId ? { ...task, priority: newPriority } : task
177
- );
178
- }
179
- );
178
+ patchTaskInVisibleCaches({
179
+ queryClient,
180
+ boardId,
181
+ taskId,
182
+ updater: (task) => ({ ...task, priority: newPriority }),
183
+ });
180
184
 
181
185
  try {
182
186
  const { task } = await updateWorkspaceTask(wsId, taskId, {
@@ -192,7 +196,7 @@ export function useTaskMutations({
192
196
  } catch (e: any) {
193
197
  console.error('Failed updating priority', e);
194
198
  // Revert optimistic update on error
195
- await invalidateTaskCaches(queryClient, boardId);
199
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
196
200
  toast({
197
201
  title: 'Failed to update priority',
198
202
  description: e.message || 'Please try again',
@@ -222,17 +226,17 @@ export function useTaskMutations({
222
226
  }
223
227
 
224
228
  const dateString = newDate ? newDate.toISOString() : null;
229
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
230
+ taskId,
231
+ ]);
225
232
 
226
233
  // Optimistic update - prevents flicker
227
- queryClient.setQueryData(
228
- ['tasks', boardId],
229
- (oldTasks: any[] | undefined) => {
230
- if (!oldTasks) return oldTasks;
231
- return oldTasks.map((task) =>
232
- task.id === taskId ? { ...task, start_date: dateString } : task
233
- );
234
- }
235
- );
234
+ patchTaskInVisibleCaches({
235
+ queryClient,
236
+ boardId,
237
+ taskId,
238
+ updater: (task) => ({ ...task, start_date: dateString ?? undefined }),
239
+ });
236
240
 
237
241
  try {
238
242
  const { task } = await updateWorkspaceTask(wsId, taskId, {
@@ -248,7 +252,7 @@ export function useTaskMutations({
248
252
  } catch (e: any) {
249
253
  console.error('Failed updating start date', e);
250
254
  // Revert optimistic update on error
251
- await invalidateTaskCaches(queryClient, boardId);
255
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
252
256
  toast({
253
257
  title: 'Failed to update start date',
254
258
  description: e.message || 'Please try again',
@@ -277,17 +281,17 @@ export function useTaskMutations({
277
281
  }
278
282
 
279
283
  const dateString = newDate ? newDate.toISOString() : null;
284
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
285
+ taskId,
286
+ ]);
280
287
 
281
288
  // Optimistic update - prevents flicker
282
- queryClient.setQueryData(
283
- ['tasks', boardId],
284
- (oldTasks: any[] | undefined) => {
285
- if (!oldTasks) return oldTasks;
286
- return oldTasks.map((task) =>
287
- task.id === taskId ? { ...task, end_date: dateString } : task
288
- );
289
- }
290
- );
289
+ patchTaskInVisibleCaches({
290
+ queryClient,
291
+ boardId,
292
+ taskId,
293
+ updater: (task) => ({ ...task, end_date: dateString }),
294
+ });
291
295
 
292
296
  try {
293
297
  const { task } = await updateWorkspaceTask(wsId, taskId, {
@@ -303,7 +307,7 @@ export function useTaskMutations({
303
307
  } catch (e: any) {
304
308
  console.error('Failed updating end date', e);
305
309
  // Revert optimistic update on error
306
- await invalidateTaskCaches(queryClient, boardId);
310
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
307
311
  toast({
308
312
  title: 'Failed to update end date',
309
313
  description: e.message || 'Please try again',
@@ -331,38 +335,33 @@ export function useTaskMutations({
331
335
  if (isCreateMode || !taskId || taskId === 'new') {
332
336
  return;
333
337
  }
338
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
339
+ taskId,
340
+ ]);
334
341
 
335
342
  // Optimistic update - prevents flicker
336
- queryClient.setQueryData(
337
- ['tasks', boardId],
338
- (oldTasks: any[] | undefined) => {
339
- if (!oldTasks) return oldTasks;
340
- return oldTasks.map((task) =>
341
- task.id === taskId ? { ...task, list_id: newListId } : task
342
- );
343
- }
344
- );
343
+ patchTaskInVisibleCaches({
344
+ queryClient,
345
+ boardId,
346
+ taskId,
347
+ updater: (task) => ({ ...task, list_id: newListId }),
348
+ });
345
349
 
346
350
  try {
347
351
  const { task: updatedTask } = await updateWorkspaceTask(wsId, taskId, {
348
352
  list_id: newListId,
349
353
  });
350
354
  // Update sender's own cache with DB-computed timestamps
351
- queryClient.setQueryData(
352
- ['tasks', boardId],
353
- (oldTasks: any[] | undefined) => {
354
- if (!oldTasks) return oldTasks;
355
- return oldTasks.map((task: any) =>
356
- task.id === taskId
357
- ? {
358
- ...task,
359
- completed_at: updatedTask.completed_at,
360
- closed_at: updatedTask.closed_at,
361
- }
362
- : task
363
- );
364
- }
365
- );
355
+ patchTaskInVisibleCaches({
356
+ queryClient,
357
+ boardId,
358
+ taskId,
359
+ updater: (task) => ({
360
+ ...task,
361
+ completed_at: updatedTask.completed_at,
362
+ closed_at: updatedTask.closed_at,
363
+ }),
364
+ });
366
365
  broadcast?.('task:upsert', {
367
366
  task: {
368
367
  id: taskId,
@@ -379,7 +378,7 @@ export function useTaskMutations({
379
378
  } catch (e: any) {
380
379
  console.error('Failed updating list', e);
381
380
  // Revert optimistic update on error
382
- await invalidateTaskCaches(queryClient, boardId);
381
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
383
382
  toast({
384
383
  title: 'Failed to update list',
385
384
  description: e.message || 'Please try again',
@@ -412,15 +411,15 @@ export function useTaskMutations({
412
411
 
413
412
  // Optimistically update the cache instead of invalidating
414
413
  // This prevents conflicts with realtime sync
415
- queryClient.setQueryData(
416
- ['tasks', boardId],
417
- (oldTasks: any[] | undefined) => {
418
- if (!oldTasks) return oldTasks;
419
- return oldTasks.map((task) =>
420
- task.id === taskId ? { ...task, name: trimmedName } : task
421
- );
422
- }
423
- );
414
+ const cacheSnapshot = snapshotVisibleTaskCaches(queryClient, boardId, [
415
+ taskId,
416
+ ]);
417
+ patchTaskInVisibleCaches({
418
+ queryClient,
419
+ boardId,
420
+ taskId,
421
+ updater: (task) => ({ ...task, name: trimmedName }),
422
+ });
424
423
 
425
424
  try {
426
425
  const { task } = await updateWorkspaceTask(wsId, taskId, {
@@ -432,8 +431,8 @@ export function useTaskMutations({
432
431
  triggerRefresh();
433
432
  } catch (e: any) {
434
433
  console.error('Failed updating task name', e);
435
- // Revert optimistic update on error by invalidating to refetch
436
- await invalidateTaskCaches(queryClient, boardId);
434
+ // Revert optimistic update without refetching visible board caches.
435
+ restoreVisibleTaskCaches(queryClient, cacheSnapshot);
437
436
  toast({
438
437
  title: 'Failed to update task name',
439
438
  description: e.message || 'Please try again',
@@ -146,4 +146,67 @@ describe('useTaskRelationships', () => {
146
146
  ])
147
147
  ).toEqual([newLabel, existingLabel]);
148
148
  });
149
+
150
+ it('patches visible task caches when labels change without invalidating board task queries', async () => {
151
+ const cachedTask = {
152
+ id: 'task-1',
153
+ labels: [],
154
+ } as unknown as Task;
155
+
156
+ queryClient.setQueryData(['tasks', 'board-1'], [cachedTask]);
157
+ queryClient.setQueryData(['tasks-full', 'board-1', 'all'], [cachedTask]);
158
+ queryClient.setQueryData(['task', 'task-1'], cachedTask);
159
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
160
+
161
+ const { result } = renderHook(
162
+ () =>
163
+ useTaskRelationships({
164
+ wsId: 'personal',
165
+ taskId: 'task-1',
166
+ isCreateMode: false,
167
+ boardId: 'board-1',
168
+ selectedLabels: [],
169
+ selectedAssignees: [],
170
+ selectedProjects: [],
171
+ newLabelName: '',
172
+ newLabelColor: '#14b8a6',
173
+ newProjectName: '',
174
+ setSelectedLabels: vi.fn(),
175
+ setSelectedAssignees: vi.fn(),
176
+ setSelectedProjects: vi.fn(),
177
+ setAvailableLabels: vi.fn(),
178
+ setNewLabelName: vi.fn(),
179
+ setNewLabelColor: vi.fn(),
180
+ setNewProjectName: vi.fn(),
181
+ setShowNewLabelDialog: vi.fn(),
182
+ setShowNewProjectDialog: vi.fn(),
183
+ onUpdate: vi.fn(),
184
+ }),
185
+ { wrapper }
186
+ );
187
+
188
+ await act(async () => {
189
+ await result.current.toggleLabel(newLabel);
190
+ });
191
+
192
+ expect(
193
+ queryClient.getQueryData<Task[]>(['tasks', 'board-1'])?.[0]?.labels
194
+ ).toEqual([newLabel]);
195
+ expect(
196
+ queryClient.getQueryData<Task[]>(['tasks-full', 'board-1', 'all'])?.[0]
197
+ ?.labels
198
+ ).toEqual([newLabel]);
199
+ expect(queryClient.getQueryData<Task>(['task', 'task-1'])?.labels).toEqual([
200
+ newLabel,
201
+ ]);
202
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
203
+ queryKey: ['tasks', 'board-1'],
204
+ });
205
+ expect(invalidateSpy).not.toHaveBeenCalledWith({
206
+ queryKey: ['tasks-full', 'board-1'],
207
+ });
208
+ expect(mocks.broadcast).toHaveBeenCalledWith('task:relations-changed', {
209
+ taskId: 'task-1',
210
+ });
211
+ });
149
212
  });
@@ -1,7 +1,5 @@
1
1
  import { type QueryClient, useQueryClient } from '@tanstack/react-query';
2
- import type { Task } from '@tuturuuu/types/primitives/Task';
3
2
  import { toast } from '@tuturuuu/ui/sonner';
4
- import { invalidateTaskCaches } from '@tuturuuu/utils/task-helper';
5
3
  import {
6
4
  type Dispatch,
7
5
  type SetStateAction,
@@ -11,9 +9,11 @@ import {
11
9
  import { getRandomNewLabelColor } from '../../../utils/taskConstants';
12
10
  import {
13
11
  type BoardBroadcastFn,
12
+ getActiveBoardRefresh,
14
13
  getActiveBroadcast,
15
14
  useBoardBroadcast,
16
15
  } from '../../board-broadcast-context';
16
+ import { patchTaskInVisibleCaches } from '../../task-cache-patches';
17
17
  import type {
18
18
  WorkspaceTaskAssignee,
19
19
  WorkspaceTaskLabel,
@@ -129,6 +129,32 @@ function upsertWorkspaceLabelCaches({
129
129
  }
130
130
  }
131
131
 
132
+ function normalizeTaskAssignees(assignees: WorkspaceTaskAssignee[]) {
133
+ return assignees
134
+ .map((assignee) => {
135
+ const id = assignee.user_id || assignee.id;
136
+ if (!id) return null;
137
+
138
+ return {
139
+ id,
140
+ display_name: assignee.display_name ?? undefined,
141
+ email: assignee.email ?? undefined,
142
+ avatar_url: assignee.avatar_url ?? undefined,
143
+ };
144
+ })
145
+ .filter((assignee): assignee is NonNullable<typeof assignee> =>
146
+ Boolean(assignee)
147
+ );
148
+ }
149
+
150
+ function normalizeTaskProjects(projects: WorkspaceTaskProject[]) {
151
+ return projects.map((project) => ({
152
+ id: project.id,
153
+ name: project.name,
154
+ status: project.status ?? 'unknown',
155
+ }));
156
+ }
157
+
132
158
  export function useTaskRelationships({
133
159
  wsId,
134
160
  labelCacheWorkspaceId,
@@ -189,9 +215,18 @@ export function useTaskRelationships({
189
215
  });
190
216
 
191
217
  setSelectedLabels(nextSelectedLabels);
192
- await invalidateTaskCaches(queryClient, boardId);
218
+ patchTaskInVisibleCaches({
219
+ queryClient,
220
+ boardId,
221
+ taskId,
222
+ updater: (task) => ({
223
+ ...task,
224
+ labels: nextSelectedLabels,
225
+ }),
226
+ });
193
227
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
194
228
  broadcast?.('task:relations-changed', { taskId });
229
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
195
230
  onUpdate();
196
231
  } catch (error: unknown) {
197
232
  toast.error('Label update failed', {
@@ -246,30 +281,18 @@ export function useTaskRelationships({
246
281
  });
247
282
 
248
283
  setSelectedAssignees(nextSelectedAssignees);
249
- queryClient.setQueryData(
250
- ['tasks', boardId],
251
- (old: Task[] | undefined) => {
252
- if (!old) return old;
253
- return old.map((task) => {
254
- if (task.id !== taskId) return task;
255
- const currentAssignees = task.assignees || [];
256
- const newAssignees = exists
257
- ? currentAssignees.filter((assignee) => assignee.id !== userId)
258
- : [
259
- ...currentAssignees,
260
- {
261
- id: userId,
262
- display_name: member.display_name,
263
- email: member.email,
264
- avatar_url: member.avatar_url,
265
- },
266
- ];
267
- return { ...task, assignees: newAssignees };
268
- });
269
- }
270
- );
284
+ patchTaskInVisibleCaches({
285
+ queryClient,
286
+ boardId,
287
+ taskId,
288
+ updater: (task) => ({
289
+ ...task,
290
+ assignees: normalizeTaskAssignees(nextSelectedAssignees),
291
+ }),
292
+ });
271
293
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
272
294
  broadcast?.('task:relations-changed', { taskId });
295
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
273
296
  onUpdate();
274
297
  } catch (error: unknown) {
275
298
  toast.error('Assignee update failed', {
@@ -315,22 +338,18 @@ export function useTaskRelationships({
315
338
  });
316
339
 
317
340
  setSelectedProjects(nextSelectedProjects);
318
- queryClient.setQueryData(
319
- ['tasks', boardId],
320
- (old: Task[] | undefined) => {
321
- if (!old) return old;
322
- return old.map((task) => {
323
- if (task.id !== taskId) return task;
324
- const currentProjects = task.projects || [];
325
- const newProjects = exists
326
- ? currentProjects.filter((entry) => entry.id !== project.id)
327
- : [...currentProjects, project];
328
- return { ...task, projects: newProjects };
329
- });
330
- }
331
- );
341
+ patchTaskInVisibleCaches({
342
+ queryClient,
343
+ boardId,
344
+ taskId,
345
+ updater: (task) => ({
346
+ ...task,
347
+ projects: normalizeTaskProjects(nextSelectedProjects),
348
+ }),
349
+ });
332
350
  queryClient.invalidateQueries({ queryKey: ['task-history'] });
333
351
  broadcast?.('task:relations-changed', { taskId });
352
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
334
353
  onUpdate();
335
354
  } catch (error: unknown) {
336
355
  toast.error('Project update failed', {
@@ -393,23 +412,17 @@ export function useTaskRelationships({
393
412
  });
394
413
 
395
414
  setSelectedLabels(nextSelectedLabels);
396
- queryClient.setQueryData(
397
- ['tasks', boardId],
398
- (old: Task[] | undefined) => {
399
- if (!old) return old;
400
- return old.map((task) => {
401
- if (task.id !== taskId) return task;
402
- const currentLabels = task.labels || [];
403
- return {
404
- ...task,
405
- labels: [...currentLabels, newLabel].sort((a, b) =>
406
- compareLabelsByName(a, b)
407
- ),
408
- };
409
- });
410
- }
411
- );
415
+ patchTaskInVisibleCaches({
416
+ queryClient,
417
+ boardId,
418
+ taskId,
419
+ updater: (task) => ({
420
+ ...task,
421
+ labels: nextSelectedLabels,
422
+ }),
423
+ });
412
424
  broadcast?.('task:relations-changed', { taskId });
425
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
413
426
  onUpdate();
414
427
  toast.success('Label created & linked', {
415
428
  description: 'New label added and attached to this task.',
@@ -485,21 +498,17 @@ export function useTaskRelationships({
485
498
  });
486
499
 
487
500
  setSelectedProjects(nextSelectedProjects);
488
- queryClient.setQueryData(
489
- ['tasks', boardId],
490
- (old: Task[] | undefined) => {
491
- if (!old) return old;
492
- return old.map((task) => {
493
- if (task.id !== taskId) return task;
494
- const currentProjects = task.projects || [];
495
- return {
496
- ...task,
497
- projects: [...currentProjects, newProject],
498
- };
499
- });
500
- }
501
- );
501
+ patchTaskInVisibleCaches({
502
+ queryClient,
503
+ boardId,
504
+ taskId,
505
+ updater: (task) => ({
506
+ ...task,
507
+ projects: normalizeTaskProjects(nextSelectedProjects),
508
+ }),
509
+ });
502
510
  broadcast?.('task:relations-changed', { taskId });
511
+ getActiveBoardRefresh()?.({ invalidateTasks: false });
503
512
  onUpdate();
504
513
  toast.success('Project created & linked', {
505
514
  description: 'New project added and attached to this task.',
@@ -38,6 +38,8 @@ interface PersonalOverridesSectionProps {
38
38
  onUpdate?: () => void;
39
39
  }
40
40
 
41
+ type PersonalOverridePopoverId = 'priority' | 'estimation';
42
+
41
43
  export function PersonalOverridesSection({
42
44
  taskId,
43
45
  isCreateMode,
@@ -51,10 +53,22 @@ export function PersonalOverridesSection({
51
53
  onUpdate
52
54
  );
53
55
  const [isExpanded, setIsExpanded] = useState(false);
54
- const [isPriorityOpen, setIsPriorityOpen] = useState(false);
55
- const [isEstimationOpen, setIsEstimationOpen] = useState(false);
56
+ const [activePopover, setActivePopover] =
57
+ useState<PersonalOverridePopoverId | null>(null);
56
58
  const [notes, setNotes] = useState('');
57
59
 
60
+ const isPopoverOpen = (popoverId: PersonalOverridePopoverId) =>
61
+ activePopover === popoverId;
62
+ const setPopoverOpen = (
63
+ popoverId: PersonalOverridePopoverId,
64
+ open: boolean
65
+ ) => {
66
+ setActivePopover((currentPopover) => {
67
+ if (open) return popoverId;
68
+ return currentPopover === popoverId ? null : currentPopover;
69
+ });
70
+ };
71
+
58
72
  // Don't render for new tasks
59
73
  if (isCreateMode || !taskId) return null;
60
74
 
@@ -75,7 +89,7 @@ export function PersonalOverridesSection({
75
89
 
76
90
  const handlePriorityChange = (priority: TaskPriority | null) => {
77
91
  upsert({ priority_override: priority });
78
- setIsPriorityOpen(false);
92
+ setPopoverOpen('priority', false);
79
93
  };
80
94
 
81
95
  const handleDueDateChange = (date: Date | undefined) => {
@@ -86,7 +100,7 @@ export function PersonalOverridesSection({
86
100
 
87
101
  const handleEstimationChange = (points: number | null) => {
88
102
  upsert({ estimation_override: points });
89
- setIsEstimationOpen(false);
103
+ setPopoverOpen('estimation', false);
90
104
  };
91
105
 
92
106
  const handleNotesBlur = () => {
@@ -112,7 +126,10 @@ export function PersonalOverridesSection({
112
126
  <div className="border-t bg-muted/20">
113
127
  <button
114
128
  type="button"
115
- onClick={() => setIsExpanded(!isExpanded)}
129
+ onClick={() => {
130
+ setIsExpanded(!isExpanded);
131
+ setActivePopover(null);
132
+ }}
116
133
  className="flex w-full items-center justify-between px-4 py-2 text-left transition-colors hover:bg-muted/40 md:px-8"
117
134
  >
118
135
  <div className="flex items-center gap-2">
@@ -167,7 +184,10 @@ export function PersonalOverridesSection({
167
184
  <Flag className="h-4 w-4 text-dynamic-orange" />
168
185
  {t('ws-tasks.my_priority')}
169
186
  </Label>
170
- <Popover open={isPriorityOpen} onOpenChange={setIsPriorityOpen}>
187
+ <Popover
188
+ open={isPopoverOpen('priority')}
189
+ onOpenChange={(open) => setPopoverOpen('priority', open)}
190
+ >
171
191
  <PopoverTrigger asChild>
172
192
  <Button
173
193
  variant="outline"
@@ -255,8 +275,8 @@ export function PersonalOverridesSection({
255
275
  {t('ws-tasks.my_estimate')}
256
276
  </Label>
257
277
  <Popover
258
- open={isEstimationOpen}
259
- onOpenChange={setIsEstimationOpen}
278
+ open={isPopoverOpen('estimation')}
279
+ onOpenChange={(open) => setPopoverOpen('estimation', open)}
260
280
  >
261
281
  <PopoverTrigger asChild>
262
282
  <Button
@@ -25,10 +25,15 @@ export function DependenciesSection({
25
25
  onNavigateToTask,
26
26
  onAddBlockingTaskDialog,
27
27
  onAddBlockedByTaskDialog,
28
+ searchOpen: controlledSearchOpen,
29
+ onSearchOpenChange,
28
30
  disabled,
29
31
  }: DependenciesSectionProps) {
30
32
  const [subTab, setSubTab] = React.useState<DependencySubTab>(initialSubTab);
31
- const [searchOpen, setSearchOpen] = React.useState(false);
33
+ const [uncontrolledSearchOpen, setUncontrolledSearchOpen] =
34
+ React.useState(false);
35
+ const searchOpen = controlledSearchOpen ?? uncontrolledSearchOpen;
36
+ const setSearchOpen = onSearchOpenChange ?? setUncontrolledSearchOpen;
32
37
 
33
38
  const allExcludeIds = React.useMemo(() => {
34
39
  const ids = new Set<string>();
@@ -56,7 +61,10 @@ export function DependenciesSection({
56
61
  <Button
57
62
  variant={subTab === 'blocks' ? 'default' : 'outline'}
58
63
  size="sm"
59
- onClick={() => setSubTab('blocks')}
64
+ onClick={() => {
65
+ setSubTab('blocks');
66
+ setSearchOpen(false);
67
+ }}
60
68
  className="h-7 text-xs"
61
69
  >
62
70
  Blocks ({blockingTasks.length})
@@ -64,7 +72,10 @@ export function DependenciesSection({
64
72
  <Button
65
73
  variant={subTab === 'blocked-by' ? 'default' : 'outline'}
66
74
  size="sm"
67
- onClick={() => setSubTab('blocked-by')}
75
+ onClick={() => {
76
+ setSubTab('blocked-by');
77
+ setSearchOpen(false);
78
+ }}
68
79
  className="h-7 text-xs"
69
80
  >
70
81
  Blocked By ({blockedByTasks.length})