@tuturuuu/ui 0.10.0 → 0.11.1

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 (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +47 -47
  3. package/src/components/ui/finance/invoices/components/subscription-attendance-summary.tsx +19 -5
  4. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.test.tsx +41 -0
  5. package/src/components/ui/finance/invoices/components/subscription-prepaid-controls.tsx +93 -0
  6. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +82 -70
  7. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +50 -25
  8. package/src/components/ui/finance/invoices/hooks.ts +11 -2
  9. package/src/components/ui/finance/invoices/internal-api.ts +5 -0
  10. package/src/components/ui/finance/invoices/subscription-invoice.tsx +92 -31
  11. package/src/components/ui/finance/invoices/utils.test.ts +82 -0
  12. package/src/components/ui/finance/invoices/utils.ts +240 -7
  13. package/src/components/ui/text-editor/__tests__/extensions.test.ts +22 -0
  14. package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  15. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  16. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  17. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-list-selector.tsx +18 -2
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.test.tsx +157 -4
  29. package/src/components/ui/tu-do/shared/task-edit-dialog/task-properties-section.tsx +178 -14
  30. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  31. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  32. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  33. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  34. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  35. package/src/components/ui/tu-do/templates/task-template-client.test.tsx +52 -0
  36. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  37. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  38. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  39. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  40. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  41. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
@@ -0,0 +1,167 @@
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 type { WorkspaceTaskTemplate } from './task-template-api';
5
+ import {
6
+ CreateTaskTemplateDialog,
7
+ SaveTaskTemplateFromTaskDialog,
8
+ UseTaskTemplateDialog,
9
+ } from './task-template-dialogs';
10
+
11
+ vi.mock('next-intl', () => ({
12
+ useTranslations: () => (key: string, values?: Record<string, string>) =>
13
+ values?.name ? `${key}:${values.name}` : key,
14
+ }));
15
+
16
+ const template: WorkspaceTaskTemplate = {
17
+ archived_at: null,
18
+ assignee_ids: [],
19
+ created_at: '2026-06-29T00:00:00.000Z',
20
+ created_by: 'user-1',
21
+ default_board_id: 'board-1',
22
+ default_list_id: 'list-1',
23
+ description: 'Template body',
24
+ description_yjs_state: null,
25
+ end_date: null,
26
+ estimation_points: null,
27
+ id: 'template-1',
28
+ isOwner: true,
29
+ label_ids: [],
30
+ name: 'Bug report',
31
+ priority: 'high',
32
+ project_ids: [],
33
+ slug: 'bug-report',
34
+ source_task_id: null,
35
+ start_date: null,
36
+ task_name: 'Investigate bug',
37
+ updated_at: '2026-06-29T00:00:00.000Z',
38
+ visibility: 'private',
39
+ ws_id: 'ws-1',
40
+ };
41
+
42
+ describe('task template dialogs', () => {
43
+ it('validates and emits create payloads for new templates', () => {
44
+ const onCreate = vi.fn();
45
+
46
+ render(
47
+ <CreateTaskTemplateDialog
48
+ onCreate={onCreate}
49
+ onOpenChange={vi.fn()}
50
+ open
51
+ pending={false}
52
+ />
53
+ );
54
+
55
+ const submit = screen.getByRole('button', { name: 'actions.create' });
56
+ expect(submit).toBeDisabled();
57
+
58
+ fireEvent.change(screen.getByLabelText('fields.template_name'), {
59
+ target: { value: 'Bug report' },
60
+ });
61
+ fireEvent.change(screen.getByLabelText('fields.key'), {
62
+ target: { value: 'bug-report' },
63
+ });
64
+ fireEvent.change(screen.getByLabelText('fields.task_name'), {
65
+ target: { value: 'Investigate bug' },
66
+ });
67
+ fireEvent.change(screen.getByLabelText('fields.description'), {
68
+ target: { value: 'Reproduce and capture expected behavior.' },
69
+ });
70
+ fireEvent.click(submit);
71
+
72
+ expect(onCreate).toHaveBeenCalledWith({
73
+ description: 'Reproduce and capture expected behavior.',
74
+ key: 'bug-report',
75
+ name: 'Bug report',
76
+ priority: null,
77
+ task_name: 'Investigate bug',
78
+ visibility: 'private',
79
+ });
80
+ });
81
+
82
+ it('emits use-template payloads with override titles and selected lists', () => {
83
+ const onUse = vi.fn();
84
+
85
+ render(
86
+ <UseTaskTemplateDialog
87
+ boards={[
88
+ {
89
+ allow_zero_estimates: false,
90
+ archived_at: null,
91
+ count_unestimated_issues: false,
92
+ created_at: '2026-06-29T00:00:00.000Z',
93
+ deleted_at: null,
94
+ estimation_type: null,
95
+ extended_estimation: false,
96
+ icon: null,
97
+ id: 'board-1',
98
+ name: 'Board',
99
+ ticket_prefix: null,
100
+ ws_id: 'ws-1',
101
+ },
102
+ ]}
103
+ lists={[
104
+ {
105
+ color: null,
106
+ id: 'list-1',
107
+ name: 'Todo',
108
+ position: 1,
109
+ status: 'active',
110
+ },
111
+ ]}
112
+ loadingLists={false}
113
+ onBoardChange={vi.fn()}
114
+ onListChange={vi.fn()}
115
+ onOpenChange={vi.fn()}
116
+ onUse={onUse}
117
+ open
118
+ pending={false}
119
+ selectedBoardId="board-1"
120
+ selectedListId="list-1"
121
+ template={template}
122
+ />
123
+ );
124
+
125
+ fireEvent.change(screen.getByLabelText('fields.override_name'), {
126
+ target: { value: 'Checkout bug' },
127
+ });
128
+ fireEvent.click(
129
+ screen.getByRole('button', { name: 'actions.create_task' })
130
+ );
131
+
132
+ expect(onUse).toHaveBeenCalledWith({
133
+ listId: 'list-1',
134
+ name: 'Checkout bug',
135
+ });
136
+ });
137
+
138
+ it('saves existing tasks as templates with private visibility by default', () => {
139
+ const onSave = vi.fn();
140
+
141
+ render(
142
+ <SaveTaskTemplateFromTaskDialog
143
+ onOpenChange={vi.fn()}
144
+ onSave={onSave}
145
+ open
146
+ pending={false}
147
+ />
148
+ );
149
+
150
+ const submit = screen.getByRole('button', { name: 'actions.save' });
151
+ expect(submit).toBeDisabled();
152
+
153
+ fireEvent.change(screen.getByLabelText('fields.task_id'), {
154
+ target: { value: 'task-1' },
155
+ });
156
+ fireEvent.change(screen.getByLabelText('fields.template_name'), {
157
+ target: { value: 'Release checklist' },
158
+ });
159
+ fireEvent.click(submit);
160
+
161
+ expect(onSave).toHaveBeenCalledWith({
162
+ name: 'Release checklist',
163
+ taskId: 'task-1',
164
+ visibility: 'private',
165
+ });
166
+ });
167
+ });
@@ -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
+ }