@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,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
|
+
});
|