@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.
Files changed (36) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +4 -4
  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/tu-do/shared/__tests__/board-client.test.tsx +7 -0
  14. package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +37 -0
  15. package/src/components/ui/tu-do/shared/board-client.tsx +3 -1
  16. package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +55 -1
  17. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -0
  18. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.test.ts +97 -0
  19. package/src/components/ui/tu-do/shared/task-edit-dialog/description-versions.ts +210 -0
  20. package/src/components/ui/tu-do/shared/task-edit-dialog/task-activity-section.tsx +56 -8
  21. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.test.tsx +63 -0
  22. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-change-dialog.tsx +218 -0
  23. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-restore-banner.tsx +83 -0
  24. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.test.tsx +120 -0
  25. package/src/components/ui/tu-do/shared/task-edit-dialog/task-description-version-restore-dialog.tsx +180 -0
  26. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.test.ts +100 -0
  27. package/src/components/ui/tu-do/shared/task-edit-dialog/utils.ts +109 -0
  28. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +381 -34
  29. package/src/components/ui/tu-do/templates/task-template-api.ts +142 -0
  30. package/src/components/ui/tu-do/templates/task-template-card.tsx +118 -0
  31. package/src/components/ui/tu-do/templates/task-template-client.tsx +258 -0
  32. package/src/components/ui/tu-do/templates/task-template-dialogs.test.tsx +167 -0
  33. package/src/components/ui/tu-do/templates/task-template-dialogs.tsx +376 -0
  34. package/src/components/ui/tu-do/templates/task-templates-hub.test.tsx +114 -0
  35. package/src/components/ui/tu-do/templates/task-templates-hub.tsx +50 -0
  36. package/src/components/ui/tu-do/templates/task-templates-page.tsx +6 -11
