@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
@@ -0,0 +1,351 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { Loader2, Trash2, Users } from '@tuturuuu/icons';
5
+ import {
6
+ createWorkspaceTaskBoardShare,
7
+ deleteWorkspaceTaskBoardShare,
8
+ listWorkspaceTaskBoardShares,
9
+ listWorkspaceTaskBoardViewableMembers,
10
+ updateWorkspaceTaskBoardShare,
11
+ type WorkspaceTaskBoardShare,
12
+ type WorkspaceTaskBoardSharePermission,
13
+ type WorkspaceTaskBoardViewableMember,
14
+ } from '@tuturuuu/internal-api/tasks';
15
+ import type { WorkspaceTaskBoard } from '@tuturuuu/types';
16
+ import { Avatar, AvatarFallback, AvatarImage } from '@tuturuuu/ui/avatar';
17
+ import { Badge } from '@tuturuuu/ui/badge';
18
+ import { Button } from '@tuturuuu/ui/button';
19
+ import { Combobox } from '@tuturuuu/ui/custom/combobox';
20
+ import { Input } from '@tuturuuu/ui/input';
21
+ import { getInitials } from '@tuturuuu/utils/name-helper';
22
+ import { useTranslations } from 'next-intl';
23
+ import { useState } from 'react';
24
+ import { toast } from 'sonner';
25
+ import { BoardPublicLinkSection } from './board-public-link-section';
26
+ import { ShareSection } from './share-section';
27
+
28
+ interface BoardShareSettingsPanelProps {
29
+ board: Pick<WorkspaceTaskBoard, 'id' | 'name'>;
30
+ enabled?: boolean;
31
+ wsId: string;
32
+ }
33
+
34
+ function shareDisplayName(share: WorkspaceTaskBoardShare) {
35
+ return (
36
+ share.user?.display_name ||
37
+ (share.user?.handle ? `@${share.user.handle}` : null) ||
38
+ share.email ||
39
+ share.user_id ||
40
+ 'Guest'
41
+ );
42
+ }
43
+
44
+ function viewableMemberDisplayName(member: WorkspaceTaskBoardViewableMember) {
45
+ return (
46
+ member.display_name ||
47
+ (member.handle ? `@${member.handle}` : null) ||
48
+ member.email ||
49
+ member.user_id
50
+ );
51
+ }
52
+
53
+ export function BoardShareSettingsPanel({
54
+ board,
55
+ enabled = true,
56
+ wsId,
57
+ }: BoardShareSettingsPanelProps) {
58
+ const t = useTranslations();
59
+ const queryClient = useQueryClient();
60
+ const [email, setEmail] = useState('');
61
+ const [permission, setPermission] =
62
+ useState<WorkspaceTaskBoardSharePermission>('view');
63
+ const [membersOpen, setMembersOpen] = useState(false);
64
+ const [guestsOpen, setGuestsOpen] = useState(false);
65
+
66
+ const queryKey = ['task-board-shares', wsId, board.id] as const;
67
+ const sharesQuery = useQuery({
68
+ queryKey,
69
+ queryFn: () => listWorkspaceTaskBoardShares(wsId, board.id),
70
+ enabled,
71
+ });
72
+ const viewableMembersQuery = useQuery({
73
+ queryKey: ['task-board-viewable-members', wsId, board.id] as const,
74
+ queryFn: () => listWorkspaceTaskBoardViewableMembers(wsId, board.id),
75
+ enabled: enabled && membersOpen,
76
+ staleTime: 60_000,
77
+ });
78
+
79
+ const createMutation = useMutation({
80
+ mutationFn: () =>
81
+ createWorkspaceTaskBoardShare(wsId, board.id, {
82
+ email,
83
+ permission,
84
+ }),
85
+ onSuccess: () => {
86
+ setEmail('');
87
+ setPermission('view');
88
+ void queryClient.invalidateQueries({ queryKey });
89
+ toast.success(t('ws-task-boards.share.saved'));
90
+ },
91
+ onError: () => {
92
+ toast.error(t('common.error'));
93
+ },
94
+ });
95
+
96
+ const updateMutation = useMutation({
97
+ mutationFn: ({
98
+ nextPermission,
99
+ shareId,
100
+ }: {
101
+ nextPermission: WorkspaceTaskBoardSharePermission;
102
+ shareId: string;
103
+ }) =>
104
+ updateWorkspaceTaskBoardShare(wsId, board.id, {
105
+ shareId,
106
+ permission: nextPermission,
107
+ }),
108
+ onSuccess: () => {
109
+ void queryClient.invalidateQueries({ queryKey });
110
+ },
111
+ onError: () => {
112
+ toast.error(t('common.error'));
113
+ },
114
+ });
115
+
116
+ const deleteMutation = useMutation({
117
+ mutationFn: (shareId: string) =>
118
+ deleteWorkspaceTaskBoardShare(wsId, board.id, shareId),
119
+ onSuccess: () => {
120
+ void queryClient.invalidateQueries({ queryKey });
121
+ toast.success(t('ws-task-boards.share.removed'));
122
+ },
123
+ onError: () => {
124
+ toast.error(t('common.error'));
125
+ },
126
+ });
127
+
128
+ const shares = sharesQuery.data?.shares ?? [];
129
+ const members = Array.isArray(viewableMembersQuery.data?.members)
130
+ ? viewableMembersQuery.data.members
131
+ : undefined;
132
+ const membersCount = members?.length;
133
+ const membersStatusBadge = viewableMembersQuery.isLoading ? (
134
+ <Badge variant="secondary" className="gap-1 px-2 py-0.5 text-[10px]">
135
+ <Loader2 className="h-3 w-3 animate-spin" />
136
+ {t('common.loading')}
137
+ </Badge>
138
+ ) : typeof membersCount === 'number' ? (
139
+ <Badge variant="secondary" className="px-2 py-0.5 text-[10px]">
140
+ {membersCount}
141
+ </Badge>
142
+ ) : (
143
+ <Badge variant="outline" className="px-2 py-0.5 text-[10px]">
144
+ {t('common.workspace')}
145
+ </Badge>
146
+ );
147
+ const guestsStatusBadge = sharesQuery.isLoading ? (
148
+ <Badge variant="secondary" className="gap-1 px-2 py-0.5 text-[10px]">
149
+ <Loader2 className="h-3 w-3 animate-spin" />
150
+ {t('common.loading')}
151
+ </Badge>
152
+ ) : shares.length ? (
153
+ <Badge variant="secondary" className="px-2 py-0.5 text-[10px]">
154
+ {shares.length}
155
+ </Badge>
156
+ ) : (
157
+ <Badge variant="outline" className="px-2 py-0.5 text-[10px]">
158
+ {t('common.none')}
159
+ </Badge>
160
+ );
161
+ const canSubmit =
162
+ email.trim().length > 0 &&
163
+ !createMutation.isPending &&
164
+ !sharesQuery.isLoading;
165
+ const permissionOptions = [
166
+ {
167
+ value: 'view',
168
+ label: t('ws-task-boards.share.permission.view'),
169
+ },
170
+ {
171
+ value: 'edit',
172
+ label: t('ws-task-boards.share.permission.edit'),
173
+ },
174
+ ];
175
+
176
+ return (
177
+ <div className="space-y-2">
178
+ <BoardPublicLinkSection boardId={board.id} open={enabled} wsId={wsId} />
179
+
180
+ <ShareSection
181
+ open={membersOpen}
182
+ onOpenChange={setMembersOpen}
183
+ title={t('ws-task-boards.share.workspace_members.title')}
184
+ tooltip={t('ws-task-boards.share.workspace_members.tooltip')}
185
+ icon={<Users className="h-4 w-4 text-muted-foreground" />}
186
+ statusBadge={membersStatusBadge}
187
+ >
188
+ {viewableMembersQuery.isLoading ? (
189
+ <div className="flex items-center gap-2 text-muted-foreground text-sm">
190
+ <Loader2 className="h-4 w-4 animate-spin" />
191
+ {t('common.loading')}
192
+ </div>
193
+ ) : (members ?? []).length === 0 ? (
194
+ <div className="text-muted-foreground text-sm">
195
+ {t('ws-task-boards.share.workspace_members.empty')}
196
+ </div>
197
+ ) : (
198
+ <div className="divide-y rounded-md border">
199
+ {members?.map((member) => (
200
+ <div
201
+ key={member.user_id}
202
+ className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"
203
+ >
204
+ <div className="flex min-w-0 flex-1 items-center gap-3">
205
+ <Avatar className="h-8 w-8">
206
+ <AvatarImage src={member.avatar_url ?? undefined} />
207
+ <AvatarFallback>
208
+ {getInitials(viewableMemberDisplayName(member))}
209
+ </AvatarFallback>
210
+ </Avatar>
211
+ <div className="min-w-0">
212
+ <div className="truncate font-medium text-sm">
213
+ {viewableMemberDisplayName(member)}
214
+ </div>
215
+ <div className="truncate text-muted-foreground text-xs">
216
+ {member.email || member.user_id}
217
+ </div>
218
+ </div>
219
+ </div>
220
+ <div className="flex flex-wrap items-center gap-1.5">
221
+ {member.is_creator && (
222
+ <Badge variant="secondary">
223
+ {t('ws-task-boards.share.workspace_members.creator')}
224
+ </Badge>
225
+ )}
226
+ {member.roles.slice(0, 2).map((role) => (
227
+ <Badge key={role.id} variant="outline">
228
+ {role.name}
229
+ </Badge>
230
+ ))}
231
+ <Badge variant="outline">
232
+ {t('ws-task-boards.share.workspace_members.badge')}
233
+ </Badge>
234
+ </div>
235
+ </div>
236
+ ))}
237
+ </div>
238
+ )}
239
+ </ShareSection>
240
+
241
+ <ShareSection
242
+ open={guestsOpen}
243
+ onOpenChange={setGuestsOpen}
244
+ title={t('ws-task-boards.share.guests.title')}
245
+ tooltip={t('ws-task-boards.share.guests.tooltip')}
246
+ icon={<Users className="h-4 w-4 text-muted-foreground" />}
247
+ statusBadge={guestsStatusBadge}
248
+ >
249
+ <div className="space-y-3">
250
+ <div className="grid gap-2 sm:grid-cols-[1fr_8rem_auto]">
251
+ <Input
252
+ type="email"
253
+ value={email}
254
+ onChange={(event) => setEmail(event.target.value)}
255
+ placeholder={t('ws-task-boards.share.email_placeholder')}
256
+ />
257
+ <Combobox
258
+ mode="single"
259
+ options={permissionOptions}
260
+ selected={permission}
261
+ onChange={(value) =>
262
+ setPermission(value as WorkspaceTaskBoardSharePermission)
263
+ }
264
+ placeholder={t('ws-task-boards.share.permission.view')}
265
+ searchPlaceholder={t('common.search_members')}
266
+ className="[&_button]:h-9"
267
+ />
268
+ <Button
269
+ type="button"
270
+ onClick={() => createMutation.mutate()}
271
+ disabled={!canSubmit}
272
+ >
273
+ {createMutation.isPending ? (
274
+ <Loader2 className="h-4 w-4 animate-spin" />
275
+ ) : (
276
+ t('common.share')
277
+ )}
278
+ </Button>
279
+ </div>
280
+
281
+ {sharesQuery.isLoading ? (
282
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
283
+ {t('common.loading')}
284
+ </div>
285
+ ) : shares.length === 0 ? (
286
+ <div className="rounded-md border border-dashed p-4 text-muted-foreground text-sm">
287
+ {t('ws-task-boards.share.empty')}
288
+ </div>
289
+ ) : (
290
+ <div className="divide-y rounded-md border">
291
+ {shares.map((share) => (
292
+ <div
293
+ key={share.id}
294
+ className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center"
295
+ >
296
+ <div className="flex min-w-0 flex-1 items-center gap-3">
297
+ <Avatar className="h-8 w-8">
298
+ <AvatarImage src={share.user?.avatar_url ?? undefined} />
299
+ <AvatarFallback>
300
+ {getInitials(shareDisplayName(share))}
301
+ </AvatarFallback>
302
+ </Avatar>
303
+ <div className="min-w-0">
304
+ <div className="truncate font-medium text-sm">
305
+ {shareDisplayName(share)}
306
+ </div>
307
+ <div className="truncate text-muted-foreground text-xs">
308
+ {share.email || share.user_id}
309
+ </div>
310
+ </div>
311
+ </div>
312
+
313
+ <Badge variant="outline" className="w-fit">
314
+ {t('common.guest_access')}
315
+ </Badge>
316
+
317
+ <Combobox
318
+ mode="single"
319
+ options={permissionOptions}
320
+ selected={share.permission}
321
+ onChange={(value) =>
322
+ updateMutation.mutate({
323
+ shareId: share.id,
324
+ nextPermission:
325
+ value as WorkspaceTaskBoardSharePermission,
326
+ })
327
+ }
328
+ placeholder={t('ws-task-boards.share.permission.view')}
329
+ searchPlaceholder={t('common.search_members')}
330
+ className="w-28 [&_button]:h-9"
331
+ />
332
+
333
+ <Button
334
+ type="button"
335
+ variant="ghost"
336
+ size="icon"
337
+ onClick={() => deleteMutation.mutate(share.id)}
338
+ disabled={deleteMutation.isPending}
339
+ aria-label={t('common.remove')}
340
+ >
341
+ <Trash2 className="h-4 w-4" />
342
+ </Button>
343
+ </div>
344
+ ))}
345
+ </div>
346
+ )}
347
+ </div>
348
+ </ShareSection>
349
+ </div>
350
+ );
351
+ }
@@ -51,6 +51,7 @@ import {
51
51
  } from './kanban/kanban-column-collapse';
