@tuturuuu/ui 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +4 -4
- package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
- package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
- package/src/components/ui/finance/invoices/hooks.ts +11 -2
- package/src/components/ui/finance/invoices/internal-api.ts +5 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
- package/src/components/ui/finance/invoices/utils.test.ts +82 -0
- package/src/components/ui/finance/invoices/utils.ts +240 -7
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
- package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
- package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
- package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
- package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
- package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
- package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
WorkspaceTaskBoardListItem,
|
|
5
|
+
WorkspaceTaskListSummary,
|
|
6
|
+
} from '@tuturuuu/internal-api/tasks';
|
|
7
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
8
|
+
import {
|
|
9
|
+
Dialog,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogHeader,
|
|
14
|
+
DialogTitle,
|
|
15
|
+
} from '@tuturuuu/ui/dialog';
|
|
16
|
+
import { Input } from '@tuturuuu/ui/input';
|
|
17
|
+
import { Label } from '@tuturuuu/ui/label';
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from '@tuturuuu/ui/select';
|
|
25
|
+
import { Textarea } from '@tuturuuu/ui/textarea';
|
|
26
|
+
import { useTranslations } from 'next-intl';
|
|
27
|
+
import { useEffect, useId, useState } from 'react';
|
|
28
|
+
import type {
|
|
29
|
+
WorkspaceTaskTemplate,
|
|
30
|
+
WorkspaceTaskTemplatePayload,
|
|
31
|
+
} from './task-template-api';
|
|
32
|
+
|
|
33
|
+
type Visibility = 'private' | 'workspace';
|
|
34
|
+
|
|
35
|
+
interface CreateTaskTemplateDialogProps {
|
|
36
|
+
onCreate: (payload: WorkspaceTaskTemplatePayload) => void;
|
|
37
|
+
onOpenChange: (open: boolean) => void;
|
|
38
|
+
open: boolean;
|
|
39
|
+
pending: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function CreateTaskTemplateDialog({
|
|
43
|
+
onCreate,
|
|
44
|
+
onOpenChange,
|
|
45
|
+
open,
|
|
46
|
+
pending,
|
|
47
|
+
}: CreateTaskTemplateDialogProps) {
|
|
48
|
+
const t = useTranslations('ws-task-templates');
|
|
49
|
+
const nameId = useId();
|
|
50
|
+
const keyId = useId();
|
|
51
|
+
const taskNameId = useId();
|
|
52
|
+
const descriptionId = useId();
|
|
53
|
+
const [name, setName] = useState('');
|
|
54
|
+
const [key, setKey] = useState('');
|
|
55
|
+
const [taskName, setTaskName] = useState('');
|
|
56
|
+
const [description, setDescription] = useState('');
|
|
57
|
+
const [priority, setPriority] = useState<string>('none');
|
|
58
|
+
const [visibility, setVisibility] = useState<Visibility>('private');
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!open) return;
|
|
62
|
+
setName('');
|
|
63
|
+
setKey('');
|
|
64
|
+
setTaskName('');
|
|
65
|
+
setDescription('');
|
|
66
|
+
setPriority('none');
|
|
67
|
+
setVisibility('private');
|
|
68
|
+
}, [open]);
|
|
69
|
+
|
|
70
|
+
const canSubmit = name.trim().length > 0 || taskName.trim().length > 0;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
74
|
+
<DialogContent>
|
|
75
|
+
<DialogHeader>
|
|
76
|
+
<DialogTitle>{t('create.title')}</DialogTitle>
|
|
77
|
+
<DialogDescription>{t('create.description')}</DialogDescription>
|
|
78
|
+
</DialogHeader>
|
|
79
|
+
<div className="space-y-4">
|
|
80
|
+
<div className="space-y-2">
|
|
81
|
+
<Label htmlFor={nameId}>{t('fields.template_name')}</Label>
|
|
82
|
+
<Input
|
|
83
|
+
id={nameId}
|
|
84
|
+
value={name}
|
|
85
|
+
onChange={(event) => setName(event.target.value)}
|
|
86
|
+
placeholder={t('fields.template_name_placeholder')}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
<Label htmlFor={keyId}>{t('fields.key')}</Label>
|
|
91
|
+
<Input
|
|
92
|
+
id={keyId}
|
|
93
|
+
value={key}
|
|
94
|
+
onChange={(event) => setKey(event.target.value)}
|
|
95
|
+
placeholder={t('fields.key_placeholder')}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
<Label htmlFor={taskNameId}>{t('fields.task_name')}</Label>
|
|
100
|
+
<Input
|
|
101
|
+
id={taskNameId}
|
|
102
|
+
value={taskName}
|
|
103
|
+
onChange={(event) => setTaskName(event.target.value)}
|
|
104
|
+
placeholder={t('fields.task_name_placeholder')}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
<div className="space-y-2">
|
|
108
|
+
<Label htmlFor={descriptionId}>{t('fields.description')}</Label>
|
|
109
|
+
<Textarea
|
|
110
|
+
id={descriptionId}
|
|
111
|
+
value={description}
|
|
112
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
113
|
+
placeholder={t('fields.description_placeholder')}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
117
|
+
<Select value={priority} onValueChange={setPriority}>
|
|
118
|
+
<SelectTrigger>
|
|
119
|
+
<SelectValue placeholder={t('fields.priority')} />
|
|
120
|
+
</SelectTrigger>
|
|
121
|
+
<SelectContent>
|
|
122
|
+
<SelectItem value="none">{t('priority.none')}</SelectItem>
|
|
123
|
+
<SelectItem value="low">{t('priority.low')}</SelectItem>
|
|
124
|
+
<SelectItem value="normal">{t('priority.normal')}</SelectItem>
|
|
125
|
+
<SelectItem value="high">{t('priority.high')}</SelectItem>
|
|
126
|
+
<SelectItem value="critical">
|
|
127
|
+
{t('priority.critical')}
|
|
128
|
+
</SelectItem>
|
|
129
|
+
</SelectContent>
|
|
130
|
+
</Select>
|
|
131
|
+
<Select
|
|
132
|
+
value={visibility}
|
|
133
|
+
onValueChange={(value) => setVisibility(value as Visibility)}
|
|
134
|
+
>
|
|
135
|
+
<SelectTrigger>
|
|
136
|
+
<SelectValue placeholder={t('fields.visibility')} />
|
|
137
|
+
</SelectTrigger>
|
|
138
|
+
<SelectContent>
|
|
139
|
+
<SelectItem value="private">
|
|
140
|
+
{t('visibility.private')}
|
|
141
|
+
</SelectItem>
|
|
142
|
+
<SelectItem value="workspace">
|
|
143
|
+
{t('visibility.workspace')}
|
|
144
|
+
</SelectItem>
|
|
145
|
+
</SelectContent>
|
|
146
|
+
</Select>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<DialogFooter>
|
|
150
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
151
|
+
{t('actions.cancel')}
|
|
152
|
+
</Button>
|
|
153
|
+
<Button
|
|
154
|
+
disabled={!canSubmit || pending}
|
|
155
|
+
onClick={() =>
|
|
156
|
+
onCreate({
|
|
157
|
+
description: description.trim() || null,
|
|
158
|
+
key: key.trim() || undefined,
|
|
159
|
+
name: name.trim() || taskName.trim(),
|
|
160
|
+
priority: priority === 'none' ? null : (priority as never),
|
|
161
|
+
task_name: taskName.trim() || name.trim(),
|
|
162
|
+
visibility,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
>
|
|
166
|
+
{t('actions.create')}
|
|
167
|
+
</Button>
|
|
168
|
+
</DialogFooter>
|
|
169
|
+
</DialogContent>
|
|
170
|
+
</Dialog>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
interface UseTaskTemplateDialogProps {
|
|
175
|
+
boards: WorkspaceTaskBoardListItem[];
|
|
176
|
+
lists: WorkspaceTaskListSummary[];
|
|
177
|
+
loadingLists: boolean;
|
|
178
|
+
onBoardChange: (boardId: string) => void;
|
|
179
|
+
onListChange: (listId: string) => void;
|
|
180
|
+
onOpenChange: (open: boolean) => void;
|
|
181
|
+
onUse: (payload: { listId: string; name?: string }) => void;
|
|
182
|
+
open: boolean;
|
|
183
|
+
pending: boolean;
|
|
184
|
+
selectedBoardId: string;
|
|
185
|
+
selectedListId: string;
|
|
186
|
+
template: WorkspaceTaskTemplate | null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function UseTaskTemplateDialog({
|
|
190
|
+
boards,
|
|
191
|
+
lists,
|
|
192
|
+
loadingLists,
|
|
193
|
+
onBoardChange,
|
|
194
|
+
onListChange,
|
|
195
|
+
onOpenChange,
|
|
196
|
+
onUse,
|
|
197
|
+
open,
|
|
198
|
+
pending,
|
|
199
|
+
selectedBoardId,
|
|
200
|
+
selectedListId,
|
|
201
|
+
template,
|
|
202
|
+
}: UseTaskTemplateDialogProps) {
|
|
203
|
+
const t = useTranslations('ws-task-templates');
|
|
204
|
+
const overrideNameId = useId();
|
|
205
|
+
const [name, setName] = useState('');
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (open) setName('');
|
|
209
|
+
}, [open]);
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
213
|
+
<DialogContent>
|
|
214
|
+
<DialogHeader>
|
|
215
|
+
<DialogTitle>{t('use.title')}</DialogTitle>
|
|
216
|
+
<DialogDescription>
|
|
217
|
+
{template
|
|
218
|
+
? t('use.description', { name: template.name })
|
|
219
|
+
: t('use.description_empty')}
|
|
220
|
+
</DialogDescription>
|
|
221
|
+
</DialogHeader>
|
|
222
|
+
<div className="space-y-4">
|
|
223
|
+
<Select value={selectedBoardId} onValueChange={onBoardChange}>
|
|
224
|
+
<SelectTrigger>
|
|
225
|
+
<SelectValue placeholder={t('fields.board')} />
|
|
226
|
+
</SelectTrigger>
|
|
227
|
+
<SelectContent>
|
|
228
|
+
{boards.map((board) => (
|
|
229
|
+
<SelectItem key={board.id} value={board.id}>
|
|
230
|
+
{board.name || t('fields.unnamed_board')}
|
|
231
|
+
</SelectItem>
|
|
232
|
+
))}
|
|
233
|
+
</SelectContent>
|
|
234
|
+
</Select>
|
|
235
|
+
<Select
|
|
236
|
+
disabled={!selectedBoardId || loadingLists}
|
|
237
|
+
onValueChange={onListChange}
|
|
238
|
+
value={selectedListId}
|
|
239
|
+
>
|
|
240
|
+
<SelectTrigger>
|
|
241
|
+
<SelectValue placeholder={t('fields.list')} />
|
|
242
|
+
</SelectTrigger>
|
|
243
|
+
<SelectContent>
|
|
244
|
+
{lists.map((list) => (
|
|
245
|
+
<SelectItem key={list.id} value={list.id}>
|
|
246
|
+
{list.name || t('fields.unnamed_list')}
|
|
247
|
+
</SelectItem>
|
|
248
|
+
))}
|
|
249
|
+
</SelectContent>
|
|
250
|
+
</Select>
|
|
251
|
+
<div className="space-y-2">
|
|
252
|
+
<Label htmlFor={overrideNameId}>{t('fields.override_name')}</Label>
|
|
253
|
+
<Input
|
|
254
|
+
id={overrideNameId}
|
|
255
|
+
value={name}
|
|
256
|
+
onChange={(event) => setName(event.target.value)}
|
|
257
|
+
placeholder={template?.task_name ?? t('fields.task_name')}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
<DialogFooter>
|
|
262
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
263
|
+
{t('actions.cancel')}
|
|
264
|
+
</Button>
|
|
265
|
+
<Button
|
|
266
|
+
disabled={!template || !selectedListId || pending}
|
|
267
|
+
onClick={() =>
|
|
268
|
+
onUse({
|
|
269
|
+
listId: selectedListId,
|
|
270
|
+
name: name.trim() || undefined,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
>
|
|
274
|
+
{t('actions.create_task')}
|
|
275
|
+
</Button>
|
|
276
|
+
</DialogFooter>
|
|
277
|
+
</DialogContent>
|
|
278
|
+
</Dialog>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface SaveTaskTemplateFromTaskDialogProps {
|
|
283
|
+
onOpenChange: (open: boolean) => void;
|
|
284
|
+
onSave: (payload: {
|
|
285
|
+
name?: string;
|
|
286
|
+
taskId: string;
|
|
287
|
+
visibility: Visibility;
|
|
288
|
+
}) => void;
|
|
289
|
+
open: boolean;
|
|
290
|
+
pending: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function SaveTaskTemplateFromTaskDialog({
|
|
294
|
+
onOpenChange,
|
|
295
|
+
onSave,
|
|
296
|
+
open,
|
|
297
|
+
pending,
|
|
298
|
+
}: SaveTaskTemplateFromTaskDialogProps) {
|
|
299
|
+
const t = useTranslations('ws-task-templates');
|
|
300
|
+
const taskIdInput = useId();
|
|
301
|
+
const nameInput = useId();
|
|
302
|
+
const [taskId, setTaskId] = useState('');
|
|
303
|
+
const [name, setName] = useState('');
|
|
304
|
+
const [visibility, setVisibility] = useState<Visibility>('private');
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (!open) return;
|
|
308
|
+
setTaskId('');
|
|
309
|
+
setName('');
|
|
310
|
+
setVisibility('private');
|
|
311
|
+
}, [open]);
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
315
|
+
<DialogContent>
|
|
316
|
+
<DialogHeader>
|
|
317
|
+
<DialogTitle>{t('save_from_task.title')}</DialogTitle>
|
|
318
|
+
<DialogDescription>
|
|
319
|
+
{t('save_from_task.description')}
|
|
320
|
+
</DialogDescription>
|
|
321
|
+
</DialogHeader>
|
|
322
|
+
<div className="space-y-4">
|
|
323
|
+
<div className="space-y-2">
|
|
324
|
+
<Label htmlFor={taskIdInput}>{t('fields.task_id')}</Label>
|
|
325
|
+
<Input
|
|
326
|
+
id={taskIdInput}
|
|
327
|
+
value={taskId}
|
|
328
|
+
onChange={(event) => setTaskId(event.target.value)}
|
|
329
|
+
placeholder={t('fields.task_id_placeholder')}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="space-y-2">
|
|
333
|
+
<Label htmlFor={nameInput}>{t('fields.template_name')}</Label>
|
|
334
|
+
<Input
|
|
335
|
+
id={nameInput}
|
|
336
|
+
value={name}
|
|
337
|
+
onChange={(event) => setName(event.target.value)}
|
|
338
|
+
placeholder={t('fields.template_name_placeholder')}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
<Select
|
|
342
|
+
value={visibility}
|
|
343
|
+
onValueChange={(value) => setVisibility(value as Visibility)}
|
|
344
|
+
>
|
|
345
|
+
<SelectTrigger>
|
|
346
|
+
<SelectValue placeholder={t('fields.visibility')} />
|
|
347
|
+
</SelectTrigger>
|
|
348
|
+
<SelectContent>
|
|
349
|
+
<SelectItem value="private">{t('visibility.private')}</SelectItem>
|
|
350
|
+
<SelectItem value="workspace">
|
|
351
|
+
{t('visibility.workspace')}
|
|
352
|
+
</SelectItem>
|
|
353
|
+
</SelectContent>
|
|
354
|
+
</Select>
|
|
355
|
+
</div>
|
|
356
|
+
<DialogFooter>
|
|
357
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
358
|
+
{t('actions.cancel')}
|
|
359
|
+
</Button>
|
|
360
|
+
<Button
|
|
361
|
+
disabled={!taskId.trim() || pending}
|
|
362
|
+
onClick={() =>
|
|
363
|
+
onSave({
|
|
364
|
+
name: name.trim() || undefined,
|
|
365
|
+
taskId: taskId.trim(),
|
|
366
|
+
visibility,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
>
|
|
370
|
+
{t('actions.save')}
|
|
371
|
+
</Button>
|
|
372
|
+
</DialogFooter>
|
|
373
|
+
</DialogContent>
|
|
374
|
+
</Dialog>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { TaskTemplatesHub } from './task-templates-hub';
|
|
5
|
+
|
|
6
|
+
vi.mock('next-intl', () => ({
|
|
7
|
+
useTranslations: () => (key: string) => key,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('./task-template-client', () => ({
|
|
11
|
+
TaskTemplateClient: ({
|
|
12
|
+
initialTemplates,
|
|
13
|
+
wsId,
|
|
14
|
+
}: {
|
|
15
|
+
initialTemplates: Array<{ name: string }>;
|
|
16
|
+
wsId: string;
|
|
17
|
+
}) => (
|
|
18
|
+
<div data-testid="task-template-tab">
|
|
19
|
+
task templates for {wsId}:{' '}
|
|
20
|
+
{initialTemplates.map((item) => item.name).join(', ')}
|
|
21
|
+
</div>
|
|
22
|
+
),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('./client', () => ({
|
|
26
|
+
default: ({
|
|
27
|
+
initialTemplates,
|
|
28
|
+
templatesBasePath,
|
|
29
|
+
wsId,
|
|
30
|
+
}: {
|
|
31
|
+
initialTemplates: Array<{ name: string }>;
|
|
32
|
+
templatesBasePath: string;
|
|
33
|
+
wsId: string;
|
|
34
|
+
}) => (
|
|
35
|
+
<div data-testid="board-template-tab">
|
|
36
|
+
board templates for {wsId}/{templatesBasePath}:{' '}
|
|
37
|
+
{initialTemplates.map((item) => item.name).join(', ')}
|
|
38
|
+
</div>
|
|
39
|
+
),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
describe('TaskTemplatesHub', () => {
|
|
43
|
+
it('defaults to task templates and keeps board templates available', () => {
|
|
44
|
+
render(
|
|
45
|
+
<TaskTemplatesHub
|
|
46
|
+
boardTemplates={[
|
|
47
|
+
{
|
|
48
|
+
createdAt: '2026-06-29T00:00:00.000Z',
|
|
49
|
+
createdBy: 'user-1',
|
|
50
|
+
description: null,
|
|
51
|
+
id: 'board-template-1',
|
|
52
|
+
isOwner: true,
|
|
53
|
+
name: 'Sprint board',
|
|
54
|
+
sourceBoardId: 'board-1',
|
|
55
|
+
stats: { labels: 0, lists: 1, tasks: 0 },
|
|
56
|
+
updatedAt: '2026-06-29T00:00:00.000Z',
|
|
57
|
+
visibility: 'workspace',
|
|
58
|
+
wsId: 'ws-1',
|
|
59
|
+
},
|
|
60
|
+
]}
|
|
61
|
+
taskTemplates={[
|
|
62
|
+
{
|
|
63
|
+
archived_at: null,
|
|
64
|
+
assignee_ids: [],
|
|
65
|
+
created_at: '2026-06-29T00:00:00.000Z',
|
|
66
|
+
created_by: 'user-1',
|
|
67
|
+
default_board_id: null,
|
|
68
|
+
default_list_id: null,
|
|
69
|
+
description: null,
|
|
70
|
+
description_yjs_state: null,
|
|
71
|
+
end_date: null,
|
|
72
|
+
estimation_points: null,
|
|
73
|
+
id: 'task-template-1',
|
|
74
|
+
isOwner: true,
|
|
75
|
+
label_ids: [],
|
|
76
|
+
name: 'Bug report',
|
|
77
|
+
priority: null,
|
|
78
|
+
project_ids: [],
|
|
79
|
+
slug: 'bug-report',
|
|
80
|
+
source_task_id: null,
|
|
81
|
+
start_date: null,
|
|
82
|
+
task_name: 'Investigate bug',
|
|
83
|
+
updated_at: '2026-06-29T00:00:00.000Z',
|
|
84
|
+
visibility: 'private',
|
|
85
|
+
ws_id: 'ws-1',
|
|
86
|
+
},
|
|
87
|
+
]}
|
|
88
|
+
templatesBasePath="tasks/templates"
|
|
89
|
+
wsId="ws-1"
|
|
90
|
+
/>
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(screen.getByRole('tab', { name: /tabs.tasks/i })).toHaveAttribute(
|
|
94
|
+
'data-state',
|
|
95
|
+
'active'
|
|
96
|
+
);
|
|
97
|
+
expect(screen.getByTestId('task-template-tab')).toHaveTextContent(
|
|
98
|
+
'Bug report'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const boardTab = screen.getByRole('tab', { name: /tabs.boards/i });
|
|
102
|
+
fireEvent.pointerDown(boardTab);
|
|
103
|
+
fireEvent.mouseDown(boardTab);
|
|
104
|
+
fireEvent.click(boardTab);
|
|
105
|
+
|
|
106
|
+
expect(boardTab).toHaveAttribute('data-state', 'active');
|
|
107
|
+
expect(screen.getByTestId('board-template-tab')).toHaveTextContent(
|
|
108
|
+
'Sprint board'
|
|
109
|
+
);
|
|
110
|
+
expect(screen.getByTestId('board-template-tab')).toHaveTextContent(
|
|
111
|
+
'tasks/templates'
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { KanbanSquare, ListTodo } from '@tuturuuu/icons';
|
|
4
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
import TemplatesClient from './client';
|
|
7
|
+
import type { WorkspaceTaskTemplate } from './task-template-api';
|
|
8
|
+
import { TaskTemplateClient } from './task-template-client';
|
|
9
|
+
import type { BoardTemplate } from './types';
|
|
10
|
+
|
|
11
|
+
interface TaskTemplatesHubProps {
|
|
12
|
+
boardTemplates: BoardTemplate[];
|
|
13
|
+
taskTemplates?: WorkspaceTaskTemplate[];
|
|
14
|
+
templatesBasePath?: string;
|
|
15
|
+
wsId: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TaskTemplatesHub({
|
|
19
|
+
boardTemplates,
|
|
20
|
+
taskTemplates = [],
|
|
21
|
+
templatesBasePath = 'templates',
|
|
22
|
+
wsId,
|
|
23
|
+
}: TaskTemplatesHubProps) {
|
|
24
|
+
const t = useTranslations('ws-task-templates');
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Tabs defaultValue="tasks" className="space-y-4">
|
|
28
|
+
<TabsList>
|
|
29
|
+
<TabsTrigger value="tasks">
|
|
30
|
+
<ListTodo className="h-4 w-4" />
|
|
31
|
+
{t('tabs.tasks')}
|
|
32
|
+
</TabsTrigger>
|
|
33
|
+
<TabsTrigger value="boards">
|
|
34
|
+
<KanbanSquare className="h-4 w-4" />
|
|
35
|
+
{t('tabs.boards')}
|
|
36
|
+
</TabsTrigger>
|
|
37
|
+
</TabsList>
|
|
38
|
+
<TabsContent value="tasks">
|
|
39
|
+
<TaskTemplateClient initialTemplates={taskTemplates} wsId={wsId} />
|
|
40
|
+
</TabsContent>
|
|
41
|
+
<TabsContent value="boards">
|
|
42
|
+
<TemplatesClient
|
|
43
|
+
initialTemplates={boardTemplates}
|
|
44
|
+
templatesBasePath={templatesBasePath}
|
|
45
|
+
wsId={wsId}
|
|
46
|
+
/>
|
|
47
|
+
</TabsContent>
|
|
48
|
+
</Tabs>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -2,10 +2,10 @@ import { Store } from '@tuturuuu/icons';
|
|
|
2
2
|
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
3
3
|
import { Button } from '@tuturuuu/ui/button';
|
|
4
4
|
import FeatureSummary from '@tuturuuu/ui/custom/feature-summary';
|
|
5
|
-
import
|
|
5
|
+
import { TaskTemplatesHub } from '@tuturuuu/ui/tu-do/templates/task-templates-hub';
|
|
6
6
|
import type { BoardTemplate } from '@tuturuuu/ui/tu-do/templates/types';
|
|
7
7
|
import { getCurrentUser } from '@tuturuuu/utils/user-helper';
|
|
8
|
-
import {
|
|
8
|
+
import { getWorkspace } from '@tuturuuu/utils/workspace-helper';
|
|
9
9
|
import Link from 'next/link';
|
|
10
10
|
import { notFound, redirect } from 'next/navigation';
|
|
11
11
|
import { getTranslations } from 'next-intl/server';
|
|
@@ -121,13 +121,8 @@ export default async function TaskTemplatesPage({
|
|
|
121
121
|
|
|
122
122
|
const wsId = workspace.id;
|
|
123
123
|
|
|
124
|
-
const permissions = await getPermissions({ wsId });
|
|
125
|
-
if (!permissions) notFound();
|
|
126
|
-
const { withoutPermission } = permissions;
|
|
127
|
-
if (withoutPermission('manage_projects')) redirect(`/${wsId}`);
|
|
128
|
-
|
|
129
124
|
const { templates } = await getTemplates(wsId, user.id);
|
|
130
|
-
const t = await getTranslations('ws-
|
|
125
|
+
const t = await getTranslations('ws-task-templates');
|
|
131
126
|
|
|
132
127
|
const marketplaceUrl = `/${wsId}/${templatesBasePath}/marketplace`;
|
|
133
128
|
|
|
@@ -144,14 +139,14 @@ export default async function TaskTemplatesPage({
|
|
|
144
139
|
<Link href={marketplaceUrl}>
|
|
145
140
|
<Button variant="outline" size="sm" className="gap-2">
|
|
146
141
|
<Store className="h-4 w-4" />
|
|
147
|
-
{t('gallery.
|
|
142
|
+
{t('gallery.board_marketplace')}
|
|
148
143
|
</Button>
|
|
149
144
|
</Link>
|
|
150
145
|
}
|
|
151
146
|
/>
|
|
152
|
-
<
|
|
147
|
+
<TaskTemplatesHub
|
|
148
|
+
boardTemplates={templates}
|
|
153
149
|
wsId={wsId}
|
|
154
|
-
initialTemplates={templates}
|
|
155
150
|
templatesBasePath={templatesBasePath}
|
|
156
151
|
/>
|
|
157
152
|
</div>
|