@@ -0,0 +1,142 @@
1
+ import {
2
+ encodePathSegment,
3
+ getInternalApiClient,
4
+ } from '@tuturuuu/internal-api/client';
5
+ import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
6
+ import type { Task } from '@tuturuuu/types/primitives/Task';
7
+
8
+ export type WorkspaceTaskTemplateVisibility = 'private' | 'workspace';
9
+
10
+ export interface WorkspaceTaskTemplate {
11
+ archived_at: string | null;
12
+ assignee_ids: string[];
13
+ created_at: string;
14
+ created_by: string;
15
+ default_board_id: string | null;
16
+ default_list_id: string | null;
17
+ description: string | null;
18
+ description_yjs_state: number[] | null;
19
+ end_date: string | null;
20
+ estimation_points: number | null;
21
+ id: string;
22
+ isOwner: boolean;
23
+ label_ids: string[];
24
+ name: string;
25
+ priority: TaskPriority | null;
26
+ project_ids: string[];
27
+ slug: string;
28
+ source_task_id: string | null;
29
+ start_date: string | null;
30
+ task_name: string;
31
+ updated_at: string;
32
+ visibility: WorkspaceTaskTemplateVisibility;
33
+ ws_id: string;
34
+ }
35
+
36
+ export interface WorkspaceTaskTemplatePayload {
37
+ assignee_ids?: string[];
38
+ default_board_id?: string | null;
39
+ default_list_id?: string | null;
40
+ description?: string | null;
41
+ description_yjs_state?: number[] | null;
42
+ end_date?: string | null;
43
+ estimation_points?: number | null;
44
+ key?: string;
45
+ label_ids?: string[];
46
+ name: string;
47
+ priority?: TaskPriority | null;
48
+ project_ids?: string[];
49
+ slug?: string;
50
+ source_task_id?: string | null;
51
+ start_date?: string | null;
52
+ task_name?: string;
53
+ title?: string;
54
+ visibility?: WorkspaceTaskTemplateVisibility;
55
+ }
56
+
57
+ interface WorkspaceTaskTemplatesResponse {
58
+ templates: WorkspaceTaskTemplate[];
59
+ }
60
+
61
+ interface WorkspaceTaskTemplateResponse {
62
+ template: WorkspaceTaskTemplate;
63
+ }
64
+
65
+ interface InstantiateWorkspaceTaskTemplateResponse {
66
+ task: Task;
67
+ template: WorkspaceTaskTemplate;
68
+ }
69
+
70
+ function taskTemplatePath(workspaceId: string, suffix = '') {
71
+ return `/api/v1/workspaces/${encodePathSegment(workspaceId)}/task-templates${suffix}`;
72
+ }
73
+
74
+ export async function listWorkspaceTaskTemplates(workspaceId: string) {
75
+ return getInternalApiClient().json<WorkspaceTaskTemplatesResponse>(
76
+ taskTemplatePath(workspaceId),
77
+ { cache: 'no-store' }
78
+ );
79
+ }
80
+
81
+ export async function createWorkspaceTaskTemplate(
82
+ workspaceId: string,
83
+ payload: WorkspaceTaskTemplatePayload
84
+ ) {
85
+ return getInternalApiClient().json<WorkspaceTaskTemplateResponse>(
86
+ taskTemplatePath(workspaceId),
87
+ {
88
+ body: JSON.stringify(payload),
89
+ cache: 'no-store',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ method: 'POST',
92
+ }
93
+ );
94
+ }
95
+
96
+ export async function deleteWorkspaceTaskTemplate(
97
+ workspaceId: string,
98
+ templateKey: string
99
+ ) {
100
+ return getInternalApiClient().json<{ success: true }>(
101
+ taskTemplatePath(workspaceId, `/${encodePathSegment(templateKey)}`),
102
+ { cache: 'no-store', method: 'DELETE' }
103
+ );
104
+ }
105
+
106
+ export async function instantiateWorkspaceTaskTemplate(
107
+ workspaceId: string,
108
+ templateKey: string,
109
+ payload: { listId: string; name?: string }
110
+ ) {
111
+ return getInternalApiClient().json<InstantiateWorkspaceTaskTemplateResponse>(
112
+ taskTemplatePath(
113
+ workspaceId,
114
+ `/${encodePathSegment(templateKey)}/instantiate`
115
+ ),
116
+ {
117
+ body: JSON.stringify(payload),
118
+ cache: 'no-store',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ method: 'POST',
121
+ }
122
+ );
123
+ }
124
+
125
+ export async function saveWorkspaceTaskTemplateFromTask(
126
+ workspaceId: string,
127
+ payload: {
128
+ name?: string;
129
+ taskId: string;
130
+ visibility: WorkspaceTaskTemplateVisibility;
131
+ }
132
+ ) {
133
+ return getInternalApiClient().json<WorkspaceTaskTemplateResponse>(
134
+ taskTemplatePath(workspaceId, '/from-task'),
135
+ {
136
+ body: JSON.stringify(payload),
137
+ cache: 'no-store',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ method: 'POST',
140
+ }
141
+ );
142
+ }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import { Archive, CopyPlus, FileText, Lock, Users } from '@tuturuuu/icons';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from '@tuturuuu/ui/card';
13
+ import { cn } from '@tuturuuu/utils/format';
14
+ import { useTranslations } from 'next-intl';
15
+ import type { WorkspaceTaskTemplate } from './task-template-api';
16
+
17
+ interface TaskTemplateCardProps {
18
+ onArchive: (template: WorkspaceTaskTemplate) => void;
19
+ onUse: (template: WorkspaceTaskTemplate) => void;
20
+ template: WorkspaceTaskTemplate;
21
+ }
22
+
23
+ export function TaskTemplateCard({
24
+ onArchive,
25
+ onUse,
26
+ template,
27
+ }: TaskTemplateCardProps) {
28
+ const t = useTranslations('ws-task-templates');
29
+ const visibilityIcon =
30
+ template.visibility === 'private' ? (
31
+ <Lock className="h-3.5 w-3.5" />
32
+ ) : (
33
+ <Users className="h-3.5 w-3.5" />
34
+ );
35
+
36
+ return (
37
+ <Card className="flex h-full flex-col">
38
+ <CardHeader className="pb-3">
39
+ <div className="flex items-start justify-between gap-3">
40
+ <div className="min-w-0 space-y-1">
41
+ <CardTitle className="truncate text-base">
42
+ {template.name}
43
+ </CardTitle>
44
+ <CardDescription className="truncate">
45
+ {template.slug}
46
+ </CardDescription>
47
+ </div>
48
+ <Badge
49
+ variant="outline"
50
+ className={cn(
51
+ 'shrink-0 gap-1 text-xs',
52
+ template.visibility === 'private' &&
53
+ 'border-dynamic-orange/30 text-dynamic-orange',
54
+ template.visibility === 'workspace' &&
55
+ 'border-dynamic-blue/30 text-dynamic-blue'
56
+ )}
57
+ >
58
+ {visibilityIcon}
59
+ <span>{t(`visibility.${template.visibility}`)}</span>
60
+ </Badge>
61
+ </div>
62
+ </CardHeader>
63
+ <CardContent className="flex flex-1 flex-col gap-4">
64
+ <div className="space-y-2">
65
+ <div className="flex items-center gap-2 font-medium text-sm">
66
+ <FileText className="h-4 w-4 text-muted-foreground" />
67
+ <span className="truncate">{template.task_name}</span>
68
+ </div>
69
+ {template.description && (
70
+ <p className="line-clamp-3 text-muted-foreground text-sm">
71
+ {template.description}
72
+ </p>
73
+ )}
74
+ </div>
75
+
76
+ <div className="mt-auto flex flex-wrap gap-2 text-muted-foreground text-xs">
77
+ {template.priority && (
78
+ <Badge variant="secondary">
79
+ {t(`priority.${template.priority}`)}
80
+ </Badge>
81
+ )}
82
+ {template.label_ids.length > 0 && (
83
+ <Badge variant="secondary">
84
+ {t('card.labels', { count: template.label_ids.length })}
85
+ </Badge>
86
+ )}
87
+ {template.assignee_ids.length > 0 && (
88
+ <Badge variant="secondary">
89
+ {t('card.assignees', { count: template.assignee_ids.length })}
90
+ </Badge>
91
+ )}
92
+ {template.project_ids.length > 0 && (
93
+ <Badge variant="secondary">
94
+ {t('card.projects', { count: template.project_ids.length })}
95
+ </Badge>
96
+ )}
97
+ </div>
98
+
99
+ <div className="flex gap-2">
100
+ <Button className="flex-1 gap-2" onClick={() => onUse(template)}>
101
+ <CopyPlus className="h-4 w-4" />
102
+ {t('actions.use')}
103
+ </Button>
104
+ {template.isOwner && (
105
+ <Button
106
+ aria-label={t('actions.archive')}
107
+ onClick={() => onArchive(template)}
108
+ size="icon"
109
+ variant="outline"
110
+ >
111
+ <Archive className="h-4 w-4" />
112
+ </Button>
113
+ )}
114
+ </div>
115
+ </CardContent>
116
+ </Card>
117
+ );
118
+ }
@@ -0,0 +1,258 @@
1
+ 'use client';
2
+
3
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { FileText, Plus, Save, Search } from '@tuturuuu/icons';
5
+ import {
6
+ listWorkspaceTaskBoards,
7
+ listWorkspaceTaskLists,
8
+ type WorkspaceTaskListSummary,
9
+ } from '@tuturuuu/internal-api/tasks';
10
+ import { Button } from '@tuturuuu/ui/button';
11
+ import { Card, CardContent } from '@tuturuuu/ui/card';
12
+ import { Input } from '@tuturuuu/ui/input';
13
+ import { toast } from '@tuturuuu/ui/sonner';
14
+ import { useTranslations } from 'next-intl';
15
+ import { useMemo, useState } from 'react';
16
+ import {
17
+ createWorkspaceTaskTemplate,
18
+ deleteWorkspaceTaskTemplate,
19
+ instantiateWorkspaceTaskTemplate,
20
+ listWorkspaceTaskTemplates,
21
+ saveWorkspaceTaskTemplateFromTask,
22
+ type WorkspaceTaskTemplate,
23
+ type WorkspaceTaskTemplatePayload,
24
+ } from './task-template-api';
25
+ import { TaskTemplateCard } from './task-template-card';
26
+ import {
27
+ CreateTaskTemplateDialog,
28
+ SaveTaskTemplateFromTaskDialog,
29
+ UseTaskTemplateDialog,
30
+ } from './task-template-dialogs';
31
+
32
+ interface TaskTemplateClientProps {
33
+ initialTemplates?: WorkspaceTaskTemplate[];
34
+ wsId: string;
35
+ }
36
+
37
+ export function TaskTemplateClient({
38
+ initialTemplates = [],
39
+ wsId,
40
+ }: TaskTemplateClientProps) {
41
+ const t = useTranslations('ws-task-templates');
42
+ const queryClient = useQueryClient();
43
+ const [search, setSearch] = useState('');
44
+ const [createOpen, setCreateOpen] = useState(false);
45
+ const [saveFromTaskOpen, setSaveFromTaskOpen] = useState(false);
46
+ const [useOpen, setUseOpen] = useState(false);
47
+ const [selectedTemplate, setSelectedTemplate] =
48
+ useState<WorkspaceTaskTemplate | null>(null);
49
+ const [selectedBoardId, setSelectedBoardId] = useState('');
50
+ const [selectedListId, setSelectedListId] = useState('');
51
+
52
+ const templatesQueryKey = ['task-templates', wsId] as const;
53
+
54
+ const { data: templates = initialTemplates } = useQuery({
55
+ queryKey: templatesQueryKey,
56
+ queryFn: async () => {
57
+ const payload = await listWorkspaceTaskTemplates(wsId);
58
+ return payload.templates;
59
+ },
60
+ initialData: initialTemplates,
61
+ });
62
+
63
+ const { data: boards = [] } = useQuery({
64
+ queryKey: ['task-template-boards', wsId],
65
+ queryFn: async () => {
66
+ const payload = await listWorkspaceTaskBoards(wsId);
67
+ return payload.boards.filter(
68
+ (board) => !board.archived_at && !board.deleted_at
69
+ );
70
+ },
71
+ enabled: useOpen,
72
+ });
73
+
74
+ const { data: lists = [], isLoading: loadingLists } = useQuery({
75
+ queryKey: ['task-template-lists', wsId, selectedBoardId],
76
+ queryFn: async (): Promise<WorkspaceTaskListSummary[]> => {
77
+ if (!selectedBoardId) return [];
78
+ const payload = await listWorkspaceTaskLists(wsId, selectedBoardId);
79
+ return payload.lists.filter((list) => !list.deleted);
80
+ },
81
+ enabled: useOpen && Boolean(selectedBoardId),
82
+ });
83
+
84
+ const filteredTemplates = useMemo(() => {
85
+ const query = search.trim().toLowerCase();
86
+ if (!query) return templates;
87
+
88
+ return templates.filter((template) =>
89
+ [template.name, template.slug, template.task_name, template.description]
90
+ .filter(Boolean)
91
+ .some((value) => value?.toLowerCase().includes(query))
92
+ );
93
+ }, [search, templates]);
94
+
95
+ const createMutation = useMutation({
96
+ mutationFn: (payload: WorkspaceTaskTemplatePayload) =>
97
+ createWorkspaceTaskTemplate(wsId, payload),
98
+ onSuccess: async () => {
99
+ toast.success(t('toast.created'));
100
+ setCreateOpen(false);
101
+ await queryClient.invalidateQueries({ queryKey: templatesQueryKey });
102
+ },
103
+ onError: (error) => {
104
+ toast.error(error instanceof Error ? error.message : t('toast.failed'));
105
+ },
106
+ });
107
+
108
+ const saveFromTaskMutation = useMutation({
109
+ mutationFn: (payload: {
110
+ name?: string;
111
+ taskId: string;
112
+ visibility: 'private' | 'workspace';
113
+ }) =>
114
+ saveWorkspaceTaskTemplateFromTask(wsId, {
115
+ name: payload.name,
116
+ taskId: payload.taskId,
117
+ visibility: payload.visibility,
118
+ }),
119
+ onSuccess: async () => {
120
+ toast.success(t('toast.saved_from_task'));
121
+ setSaveFromTaskOpen(false);
122
+ await queryClient.invalidateQueries({ queryKey: templatesQueryKey });
123
+ },
124
+ onError: (error) => {
125
+ toast.error(error instanceof Error ? error.message : t('toast.failed'));
126
+ },
127
+ });
128
+
129
+ const useMutationForTemplate = useMutation({
130
+ mutationFn: (payload: { listId: string; name?: string }) => {
131
+ if (!selectedTemplate) {
132
+ throw new Error(t('toast.no_template_selected'));
133
+ }
134
+ return instantiateWorkspaceTaskTemplate(wsId, selectedTemplate.slug, {
135
+ listId: payload.listId,
136
+ name: payload.name,
137
+ });
138
+ },
139
+ onSuccess: async () => {
140
+ toast.success(t('toast.task_created'));
141
+ setUseOpen(false);
142
+ setSelectedTemplate(null);
143
+ setSelectedBoardId('');
144
+ setSelectedListId('');
145
+ await queryClient.invalidateQueries({ queryKey: ['tasks'] });
146
+ },
147
+ onError: (error) => {
148
+ toast.error(error instanceof Error ? error.message : t('toast.failed'));
149
+ },
150
+ });
151
+
152
+ const archiveMutation = useMutation({
153
+ mutationFn: (template: WorkspaceTaskTemplate) =>
154
+ deleteWorkspaceTaskTemplate(wsId, template.id),
155
+ onSuccess: async () => {
156
+ toast.success(t('toast.archived'));
157
+ await queryClient.invalidateQueries({ queryKey: templatesQueryKey });
158
+ },
159
+ onError: (error) => {
160
+ toast.error(error instanceof Error ? error.message : t('toast.failed'));
161
+ },
162
+ });
163
+
164
+ const openUseDialog = (template: WorkspaceTaskTemplate) => {
165
+ setSelectedTemplate(template);
166
+ setSelectedBoardId(template.default_board_id ?? '');
167
+ setSelectedListId(template.default_list_id ?? '');
168
+ setUseOpen(true);
169
+ };
170
+
171
+ return (
172
+ <div className="space-y-6">
173
+ <Card>
174
+ <CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-end sm:justify-between">
175
+ <div className="space-y-2">
176
+ <p className="font-medium text-sm leading-none">
177
+ {t('fields.search')}
178
+ </p>
179
+ <div className="relative w-full sm:w-80">
180
+ <Input
181
+ className="pl-9"
182
+ onChange={(event) => setSearch(event.target.value)}
183
+ placeholder={t('fields.search_placeholder')}
184
+ value={search}
185
+ />
186
+ <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
187
+ </div>
188
+ </div>
189
+ <div className="flex flex-wrap gap-2">
190
+ <Button
191
+ className="gap-2"
192
+ onClick={() => setSaveFromTaskOpen(true)}
193
+ variant="outline"
194
+ >
195
+ <Save className="h-4 w-4" />
196
+ {t('actions.save_from_task')}
197
+ </Button>
198
+ <Button className="gap-2" onClick={() => setCreateOpen(true)}>
199
+ <Plus className="h-4 w-4" />
200
+ {t('actions.new_template')}
201
+ </Button>
202
+ </div>
203
+ </CardContent>
204
+ </Card>
205
+
206
+ {filteredTemplates.length === 0 ? (
207
+ <div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16 text-center">
208
+ <FileText className="mb-4 h-10 w-10 text-muted-foreground/50" />
209
+ <h3 className="font-semibold text-lg">{t('empty.title')}</h3>
210
+ <p className="mt-2 max-w-sm text-muted-foreground text-sm">
211
+ {search ? t('empty.no_results') : t('empty.description')}
212
+ </p>
213
+ </div>
214
+ ) : (
215
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
216
+ {filteredTemplates.map((template) => (
217
+ <TaskTemplateCard
218
+ key={template.id}
219
+ onArchive={(item) => archiveMutation.mutate(item)}
220
+ onUse={openUseDialog}
221
+ template={template}
222
+ />
223
+ ))}
224
+ </div>
225
+ )}
226
+
227
+ <CreateTaskTemplateDialog
228
+ onCreate={(payload) => createMutation.mutate(payload)}
229
+ onOpenChange={setCreateOpen}
230
+ open={createOpen}
231
+ pending={createMutation.isPending}
232
+ />
233
+ <SaveTaskTemplateFromTaskDialog
234
+ onOpenChange={setSaveFromTaskOpen}
235
+ onSave={(payload) => saveFromTaskMutation.mutate(payload)}
236
+ open={saveFromTaskOpen}
237
+ pending={saveFromTaskMutation.isPending}
238
+ />
239
+ <UseTaskTemplateDialog
240
+ boards={boards}
241
+ lists={lists}
242
+ loadingLists={loadingLists}
243
+ onBoardChange={(boardId) => {
244
+ setSelectedBoardId(boardId);
245
+ setSelectedListId('');
246
+ }}
247
+ onListChange={setSelectedListId}
248
+ onOpenChange={setUseOpen}
249
+ onUse={(payload) => useMutationForTemplate.mutate(payload)}
250
+ open={useOpen}
251
+ pending={useMutationForTemplate.isPending}
252
+ selectedBoardId={selectedBoardId}
253
+ selectedListId={selectedListId}
254
+ template={selectedTemplate}
255
+ />
256
+ </div>
257
+ );
258
+ }
@@ -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
+ });