52
52
  import { ListActions } from './list-actions';
53
53
  import { statusIcons } from './status-section';
54
+ import type { TaskCardAssigneeMemberSource } from './task-card/task-card';
54
55
  import type { TaskFilters } from './task-filter';
55
56
  import { VirtualizedTaskList } from './task-list';
56
57
 
@@ -163,6 +164,8 @@ interface BoardColumnProps {
163
164
  isMultiSelectMode?: boolean;
164
165
  setIsMultiSelectMode?: (value: boolean) => void;
165
166
  isPersonalWorkspace?: boolean;
167
+ canUseBoardAssignees?: boolean;
168
+ assigneeMemberSource?: TaskCardAssigneeMemberSource;
166
169
  onTaskSelect?: (taskId: string, event: React.MouseEvent) => void;
167
170
  onClearSelection?: () => void;
168
171
  onAddTask?: (list: TaskList) => void;
@@ -177,6 +180,7 @@ interface BoardColumnProps {
177
180
  onExternalTasksCollapsedChange?: (collapsed: boolean) => void;
178
181
  onTaskListCollapsedChange?: (listId: string, collapsed: boolean) => void;
179
182
  specialPinned?: boolean;
183
+ specialStickyOffset?: string;
180
184
  onSpecialPinnedChange?: (pinned: boolean) => void;
181
185
  readOnly?: boolean;
182
186
  }
@@ -194,6 +198,8 @@ export function BoardColumn({
194
198
  isMultiSelectMode,
195
199
  setIsMultiSelectMode,
196
200
  isPersonalWorkspace,
201
+ canUseBoardAssignees,
202
+ assigneeMemberSource,
197
203
  onAddTask,
198
204
  dragPreviewPosition,
199
205
  suppressTaskTransforms,
@@ -206,6 +212,7 @@ export function BoardColumn({
206
212
  onExternalTasksCollapsedChange,
207
213
  onTaskListCollapsedChange,
208
214
  specialPinned = false,
215
+ specialStickyOffset,
209
216
  onSpecialPinnedChange,
210
217
  readOnly = false,
211
218
  }: BoardColumnProps) {
@@ -410,6 +417,12 @@ export function BoardColumn({
410
417
  transform: CSS.Transform.toString(transform),
411
418
  transition,
412
419
  };
420
+ const columnStyle: React.CSSProperties = specialStickyOffset
421
+ ? {
422
+ ...style,
423
+ left: `calc(var(--kanban-snap-left-padding) + ${specialStickyOffset})`,
424
+ }
425
+ : style;
413
426
 
414
427
  const handleUpdate = () => {
415
428
  onUpdate?.();
@@ -513,12 +526,14 @@ export function BoardColumn({
513
526
  return (
514
527
  <Card
515
528
  ref={composedRef}
516
- style={style}
529
+ style={columnStyle}
530
+ data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
517
531
  data-kanban-column-id={column.id}
518
532
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
519
533
  className={cn(
520
534
  'group flex h-full w-14 shrink-0 snap-start flex-col items-center rounded-xl border border-dashed transition-all duration-200',
521
535
  'touch-none select-none overflow-hidden hover:shadow-md',
536
+ specialStickyOffset && 'sticky z-30',
522
537
  isExternalCollapsed
523
538
  ? 'border-dynamic-cyan/45 bg-dynamic-cyan/[0.035]'
524
539
  : colorClass
@@ -564,12 +579,14 @@ export function BoardColumn({
564
579
  return (
565
580
  <Card
566
581
  ref={composedRef}
567
- style={style}
582
+ style={columnStyle}
583
+ data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
568
584
  data-kanban-column-id={column.id}
569
585
  data-kanban-real-column={isExternalStaging ? undefined : 'true'}
570
586
  className={cn(
571
587
  'group flex h-full w-[var(--kanban-column-width)] shrink-0 snap-start flex-col rounded-xl transition-all duration-200 last:snap-end',
572
588
  'touch-none select-none',
589
+ specialStickyOffset && 'sticky z-30',
573
590
  colorClass,
574
591
  isDragging &&
575
592
  'rotate-1 scale-[1.02] opacity-90 shadow-xl ring-2 ring-primary/20',
@@ -742,19 +759,17 @@ export function BoardColumn({
742
759
  )}
743
760
  </Button>
744
761
  ) : null}
745
- {!specialPinned && (
746
- <Button
747
- type="button"
748
- variant="ghost"
749
- size="xs"
750
- className="h-7 w-7 p-0 text-dynamic-cyan hover:bg-dynamic-cyan/10"
751
- title={tTasks('collapse_external_tasks')}
752
- aria-label={tTasks('collapse_external_tasks')}
753
- onClick={() => onExternalTasksCollapsedChange?.(true)}
754
- >
755
- <ChevronLeft className="h-3.5 w-3.5" />
756
- </Button>
757
- )}
762
+ <Button
763
+ type="button"
764
+ variant="ghost"
765
+ size="xs"
766
+ className="h-7 w-7 p-0 text-dynamic-cyan hover:bg-dynamic-cyan/10"
767
+ title={tTasks('collapse_external_tasks')}
768
+ aria-label={tTasks('collapse_external_tasks')}
769
+ onClick={() => onExternalTasksCollapsedChange?.(true)}
770
+ >
771
+ <ChevronLeft className="h-3.5 w-3.5" />
772
+ </Button>
758
773
  </>
759
774
  ) : (
760
775
  <>
@@ -781,28 +796,24 @@ export function BoardColumn({
781
796
  )}
782
797
  </Button>
783
798
  ) : null}
784
- {!specialPinned && (
785
- <Button
786
- type="button"
787
- variant="ghost"
788
- size="xs"
789
- className={cn(
790
- 'h-7 w-7 p-0 hover:bg-muted/40',
791
- getListTextColorClass(column.color as SupportedColor)
792
- )}
793
- title={tTasks('collapse_task_list', {
794
- name: translateListName(column.name),
795
- })}
796
- aria-label={tTasks('collapse_task_list', {
797
- name: translateListName(column.name),
798
- })}
799
- onClick={() =>
800
- onTaskListCollapsedChange?.(column.id, true)
801
- }
802
- >
803
- <ChevronLeft className="h-3.5 w-3.5" />
804
- </Button>
805
- )}
799
+ <Button
800
+ type="button"
801
+ variant="ghost"
802
+ size="xs"
803
+ className={cn(
804
+ 'h-7 w-7 p-0 hover:bg-muted/40',
805
+ getListTextColorClass(column.color as SupportedColor)
806
+ )}
807
+ title={tTasks('collapse_task_list', {
808
+ name: translateListName(column.name),
809
+ })}
810
+ aria-label={tTasks('collapse_task_list', {
811
+ name: translateListName(column.name),
812
+ })}
813
+ onClick={() => onTaskListCollapsedChange?.(column.id, true)}
814
+ >
815
+ <ChevronLeft className="h-3.5 w-3.5" />
816
+ </Button>
806
817
  </>
807
818
  ) : null}
808
819
  {!readOnly && (
@@ -841,6 +852,8 @@ export function BoardColumn({
841
852
  isMultiSelectMode={isMultiSelectMode}
842
853
  selectedTasks={selectedTasks}
843
854
  isPersonalWorkspace={isPersonalWorkspace}
855
+ canUseBoardAssignees={canUseBoardAssignees}
856
+ assigneeMemberSource={assigneeMemberSource}
844
857
  onTaskSelect={onTaskSelect}
845
858
  onClearSelection={onClearSelection}
846
859
  dragPreviewPosition={dragPreviewPosition}
@@ -44,6 +44,7 @@ import { useBoardBroadcast } from '../../shared/board-broadcast-context';
44
44
  import { isTaskListNameExistsError } from '../../shared/task-board-errors';
45
45
  import { normalizeBoardText } from './board-text-utils';
46
46
  import { TaskCard } from './task';
47
+ import type { TaskCardAssigneeMemberSource } from './task-card/task-card';
47
48
 
48
49
  interface Props {
49
50
  list: TaskList;
@@ -54,6 +55,8 @@ interface Props {
54
55
  isOverlay?: boolean;
55
56
  hideTasksMode?: boolean;
56
57
  isPersonalWorkspace?: boolean;
58
+ canUseBoardAssignees?: boolean;
59
+ assigneeMemberSource?: TaskCardAssigneeMemberSource;
57
60
  onAddTask?: (list: TaskList) => void;
58
61
  }
59
62
 
@@ -102,6 +105,8 @@ export function EnhancedTaskList({
102
105
  isOverlay = false,
103
106
  hideTasksMode = false,
104
107
  isPersonalWorkspace = false,
108
+ canUseBoardAssignees,
109
+ assigneeMemberSource,
105
110
  onAddTask,
106
111
  }: Props) {
107
112
  const t = useTranslations('common');
@@ -479,6 +484,8 @@ export function EnhancedTaskList({
479
484
  taskList={list}
480
485
  boardId={boardId}
481
486
  isPersonalWorkspace={isPersonalWorkspace}
487
+ canUseBoardAssignees={canUseBoardAssignees}
488
+ assigneeMemberSource={assigneeMemberSource}
482
489
  onUpdate={onUpdate}
483
490
  />
484
491
  ))}
@@ -12,9 +12,9 @@ export interface WorkspaceProject {
12
12
  export interface WorkspaceMember {
13
13
  id: string;
14
14
  user_id?: string;
15
- display_name: string;
16
- email: string;
17
- avatar_url: string | null;
15
+ display_name?: string;
16
+ email?: string;
17
+ avatar_url?: string | null;
18
18
  }
19
19
 
20
20
  export interface BulkOperationsConfig {
@@ -5,14 +5,24 @@ import {
5
5
  listWorkspaceLabels,
6
6
  listWorkspaceTaskProjects,
7
7
  } from '@tuturuuu/internal-api';
8
+ import { listWorkspaceTaskBoardViewableMembers } from '@tuturuuu/internal-api/tasks';
8
9
  import type { Workspace } from '@tuturuuu/types';
9
- import { useWorkspaceMembers } from '@tuturuuu/ui/hooks/use-workspace-members';
10
+ import {
11
+ useWorkspaceMembers,
12
+ type WorkspaceMember,
13
+ } from '@tuturuuu/ui/hooks/use-workspace-members';
10
14
 
11
15
  export function useBulkResources({
16
+ boardId,
17
+ canUseBoardAssignees,
18
+ assigneeMemberSource,
12
19
  workspace,
13
20
  isMultiSelectMode,
14
21
  selectedCount,
15
22
  }: {
23
+ boardId?: string | null;
24
+ canUseBoardAssignees?: boolean;
25
+ assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
16
26
  workspace: Workspace;
17
27
  isMultiSelectMode: boolean;
18
28
  selectedCount: number;
@@ -46,13 +56,57 @@ export function useBulkResources({
46
56
  });
47
57
 
48
58
  // Workspace members for bulk operations
49
- const { data: workspaceMembers = [] } = useWorkspaceMembers(workspace.id, {
59
+ const shouldLoadMembers =
60
+ canUseBoardAssignees !== false && isMultiSelectMode && selectedCount > 0;
61
+ const effectiveAssigneeMemberSource =
62
+ assigneeMemberSource ?? (workspace.personal ? 'board' : 'workspace');
63
+ const { data: workspaceMembersData = [] } = useWorkspaceMembers(
64
+ workspace.id,
65
+ {
66
+ enabled:
67
+ !!workspace.id &&
68
+ shouldLoadMembers &&
69
+ effectiveAssigneeMemberSource !== 'board',
70
+ }
71
+ );
72
+ const { data: boardViewableMembers = [] } = useQuery({
73
+ queryKey: ['task-board-viewable-members', workspace.id, boardId],
74
+ queryFn: async (): Promise<WorkspaceMember[]> => {
75
+ if (!workspace.id || !boardId) return [];
76
+
77
+ const payload = await listWorkspaceTaskBoardViewableMembers(
78
+ workspace.id,
79
+ boardId
80
+ );
81
+ const members = Array.isArray(payload?.members) ? payload.members : [];
82
+
83
+ return members.map((member) => ({
84
+ id: member.user_id,
85
+ user_id: member.user_id,
86
+ workspace_id: workspace.id,
87
+ display_name: member.display_name ?? member.email ?? member.user_id,
88
+ email: member.email ?? undefined,
89
+ avatar_url: member.avatar_url ?? undefined,
90
+ }));
91
+ },
50
92
  enabled:
51
93
  !!workspace.id &&
52
- !workspace.personal &&
53
- isMultiSelectMode &&
54
- selectedCount > 0,
94
+ !!boardId &&
95
+ shouldLoadMembers &&
96
+ effectiveAssigneeMemberSource !== 'workspace',
97
+ staleTime: 5 * 60 * 1000,
55
98
  });
99
+ const workspaceMembers: WorkspaceMember[] = [
100
+ ...workspaceMembersData,
101
+ ...boardViewableMembers.filter(
102
+ (boardMember) =>
103
+ !workspaceMembersData.some(
104
+ (workspaceMember) =>
105
+ (workspaceMember.user_id ?? workspaceMember.id) ===
106
+ boardMember.user_id
107
+ )
108
+ ),
109
+ ];
56
110
 
57
111
  return { workspaceLabels, workspaceProjects, workspaceMembers };
58
112
  }