@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
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import type { UseMutationResult } from '@tanstack/react-query';
|
|
2
|
+
import { Plus, Target } from '@tuturuuu/icons';
|
|
3
|
+
import type { TaskProgressMetric } from '@tuturuuu/internal-api';
|
|
4
|
+
import { Badge } from '@tuturuuu/ui/badge';
|
|
5
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@tuturuuu/ui/card';
|
|
7
|
+
import { Input } from '@tuturuuu/ui/input';
|
|
8
|
+
import { Textarea } from '@tuturuuu/ui/textarea';
|
|
9
|
+
import type { useTranslations } from 'next-intl';
|
|
10
|
+
import type { ReactNode } from 'react';
|
|
11
|
+
|
|
12
|
+
type Translate = ReturnType<typeof useTranslations>;
|
|
13
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
14
|
+
|
|
15
|
+
export function SummaryCard({
|
|
16
|
+
icon,
|
|
17
|
+
label,
|
|
18
|
+
value,
|
|
19
|
+
}: {
|
|
20
|
+
icon: ReactNode;
|
|
21
|
+
label: string;
|
|
22
|
+
value: number;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<Card>
|
|
26
|
+
<CardHeader className="pb-2">
|
|
27
|
+
<CardTitle className="flex items-center gap-2 text-muted-foreground text-sm">
|
|
28
|
+
{icon}
|
|
29
|
+
{label}
|
|
30
|
+
</CardTitle>
|
|
31
|
+
</CardHeader>
|
|
32
|
+
<CardContent>
|
|
33
|
+
<div className="font-bold text-2xl">
|
|
34
|
+
{Number(value).toLocaleString()}
|
|
35
|
+
</div>
|
|
36
|
+
</CardContent>
|
|
37
|
+
</Card>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function MetricSelect({
|
|
42
|
+
metrics,
|
|
43
|
+
name = 'metric_id',
|
|
44
|
+
selectedMetric,
|
|
45
|
+
}: {
|
|
46
|
+
metrics: TaskProgressMetric[];
|
|
47
|
+
name?: string;
|
|
48
|
+
selectedMetric: TaskProgressMetric | null;
|
|
49
|
+
}) {
|
|
50
|
+
return (
|
|
51
|
+
<select
|
|
52
|
+
className="h-10 rounded-md border bg-background px-3 text-sm"
|
|
53
|
+
defaultValue={selectedMetric?.id}
|
|
54
|
+
name={name}
|
|
55
|
+
required
|
|
56
|
+
>
|
|
57
|
+
{metrics.map((metric) => (
|
|
58
|
+
<option key={metric.id} value={metric.id}>
|
|
59
|
+
{metric.name}
|
|
60
|
+
</option>
|
|
61
|
+
))}
|
|
62
|
+
</select>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ProgressPanel(props: {
|
|
67
|
+
createEntryMutation: UseMutationResult<any, unknown, FormData>;
|
|
68
|
+
createMetricMutation: UseMutationResult<any, unknown, FormData>;
|
|
69
|
+
entries: any[];
|
|
70
|
+
metrics: TaskProgressMetric[];
|
|
71
|
+
selectedMetric: TaskProgressMetric | null;
|
|
72
|
+
t: Translate;
|
|
73
|
+
}) {
|
|
74
|
+
const {
|
|
75
|
+
createEntryMutation,
|
|
76
|
+
createMetricMutation,
|
|
77
|
+
entries,
|
|
78
|
+
metrics,
|
|
79
|
+
selectedMetric,
|
|
80
|
+
t,
|
|
81
|
+
} = props;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
85
|
+
<Card>
|
|
86
|
+
<CardHeader>
|
|
87
|
+
<CardTitle>{t('progress.log_entry')}</CardTitle>
|
|
88
|
+
</CardHeader>
|
|
89
|
+
<CardContent>
|
|
90
|
+
<form
|
|
91
|
+
className="grid gap-3"
|
|
92
|
+
onSubmit={(event) => {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
createEntryMutation.mutate(new FormData(event.currentTarget));
|
|
95
|
+
event.currentTarget.reset();
|
|
96
|
+
}}
|
|
97
|
+
>
|
|
98
|
+
<MetricSelect metrics={metrics} selectedMetric={selectedMetric} />
|
|
99
|
+
<Input defaultValue={today()} name="entry_date" type="date" />
|
|
100
|
+
<Input
|
|
101
|
+
name="value"
|
|
102
|
+
placeholder={t('fields.value')}
|
|
103
|
+
required
|
|
104
|
+
type="number"
|
|
105
|
+
/>
|
|
106
|
+
<Input name="tags" placeholder={t('fields.tags')} />
|
|
107
|
+
<Textarea name="note" placeholder={t('fields.note')} />
|
|
108
|
+
<Button disabled={!selectedMetric || createEntryMutation.isPending}>
|
|
109
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
110
|
+
{t('actions.add_entry')}
|
|
111
|
+
</Button>
|
|
112
|
+
</form>
|
|
113
|
+
<form
|
|
114
|
+
className="mt-6 grid gap-3 border-t pt-4"
|
|
115
|
+
onSubmit={(event) => {
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
createMetricMutation.mutate(new FormData(event.currentTarget));
|
|
118
|
+
event.currentTarget.reset();
|
|
119
|
+
}}
|
|
120
|
+
>
|
|
121
|
+
<Input name="name" placeholder={t('fields.metric_name')} required />
|
|
122
|
+
<Input name="unit_label" placeholder={t('fields.unit')} required />
|
|
123
|
+
<Button disabled={createMetricMutation.isPending} variant="outline">
|
|
124
|
+
{t('actions.add_metric')}
|
|
125
|
+
</Button>
|
|
126
|
+
</form>
|
|
127
|
+
</CardContent>
|
|
128
|
+
</Card>
|
|
129
|
+
<Card>
|
|
130
|
+
<CardHeader>
|
|
131
|
+
<CardTitle>{t('progress.recent_entries')}</CardTitle>
|
|
132
|
+
</CardHeader>
|
|
133
|
+
<CardContent className="space-y-3">
|
|
134
|
+
{entries.length === 0 ? (
|
|
135
|
+
<p className="text-muted-foreground text-sm">
|
|
136
|
+
{t('empty.entries')}
|
|
137
|
+
</p>
|
|
138
|
+
) : (
|
|
139
|
+
entries.map((entry) => (
|
|
140
|
+
<div
|
|
141
|
+
className="flex items-start justify-between gap-3 rounded-md border p-3"
|
|
142
|
+
key={entry.id}
|
|
143
|
+
>
|
|
144
|
+
<div>
|
|
145
|
+
<div className="font-medium">{entry.entry_date}</div>
|
|
146
|
+
<div className="text-muted-foreground text-sm">
|
|
147
|
+
{entry.note || entry.metric?.name}
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mt-2 flex flex-wrap gap-1">
|
|
150
|
+
{(entry.tags ?? []).map((tag: string) => (
|
|
151
|
+
<Badge key={tag} variant="secondary">
|
|
152
|
+
{tag}
|
|
153
|
+
</Badge>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="text-right font-semibold">
|
|
158
|
+
{Number(entry.value).toLocaleString()}{' '}
|
|
159
|
+
{entry.metric?.unit_label}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
))
|
|
163
|
+
)}
|
|
164
|
+
</CardContent>
|
|
165
|
+
</Card>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function GoalsPanel(props: {
|
|
171
|
+
createGoalMutation: UseMutationResult<any, unknown, FormData>;
|
|
172
|
+
goals: any[];
|
|
173
|
+
metrics: TaskProgressMetric[];
|
|
174
|
+
selectedMetric: TaskProgressMetric | null;
|
|
175
|
+
t: Translate;
|
|
176
|
+
}) {
|
|
177
|
+
const { createGoalMutation, goals, metrics, selectedMetric, t } = props;
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
181
|
+
<Card>
|
|
182
|
+
<CardHeader>
|
|
183
|
+
<CardTitle>{t('goals.create_goal')}</CardTitle>
|
|
184
|
+
</CardHeader>
|
|
185
|
+
<CardContent>
|
|
186
|
+
<form
|
|
187
|
+
className="grid gap-3"
|
|
188
|
+
onSubmit={(event) => {
|
|
189
|
+
event.preventDefault();
|
|
190
|
+
createGoalMutation.mutate(new FormData(event.currentTarget));
|
|
191
|
+
event.currentTarget.reset();
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<Input name="name" placeholder={t('fields.goal_name')} required />
|
|
195
|
+
<MetricSelect metrics={metrics} selectedMetric={selectedMetric} />
|
|
196
|
+
<Input
|
|
197
|
+
name="target_value"
|
|
198
|
+
placeholder={t('fields.target')}
|
|
199
|
+
required
|
|
200
|
+
type="number"
|
|
201
|
+
/>
|
|
202
|
+
<Input defaultValue={today()} name="period_start" type="date" />
|
|
203
|
+
<Input name="period_end" type="date" />
|
|
204
|
+
<select
|
|
205
|
+
className="h-10 rounded-md border bg-background px-3 text-sm"
|
|
206
|
+
name="goal_type"
|
|
207
|
+
>
|
|
208
|
+
<option value="target">{t('goal_types.target')}</option>
|
|
209
|
+
<option value="habit">{t('goal_types.habit')}</option>
|
|
210
|
+
</select>
|
|
211
|
+
<Button disabled={!selectedMetric || createGoalMutation.isPending}>
|
|
212
|
+
<Target className="mr-2 h-4 w-4" />
|
|
213
|
+
{t('actions.add_goal')}
|
|
214
|
+
</Button>
|
|
215
|
+
</form>
|
|
216
|
+
</CardContent>
|
|
217
|
+
</Card>
|
|
218
|
+
<div className="grid gap-3">
|
|
219
|
+
{goals.length === 0 ? (
|
|
220
|
+
<Card>
|
|
221
|
+
<CardContent className="py-8 text-sm">
|
|
222
|
+
{t('empty.goals')}
|
|
223
|
+
</CardContent>
|
|
224
|
+
</Card>
|
|
225
|
+
) : (
|
|
226
|
+
goals.map((goal) => (
|
|
227
|
+
<Card key={goal.id}>
|
|
228
|
+
<CardContent className="space-y-3 py-4">
|
|
229
|
+
<div className="flex items-start justify-between gap-3">
|
|
230
|
+
<div>
|
|
231
|
+
<div className="font-semibold">{goal.name}</div>
|
|
232
|
+
<div className="text-muted-foreground text-sm">
|
|
233
|
+
{goal.period_start}
|
|
234
|
+
{goal.period_end ? ` - ${goal.period_end}` : ''}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<Badge variant="secondary">{goal.goal_type}</Badge>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
240
|
+
<div
|
|
241
|
+
className="h-full bg-dynamic-green"
|
|
242
|
+
style={{
|
|
243
|
+
width: `${Math.min(Number(goal.percent ?? 0), 100)}%`,
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
<div className="text-muted-foreground text-sm">
|
|
248
|
+
{Number(goal.progress ?? 0).toLocaleString()} /{' '}
|
|
249
|
+
{Number(goal.target_value ?? 0).toLocaleString()}{' '}
|
|
250
|
+
{goal.metric?.unit_label}
|
|
251
|
+
</div>
|
|
252
|
+
</CardContent>
|
|
253
|
+
</Card>
|
|
254
|
+
))
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function StatsPanel({ stats, t }: { stats: any; t: Translate }) {
|
|
262
|
+
const maxValue = Math.max(
|
|
263
|
+
...(stats?.daily ?? []).map((day: any) => day.value),
|
|
264
|
+
1
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<Card>
|
|
269
|
+
<CardHeader>
|
|
270
|
+
<CardTitle>{t('stats.daily_progress')}</CardTitle>
|
|
271
|
+
</CardHeader>
|
|
272
|
+
<CardContent className="space-y-4">
|
|
273
|
+
{(stats?.daily ?? []).length === 0 ? (
|
|
274
|
+
<p className="text-muted-foreground text-sm">{t('empty.stats')}</p>
|
|
275
|
+
) : (
|
|
276
|
+
<div className="space-y-2">
|
|
277
|
+
{stats.daily.slice(-30).map((day: any) => (
|
|
278
|
+
<div
|
|
279
|
+
className="grid grid-cols-[6.5rem_1fr_5rem] items-center gap-3"
|
|
280
|
+
key={day.date}
|
|
281
|
+
>
|
|
282
|
+
<div className="text-muted-foreground text-xs">{day.date}</div>
|
|
283
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
284
|
+
<div
|
|
285
|
+
className="h-full bg-dynamic-blue"
|
|
286
|
+
style={{
|
|
287
|
+
width: `${(Number(day.value) / maxValue) * 100}%`,
|
|
288
|
+
}}
|
|
289
|
+
/>
|
|
290
|
+
</div>
|
|
291
|
+
<div className="text-right text-sm">
|
|
292
|
+
{Number(day.value).toLocaleString()}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</CardContent>
|
|
299
|
+
</Card>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -23,6 +23,11 @@ import { useOptionalWorkspacePresenceContext } from './workspace-presence-provid
|
|
|
23
23
|
|
|
24
24
|
export type { PendingRelationship, PendingRelationshipType };
|
|
25
25
|
|
|
26
|
+
export type TaskAssigneeMemberSource =
|
|
27
|
+
| 'workspace'
|
|
28
|
+
| 'board'
|
|
29
|
+
| 'workspace-and-board';
|
|
30
|
+
|
|
26
31
|
type WorkspaceLabelSummary = {
|
|
27
32
|
id: string;
|
|
28
33
|
name: string | null;
|
|
@@ -57,6 +62,10 @@ interface TaskDialogState {
|
|
|
57
62
|
taskWsId?: string;
|
|
58
63
|
/** Whether the task's workspace is personal (affects realtime/presence decisions) */
|
|
59
64
|
taskWorkspacePersonal?: boolean;
|
|
65
|
+
/** Whether the board context should expose assignee controls. */
|
|
66
|
+
canUseBoardAssignees?: boolean;
|
|
67
|
+
/** Where assignee candidates should be loaded from. */
|
|
68
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
60
69
|
/** The task workspace tier used to gate cursor tracking for edit mode */
|
|
61
70
|
taskWorkspaceTier?: WorkspaceProductTier;
|
|
62
71
|
/** Initial board/list context used for immediate partial-task rendering. */
|
|
@@ -78,6 +87,8 @@ interface OpenTaskByIdOptions {
|
|
|
78
87
|
taskWsId?: string;
|
|
79
88
|
taskWorkspacePersonal?: boolean;
|
|
80
89
|
taskWorkspaceTier?: WorkspaceProductTier;
|
|
90
|
+
canUseBoardAssignees?: boolean;
|
|
91
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
81
92
|
initialSharedContext?: SharedTaskContext;
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -100,6 +111,10 @@ interface TaskDialogContextValue {
|
|
|
100
111
|
taskWsId?: string;
|
|
101
112
|
/** Whether the task's workspace is personal (affects realtime features) */
|
|
102
113
|
taskWorkspacePersonal?: boolean;
|
|
114
|
+
/** Whether the board context should expose assignee controls */
|
|
115
|
+
canUseBoardAssignees?: boolean;
|
|
116
|
+
/** Where assignee candidates should be loaded from */
|
|
117
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
103
118
|
/** The task's workspace tier (affects cursor tracking) */
|
|
104
119
|
taskWorkspaceTier?: WorkspaceProductTier;
|
|
105
120
|
}
|
|
@@ -383,6 +398,8 @@ export function TaskDialogProvider({
|
|
|
383
398
|
preserveUrl?: boolean;
|
|
384
399
|
taskWsId?: string;
|
|
385
400
|
taskWorkspacePersonal?: boolean;
|
|
401
|
+
canUseBoardAssignees?: boolean;
|
|
402
|
+
assigneeMemberSource?: TaskAssigneeMemberSource;
|
|
386
403
|
taskWorkspaceTier?: WorkspaceProductTier;
|
|
387
404
|
}
|
|
388
405
|
) => {
|
|
@@ -408,6 +425,9 @@ export function TaskDialogProvider({
|
|
|
408
425
|
fakeTaskUrl,
|
|
409
426
|
taskWsId: options?.taskWsId,
|
|
410
427
|
taskWorkspacePersonal: isTaskWorkspacePersonal,
|
|
428
|
+
canUseBoardAssignees:
|
|
429
|
+
options?.canUseBoardAssignees ?? !isTaskWorkspacePersonal,
|
|
430
|
+
assigneeMemberSource: options?.assigneeMemberSource,
|
|
411
431
|
taskWorkspaceTier: options?.taskWorkspaceTier,
|
|
412
432
|
});
|
|
413
433
|
},
|
|
@@ -461,6 +481,9 @@ export function TaskDialogProvider({
|
|
|
461
481
|
fakeTaskUrl: options?.fakeTaskUrl,
|
|
462
482
|
taskWsId: options?.taskWsId,
|
|
463
483
|
taskWorkspacePersonal: initialTaskWorkspacePersonal,
|
|
484
|
+
canUseBoardAssignees:
|
|
485
|
+
options?.canUseBoardAssignees ?? !initialTaskWorkspacePersonal,
|
|
486
|
+
assigneeMemberSource: options?.assigneeMemberSource,
|
|
464
487
|
taskWorkspaceTier: options?.taskWorkspaceTier,
|
|
465
488
|
initialSharedContext: options?.initialSharedContext,
|
|
466
489
|
isHydratingTask: true,
|
|
@@ -540,6 +563,9 @@ export function TaskDialogProvider({
|
|
|
540
563
|
fakeTaskUrl: options?.fakeTaskUrl,
|
|
541
564
|
taskWsId,
|
|
542
565
|
taskWorkspacePersonal: isTaskWorkspacePersonal,
|
|
566
|
+
canUseBoardAssignees:
|
|
567
|
+
options?.canUseBoardAssignees ?? !isTaskWorkspacePersonal,
|
|
568
|
+
assigneeMemberSource: options?.assigneeMemberSource,
|
|
543
569
|
taskWorkspaceTier,
|
|
544
570
|
isHydratingTask: false,
|
|
545
571
|
taskLoadError: false,
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import '@testing-library/jest-dom';
|
|
2
2
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react';
|
|
4
10
|
import type React from 'react';
|
|
11
|
+
import { createRef } from 'react';
|
|
5
12
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import { AssigneeSelect } from '../assignee-select';
|
|
13
|
+
import { AssigneeSelect, type AssigneeSelectHandle } from '../assignee-select';
|
|
14
|
+
|
|
15
|
+
const listWorkspaceTaskBoardViewableMembersMock = vi.fn();
|
|
16
|
+
const updateWorkspaceTaskMock = vi.fn();
|
|
7
17
|
|
|
8
18
|
vi.mock('next-intl', () => ({
|
|
9
19
|
useTranslations: () => (key: string) => key,
|
|
@@ -25,7 +35,11 @@ vi.mock('@tuturuuu/ui/hooks/use-workspace-members', () => ({
|
|
|
25
35
|
}));
|
|
26
36
|
|
|
27
37
|
vi.mock('@tuturuuu/internal-api/tasks', () => ({
|
|
28
|
-
|
|
38
|
+
listWorkspaceTaskBoardViewableMembers: (
|
|
39
|
+
...args: Parameters<typeof listWorkspaceTaskBoardViewableMembersMock>
|
|
40
|
+
) => listWorkspaceTaskBoardViewableMembersMock(...args),
|
|
41
|
+
updateWorkspaceTask: (...args: Parameters<typeof updateWorkspaceTaskMock>) =>
|
|
42
|
+
updateWorkspaceTaskMock(...args),
|
|
29
43
|
}));
|
|
30
44
|
|
|
31
45
|
vi.mock('../board-broadcast-context', () => ({
|
|
@@ -35,9 +49,18 @@ vi.mock('../board-broadcast-context', () => ({
|
|
|
35
49
|
describe('AssigneeSelect', () => {
|
|
36
50
|
beforeEach(() => {
|
|
37
51
|
vi.clearAllMocks();
|
|
52
|
+
listWorkspaceTaskBoardViewableMembersMock.mockResolvedValue({
|
|
53
|
+
members: [],
|
|
54
|
+
});
|
|
55
|
+
updateWorkspaceTaskMock.mockResolvedValue({});
|
|
38
56
|
});
|
|
39
57
|
|
|
40
|
-
|
|
58
|
+
function renderWithQueryClient(
|
|
59
|
+
props: React.ComponentProps<typeof AssigneeSelect> = {
|
|
60
|
+
taskId: 'task-1',
|
|
61
|
+
},
|
|
62
|
+
ref?: React.Ref<AssigneeSelectHandle>
|
|
63
|
+
) {
|
|
41
64
|
const queryClient = new QueryClient({
|
|
42
65
|
defaultOptions: {
|
|
43
66
|
queries: {
|
|
@@ -46,6 +69,14 @@ describe('AssigneeSelect', () => {
|
|
|
46
69
|
},
|
|
47
70
|
});
|
|
48
71
|
|
|
72
|
+
return render(
|
|
73
|
+
<QueryClientProvider client={queryClient}>
|
|
74
|
+
<AssigneeSelect {...props} ref={ref} />
|
|
75
|
+
</QueryClientProvider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('does not enter a render loop when rerendered with equivalent assignees', () => {
|
|
49
80
|
const assignees: React.ComponentProps<typeof AssigneeSelect>['assignees'] =
|
|
50
81
|
[
|
|
51
82
|
{
|
|
@@ -55,18 +86,58 @@ describe('AssigneeSelect', () => {
|
|
|
55
86
|
},
|
|
56
87
|
];
|
|
57
88
|
|
|
58
|
-
const { rerender } =
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
89
|
+
const { rerender } = renderWithQueryClient({
|
|
90
|
+
assignees,
|
|
91
|
+
taskId: 'task-1',
|
|
92
|
+
});
|
|
63
93
|
|
|
64
94
|
expect(() =>
|
|
65
95
|
rerender(
|
|
66
|
-
<QueryClientProvider client={
|
|
96
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
67
97
|
<AssigneeSelect taskId="task-1" assignees={[{ ...assignees[0]! }]} />
|
|
68
98
|
</QueryClientProvider>
|
|
69
99
|
)
|
|
70
100
|
).not.toThrow();
|
|
71
101
|
});
|
|
102
|
+
|
|
103
|
+
it('uses board viewable members so direct guests can be assigned', async () => {
|
|
104
|
+
listWorkspaceTaskBoardViewableMembersMock.mockResolvedValue({
|
|
105
|
+
members: [
|
|
106
|
+
{
|
|
107
|
+
avatar_url: null,
|
|
108
|
+
display_name: 'Board Guest',
|
|
109
|
+
email: 'guest@example.com',
|
|
110
|
+
handle: null,
|
|
111
|
+
id: 'guest-user-1',
|
|
112
|
+
is_creator: false,
|
|
113
|
+
roles: [],
|
|
114
|
+
user_id: 'guest-user-1',
|
|
115
|
+
workspace_member_type: 'GUEST',
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const assigneeSelectRef = createRef<AssigneeSelectHandle>();
|
|
121
|
+
renderWithQueryClient({ taskId: 'task-1' }, assigneeSelectRef);
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(listWorkspaceTaskBoardViewableMembersMock).toHaveBeenCalledWith(
|
|
125
|
+
'ws-1',
|
|
126
|
+
'board-1'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
act(() => {
|
|
130
|
+
assigneeSelectRef.current?.open();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(await screen.findByText('Board Guest')).toBeInTheDocument();
|
|
134
|
+
|
|
135
|
+
fireEvent.click(screen.getByText('Board Guest'));
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(updateWorkspaceTaskMock).toHaveBeenCalledWith('ws-1', 'task-1', {
|
|
139
|
+
assignee_ids: ['guest-user-1'],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
72
143
|
});
|
|
@@ -12,6 +12,7 @@ const useWorkspaceLabelsMock = vi.fn();
|
|
|
12
12
|
const getWorkspaceTaskBoardMock = vi.fn();
|
|
13
13
|
const listWorkspaceTasksMock = vi.fn();
|
|
14
14
|
const useProgressiveBoardLoaderMock = vi.fn();
|
|
15
|
+
const useBoardRealtimeMock = vi.fn();
|
|
15
16
|
const revalidateLoadedListsMock = vi.fn();
|
|
16
17
|
|
|
17
18
|
vi.mock('@tuturuuu/internal-api/tasks', () => ({
|
|
@@ -25,7 +26,10 @@ vi.mock('@tuturuuu/utils/task-helper', () => ({
|
|
|
25
26
|
}));
|
|
26
27
|
|
|
27
28
|
vi.mock('@tuturuuu/ui/hooks/useBoardRealtime', () => ({
|
|
28
|
-
useBoardRealtime: () =>
|
|
29
|
+
useBoardRealtime: (...args: unknown[]) => {
|
|
30
|
+
useBoardRealtimeMock(...args);
|
|
31
|
+
return { broadcast: null };
|
|
32
|
+
},
|
|
29
33
|
}));
|
|
30
34
|
|
|
31
35
|
vi.mock('next/navigation', () => ({
|
|
@@ -78,6 +82,7 @@ describe('BoardClient', () => {
|
|
|
78
82
|
],
|
|
79
83
|
},
|
|
80
84
|
});
|
|
85
|
+
useBoardRealtimeMock.mockReset();
|
|
81
86
|
useProgressiveBoardLoaderMock.mockReset();
|
|
82
87
|
revalidateLoadedListsMock.mockReset();
|
|
83
88
|
revalidateLoadedListsMock.mockResolvedValue(undefined);
|
|
@@ -115,6 +120,42 @@ describe('BoardClient', () => {
|
|
|
115
120
|
);
|
|
116
121
|
});
|
|
117
122
|
|
|
123
|
+
it('refreshes board task cache without relationship summaries', async () => {
|
|
124
|
+
const queryClient = new QueryClient({
|
|
125
|
+
defaultOptions: {
|
|
126
|
+
queries: {
|
|
127
|
+
retry: false,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
render(
|
|
133
|
+
<QueryClientProvider client={queryClient}>
|
|
134
|
+
<BoardClient
|
|
135
|
+
boardId="board-1"
|
|
136
|
+
workspace={{ id: 'workspace-uuid', personal: false } as any}
|
|
137
|
+
currentUserId="user-1"
|
|
138
|
+
/>
|
|
139
|
+
</QueryClientProvider>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(await screen.findByTestId('board-views')).toBeInTheDocument();
|
|
143
|
+
await waitFor(() => {
|
|
144
|
+
expect(getActiveBoardRefresh()).toBeInstanceOf(Function);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
getActiveBoardRefresh()?.();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(listWorkspaceTasksMock).toHaveBeenCalledWith('board-ws-uuid', {
|
|
153
|
+
boardId: 'board-1',
|
|
154
|
+
includeRelationshipSummary: false,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
118
159
|
it('uses the shared task board loading state while the board query resolves', () => {
|
|
119
160
|
getWorkspaceTaskBoardMock.mockReturnValue(new Promise(() => {}));
|
|
120
161
|
const queryClient = new QueryClient({
|
|
@@ -136,10 +177,41 @@ describe('BoardClient', () => {
|
|
|
136
177
|
);
|
|
137
178
|
|
|
138
179
|
expect(screen.getByTestId('task-board-loading-state')).toBeInTheDocument();
|
|
180
|
+
expect(screen.getByTestId('task-board-loading-state')).not.toHaveClass(
|
|
181
|
+
'-m-4'
|
|
182
|
+
);
|
|
139
183
|
expect(screen.getByTestId('kanban-skeleton')).toBeInTheDocument();
|
|
140
184
|
expect(screen.queryByText('Loading board...')).not.toBeInTheDocument();
|
|
141
185
|
});
|
|
142
186
|
|
|
187
|
+
it('can render the shared task board loading state as a full-bleed route root', () => {
|
|
188
|
+
getWorkspaceTaskBoardMock.mockReturnValue(new Promise(() => {}));
|
|
189
|
+
const queryClient = new QueryClient({
|
|
190
|
+
defaultOptions: {
|
|
191
|
+
queries: {
|
|
192
|
+
retry: false,
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
render(
|
|
198
|
+
<QueryClientProvider client={queryClient}>
|
|
199
|
+
<BoardClient
|
|
200
|
+
boardId="board-1"
|
|
201
|
+
workspace={{ id: 'workspace-uuid', personal: false } as any}
|
|
202
|
+
currentUserId="user-1"
|
|
203
|
+
rootLoading
|
|
204
|
+
/>
|
|
205
|
+
</QueryClientProvider>
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(screen.getByTestId('task-board-loading-state')).toHaveClass(
|
|
209
|
+
'-m-4',
|
|
210
|
+
'h-[calc(100dvh+2rem)]',
|
|
211
|
+
'w-[calc(100%+2rem)]'
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
143
215
|
it('can revalidate loaded board lists without invalidating visible task caches', async () => {
|
|
144
216
|
const queryClient = new QueryClient({
|
|
145
217
|
defaultOptions: {
|
|
@@ -179,6 +251,49 @@ describe('BoardClient', () => {
|
|
|
179
251
|
expect(revalidateLoadedListsMock).toHaveBeenCalledTimes(1);
|
|
180
252
|
});
|
|
181
253
|
|
|
254
|
+
it('revalidates loaded lists for relation broadcasts without invalidating visible task caches', async () => {
|
|
255
|
+
const queryClient = new QueryClient({
|
|
256
|
+
defaultOptions: {
|
|
257
|
+
queries: {
|
|
258
|
+
retry: false,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
|
|
263
|
+
|
|
264
|
+
render(
|
|
265
|
+
<QueryClientProvider client={queryClient}>
|
|
266
|
+
<BoardClient
|
|
267
|
+
boardId="board-1"
|
|
268
|
+
workspace={{ id: 'workspace-uuid', personal: false } as any}
|
|
269
|
+
currentUserId="user-1"
|
|
270
|
+
/>
|
|
271
|
+
</QueryClientProvider>
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(await screen.findByTestId('board-views')).toBeInTheDocument();
|
|
275
|
+
|
|
276
|
+
const realtimeOptions = useBoardRealtimeMock.mock.calls.find(
|
|
277
|
+
([boardId]) => boardId === 'board-1'
|
|
278
|
+
)?.[1] as
|
|
279
|
+
| {
|
|
280
|
+
onTaskRelationsChange?: (taskIds: string[]) => void;
|
|
281
|
+
}
|
|
282
|
+
| undefined;
|
|
283
|
+
|
|
284
|
+
await act(async () => {
|
|
285
|
+
realtimeOptions?.onTaskRelationsChange?.(['task-1']);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
289
|
+
queryKey: ['tasks', 'board-1'],
|
|
290
|
+
});
|
|
291
|
+
expect(invalidateSpy).not.toHaveBeenCalledWith({
|
|
292
|
+
queryKey: ['tasks-full', 'board-1'],
|
|
293
|
+
});
|
|
294
|
+
expect(revalidateLoadedListsMock).toHaveBeenCalledTimes(1);
|
|
295
|
+
});
|
|
296
|
+
|
|
182
297
|
it('throttles focus-driven list revalidation for thirty seconds', async () => {
|
|
183
298
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(100_000);
|
|
184
299
|
const queryClient = new QueryClient({
|