@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,83 @@
1
+ 'use client';
2
+
3
+ import { AlertTriangle, History, Loader2, RotateCcw } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
6
+
7
+ interface TaskDescriptionRestoreBannerProps {
8
+ latestVersion: RecoverableTaskDescriptionVersion | null;
9
+ versionCount: number;
10
+ isRestoring: boolean;
11
+ onRestoreLatest: () => void;
12
+ onViewVersions: () => void;
13
+ t: (
14
+ key: string,
15
+ options?: { count?: number; defaultValue?: string }
16
+ ) => string;
17
+ }
18
+
19
+ export function TaskDescriptionRestoreBanner({
20
+ latestVersion,
21
+ versionCount,
22
+ isRestoring,
23
+ onRestoreLatest,
24
+ onViewVersions,
25
+ t,
26
+ }: TaskDescriptionRestoreBannerProps) {
27
+ if (!latestVersion) return null;
28
+
29
+ return (
30
+ <div className="mx-4 mb-3 rounded-md border border-dynamic-orange/30 bg-dynamic-orange/5 p-3 md:mx-8">
31
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
32
+ <div className="flex min-w-0 gap-2">
33
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-dynamic-orange" />
34
+ <div className="min-w-0 space-y-1">
35
+ <p className="font-medium text-sm">
36
+ {t('description_restore_banner_title', {
37
+ defaultValue: 'A tracked description version is available',
38
+ })}
39
+ </p>
40
+ <p className="text-muted-foreground text-xs">
41
+ {t('description_restore_banner_description', {
42
+ count: versionCount,
43
+ defaultValue:
44
+ 'Current content differs from the latest tracked version. Restore it or compare all tracked versions.',
45
+ })}
46
+ </p>
47
+ </div>
48
+ </div>
49
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
50
+ <Button
51
+ className="h-8 gap-1.5 px-2.5 text-xs"
52
+ disabled={isRestoring}
53
+ onClick={onRestoreLatest}
54
+ size="sm"
55
+ type="button"
56
+ variant="default"
57
+ >
58
+ {isRestoring ? (
59
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
60
+ ) : (
61
+ <RotateCcw className="h-3.5 w-3.5" />
62
+ )}
63
+ {t('restore_latest_tracked', {
64
+ defaultValue: 'Restore latest tracked',
65
+ })}
66
+ </Button>
67
+ <Button
68
+ className="h-8 gap-1.5 px-2.5 text-xs"
69
+ onClick={onViewVersions}
70
+ size="sm"
71
+ type="button"
72
+ variant="outline"
73
+ >
74
+ <History className="h-3.5 w-3.5" />
75
+ {t('view_description_versions', {
76
+ defaultValue: 'View versions',
77
+ })}
78
+ </Button>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,120 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
7
+ import { TaskDescriptionRestoreBanner } from './task-description-restore-banner';
8
+ import { TaskDescriptionVersionRestoreDialog } from './task-description-version-restore-dialog';
9
+
10
+ vi.mock('./description-diff-viewer', () => ({
11
+ DescriptionDiffViewer: ({ trigger }: { trigger?: ReactNode }) =>
12
+ trigger ?? <button type="button">compare</button>,
13
+ }));
14
+
15
+ const t = (_key: string, options?: { count?: number; defaultValue?: string }) =>
16
+ options?.defaultValue ?? _key;
17
+
18
+ const makeVersion = (
19
+ overrides: Partial<RecoverableTaskDescriptionVersion> = {}
20
+ ): RecoverableTaskDescriptionVersion => ({
21
+ id: 'history-1:new_value',
22
+ historyId: 'history-1',
23
+ changedAt: '2026-06-27T00:00:00.000Z',
24
+ source: 'new_value',
25
+ reason: 'tracked',
26
+ description:
27
+ '{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Tracked description"}]}]}',
28
+ content: {
29
+ type: 'doc',
30
+ content: [
31
+ {
32
+ type: 'paragraph',
33
+ content: [{ type: 'text', text: 'Tracked description' }],
34
+ },
35
+ ],
36
+ },
37
+ previewText: 'Tracked description',
38
+ user: { id: 'user-1', name: 'User' },
39
+ ...overrides,
40
+ });
41
+
42
+ describe('task description restore UI', () => {
43
+ it('shows the recovery banner and wires restore/view actions', () => {
44
+ const onRestoreLatest = vi.fn();
45
+ const onViewVersions = vi.fn();
46
+
47
+ render(
48
+ <TaskDescriptionRestoreBanner
49
+ isRestoring={false}
50
+ latestVersion={makeVersion()}
51
+ onRestoreLatest={onRestoreLatest}
52
+ onViewVersions={onViewVersions}
53
+ t={t}
54
+ versionCount={1}
55
+ />
56
+ );
57
+
58
+ fireEvent.click(
59
+ screen.getByRole('button', { name: /restore latest tracked/i })
60
+ );
61
+ fireEvent.click(screen.getByRole('button', { name: /view versions/i }));
62
+
63
+ expect(onRestoreLatest).toHaveBeenCalledTimes(1);
64
+ expect(onViewVersions).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('does not show the recovery banner without a newer tracked version', () => {
68
+ const { container } = render(
69
+ <TaskDescriptionRestoreBanner
70
+ isRestoring={false}
71
+ latestVersion={null}
72
+ onRestoreLatest={vi.fn()}
73
+ onViewVersions={vi.fn()}
74
+ t={t}
75
+ versionCount={0}
76
+ />
77
+ );
78
+
79
+ expect(container).toBeEmptyDOMElement();
80
+ });
81
+
82
+ it('shows an empty version picker state when history has no recoverable content', () => {
83
+ render(
84
+ <TaskDescriptionVersionRestoreDialog
85
+ currentDescription={null}
86
+ isOpen
87
+ onClose={vi.fn()}
88
+ onRestoreVersion={vi.fn()}
89
+ t={t}
90
+ versions={[]}
91
+ />
92
+ );
93
+
94
+ expect(
95
+ screen.getByText(/no restorable description versions were found/i)
96
+ ).toBeInTheDocument();
97
+ });
98
+
99
+ it('restores a selected version from the version picker', () => {
100
+ const onRestoreVersion = vi.fn();
101
+ const version = makeVersion();
102
+
103
+ render(
104
+ <TaskDescriptionVersionRestoreDialog
105
+ currentDescription={null}
106
+ isOpen
107
+ onClose={vi.fn()}
108
+ onRestoreVersion={onRestoreVersion}
109
+ t={t}
110
+ versions={[version]}
111
+ />
112
+ );
113
+
114
+ expect(screen.getByText('Tracked description')).toBeInTheDocument();
115
+
116
+ fireEvent.click(screen.getByRole('button', { name: /^restore$/i }));
117
+
118
+ expect(onRestoreVersion).toHaveBeenCalledWith(version);
119
+ });
120
+ });
@@ -0,0 +1,180 @@
1
+ 'use client';
2
+
3
+ import { Clock, Eye, Loader2, RotateCcw } from '@tuturuuu/icons';
4
+ import { Badge } from '@tuturuuu/ui/badge';
5
+ import { Button } from '@tuturuuu/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@tuturuuu/ui/dialog';
13
+ import { ScrollArea } from '@tuturuuu/ui/scroll-area';
14
+ import { format, formatDistanceToNow } from 'date-fns';
15
+ import { enUS, vi } from 'date-fns/locale';
16
+ import { DescriptionDiffViewer } from './description-diff-viewer';
17
+ import type { RecoverableTaskDescriptionVersion } from './description-versions';
18
+
19
+ interface TaskDescriptionVersionRestoreDialogProps {
20
+ currentDescription: string | null;
21
+ isOpen: boolean;
22
+ locale?: string;
23
+ onClose: () => void;
24
+ onRestoreVersion: (
25
+ version: RecoverableTaskDescriptionVersion
26
+ ) => Promise<void> | void;
27
+ restoringVersionId?: string | null;
28
+ t: (
29
+ key: string,
30
+ options?: { count?: number; defaultValue?: string }
31
+ ) => string;
32
+ versions: RecoverableTaskDescriptionVersion[];
33
+ }
34
+
35
+ export function TaskDescriptionVersionRestoreDialog({
36
+ currentDescription,
37
+ isOpen,
38
+ locale = 'en',
39
+ onClose,
40
+ onRestoreVersion,
41
+ restoringVersionId,
42
+ t,
43
+ versions,
44
+ }: TaskDescriptionVersionRestoreDialogProps) {
45
+ const dateLocale = locale === 'vi' ? vi : enUS;
46
+
47
+ return (
48
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
49
+ <DialogContent className="grid max-h-[85vh] w-full max-w-2xl grid-rows-[auto_1fr] overflow-hidden md:max-w-3xl">
50
+ <DialogHeader>
51
+ <DialogTitle>
52
+ {t('description_versions_title', {
53
+ defaultValue: 'Description versions',
54
+ })}
55
+ </DialogTitle>
56
+ <DialogDescription>
57
+ {t('description_versions_description', {
58
+ defaultValue:
59
+ 'Compare tracked description versions and restore the one that should be current.',
60
+ })}
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+
64
+ <ScrollArea className="-mx-6 min-h-0 px-6">
65
+ <div className="space-y-3 py-4">
66
+ {versions.length === 0 ? (
67
+ <div className="rounded-md border border-dashed p-4 text-center text-muted-foreground text-sm">
68
+ {t('no_recoverable_description_versions', {
69
+ defaultValue:
70
+ 'No restorable description versions were found in history.',
71
+ })}
72
+ </div>
73
+ ) : (
74
+ versions.map((version, index) => {
75
+ const isRestoring = restoringVersionId === version.id;
76
+ const time = new Date(version.changedAt);
77
+ const exactTime = format(time, 'PPpp', {
78
+ locale: dateLocale,
79
+ });
80
+ const relativeTime = formatDistanceToNow(time, {
81
+ addSuffix: true,
82
+ locale: dateLocale,
83
+ });
84
+
85
+ return (
86
+ <div
87
+ className="rounded-md border bg-card p-3"
88
+ key={version.id}
89
+ >
90
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
91
+ <div className="min-w-0 space-y-2">
92
+ <div className="flex flex-wrap items-center gap-2">
93
+ {index === 0 && (
94
+ <Badge variant="secondary">
95
+ {t('latest_version', {
96
+ defaultValue: 'Latest',
97
+ })}
98
+ </Badge>
99
+ )}
100
+ {version.reason === 'before_clear' && (
101
+ <Badge
102
+ className="border-dynamic-orange/30 text-dynamic-orange"
103
+ variant="outline"
104
+ >
105
+ {t('before_clear_version', {
106
+ defaultValue: 'Before clear',
107
+ })}
108
+ </Badge>
109
+ )}
110
+ <span
111
+ className="inline-flex items-center gap-1 text-muted-foreground text-xs"
112
+ title={exactTime}
113
+ >
114
+ <Clock className="h-3.5 w-3.5" />
115
+ {relativeTime}
116
+ </span>
117
+ </div>
118
+ <p className="line-clamp-2 text-sm">
119
+ {version.previewText ||
120
+ t('description_version_no_preview', {
121
+ defaultValue: 'No text preview',
122
+ })}
123
+ </p>
124
+ {version.user?.name && (
125
+ <p className="text-muted-foreground text-xs">
126
+ {t('tracked_by_user', {
127
+ defaultValue: 'Tracked by',
128
+ })}
129
+ {` ${version.user.name}`}
130
+ </p>
131
+ )}
132
+ </div>
133
+
134
+ <div className="flex shrink-0 flex-wrap items-center gap-2">
135
+ <DescriptionDiffViewer
136
+ newValue={version.description}
137
+ oldValue={currentDescription}
138
+ t={t}
139
+ trigger={
140
+ <Button
141
+ className="h-8 gap-1.5 px-2.5 text-xs"
142
+ size="sm"
143
+ type="button"
144
+ variant="outline"
145
+ >
146
+ <Eye className="h-3.5 w-3.5" />
147
+ {t('compare_version', {
148
+ defaultValue: 'Compare',
149
+ })}
150
+ </Button>
151
+ }
152
+ />
153
+ <Button
154
+ className="h-8 gap-1.5 px-2.5 text-xs"
155
+ disabled={!!restoringVersionId}
156
+ onClick={() => onRestoreVersion(version)}
157
+ size="sm"
158
+ type="button"
159
+ >
160
+ {isRestoring ? (
161
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
162
+ ) : (
163
+ <RotateCcw className="h-3.5 w-3.5" />
164
+ )}
165
+ {t('restore_version', {
166
+ defaultValue: 'Restore',
167
+ })}
168
+ </Button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+ })
174
+ )}
175
+ </div>
176
+ </ScrollArea>
177
+ </DialogContent>
178
+ </Dialog>
179
+ );
180
+ }
@@ -4,8 +4,9 @@
4
4
 
5
5
  import '@testing-library/jest-dom';
6
6
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
7
- import { fireEvent, render, screen } from '@testing-library/react';
7
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
8
8
  import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
9
+ import type { ComponentProps } from 'react';
9
10
  import { describe, expect, it, vi } from 'vitest';
10
11
  import { TaskPropertiesSection } from './task-properties-section';
11
12
 
@@ -25,7 +26,9 @@ vi.mock('@tuturuuu/ui/hooks/use-calendar-preferences', () => ({
25
26
  }),
26
27
  }));
27
28
 
28
- function renderTaskPropertiesSection() {
29
+ function renderTaskPropertiesSection(
30
+ overrides: Partial<ComponentProps<typeof TaskPropertiesSection>> = {}
31
+ ) {
29
32
  const props = {
30
33
  wsId: 'ws-1',
31
34
  boardId: 'board-1',
@@ -72,7 +75,13 @@ function renderTaskPropertiesSection() {
72
75
  created_at: '2026-01-01T00:00:00.000Z',
73
76
  },
74
77
  ],
75
- taskProjects: [],
78
+ taskProjects: [
79
+ {
80
+ id: 'project-1',
81
+ name: 'Launch',
82
+ status: 'active',
83
+ },
84
+ ],
76
85
  workspaceMembers: [
77
86
  {
78
87
  id: 'user-1',
@@ -105,6 +114,7 @@ function renderTaskPropertiesSection() {
105
114
  disabled: false,
106
115
  isDraftMode: false,
107
116
  variant: 'compact' as const,
117
+ ...overrides,
108
118
  };
109
119
 
110
120
  const queryClient = new QueryClient({
@@ -124,13 +134,18 @@ function renderTaskPropertiesSection() {
124
134
  }
125
135
 
126
136
  describe('TaskPropertiesSection', () => {
127
- it('keeps only one property popover open at a time', () => {
137
+ it('keeps only one property popover open at a time', async () => {
128
138
  renderTaskPropertiesSection();
129
139
 
130
140
  fireEvent.click(screen.getByLabelText('common.priority'));
131
141
  expect(screen.getByText('tasks.priority_critical')).toBeInTheDocument();
132
142
 
133
143
  fireEvent.click(screen.getByLabelText('common.labels'));
144
+ await waitFor(() =>
145
+ expect(
146
+ screen.queryByText('tasks.priority_critical')
147
+ ).not.toBeInTheDocument()
148
+ );
134
149
  expect(
135
150
  screen.queryByText('tasks.priority_critical')
136
151
  ).not.toBeInTheDocument();
@@ -139,6 +154,11 @@ describe('TaskPropertiesSection', () => {
139
154
  ).toBeInTheDocument();
140
155
 
141
156
  fireEvent.click(screen.getByLabelText('common.list_name_to_do'));
157
+ await waitFor(() =>
158
+ expect(
159
+ screen.queryByPlaceholderText('common.search_labels')
160
+ ).not.toBeInTheDocument()
161
+ );
142
162
  expect(
143
163
  screen.queryByPlaceholderText('common.search_labels')
144
164
  ).not.toBeInTheDocument();
@@ -147,4 +167,137 @@ describe('TaskPropertiesSection', () => {
147
167
  'true'
148
168
  );
149
169
  });
170
+
171
+ it('switches from priority to dates without closing the target popover', async () => {
172
+ renderTaskPropertiesSection();
173
+
174
+ fireEvent.click(screen.getByLabelText('common.priority'));
175
+ expect(screen.getByText('tasks.priority_critical')).toBeInTheDocument();
176
+
177
+ fireEvent.click(screen.getByLabelText('ws-task-boards.dialog.dates'));
178
+
179
+ await waitFor(() =>
180
+ expect(
181
+ screen.getByText('ws-task-boards.dialog.start_date')
182
+ ).toBeInTheDocument()
183
+ );
184
+ expect(
185
+ screen.getByText('ws-task-boards.dialog.due_date')
186
+ ).toBeInTheDocument();
187
+ expect(
188
+ screen.queryByText('tasks.priority_critical')
189
+ ).not.toBeInTheDocument();
190
+ });
191
+
192
+ it.each([
193
+ {
194
+ triggerLabel: 'common.labels',
195
+ targetPlaceholder: 'common.search_labels',
196
+ },
197
+ {
198
+ triggerLabel: 'common.projects',
199
+ targetPlaceholder: 'common.search_projects',
200
+ },
201
+ {
202
+ triggerLabel: 'common.assignees',
203
+ targetPlaceholder: 'common.search_members',
204
+ },
205
+ ])('switches from priority to $triggerLabel and keeps the target popover open', async ({
206
+ targetPlaceholder,
207
+ triggerLabel,
208
+ }) => {
209
+ renderTaskPropertiesSection();
210
+
211
+ fireEvent.click(screen.getByLabelText('common.priority'));
212
+ expect(screen.getByText('tasks.priority_critical')).toBeInTheDocument();
213
+
214
+ fireEvent.click(screen.getByLabelText(triggerLabel));
215
+
216
+ await waitFor(() =>
217
+ expect(screen.getByPlaceholderText(targetPlaceholder)).toBeInTheDocument()
218
+ );
219
+ expect(
220
+ screen.queryByText('tasks.priority_critical')
221
+ ).not.toBeInTheDocument();
222
+ });
223
+
224
+ it('closes the priority popover after selecting a priority', () => {
225
+ const props = renderTaskPropertiesSection();
226
+
227
+ fireEvent.click(screen.getByLabelText('common.priority'));
228
+ fireEvent.click(screen.getByText('tasks.priority_high'));
229
+
230
+ expect(props.onPriorityChange).toHaveBeenCalledWith('high');
231
+ expect(
232
+ screen.queryByText('tasks.priority_critical')
233
+ ).not.toBeInTheDocument();
234
+ });
235
+
236
+ it('keeps labels popover open after toggling a label', () => {
237
+ const props = renderTaskPropertiesSection();
238
+
239
+ fireEvent.click(screen.getByLabelText('common.labels'));
240
+ fireEvent.click(screen.getByText('Bug'));
241
+
242
+ expect(props.onLabelToggle).toHaveBeenCalledWith(
243
+ expect.objectContaining({ id: 'label-1' })
244
+ );
245
+ expect(
246
+ screen.getByPlaceholderText('common.search_labels')
247
+ ).toBeInTheDocument();
248
+ });
249
+
250
+ it('keeps projects popover open after toggling a project', () => {
251
+ const props = renderTaskPropertiesSection();
252
+
253
+ fireEvent.click(screen.getByLabelText('common.projects'));
254
+ fireEvent.click(screen.getByText('Launch'));
255
+
256
+ expect(props.onProjectToggle).toHaveBeenCalledWith(
257
+ expect.objectContaining({ id: 'project-1' })
258
+ );
259
+ expect(
260
+ screen.getByPlaceholderText('common.search_projects')
261
+ ).toBeInTheDocument();
262
+ });
263
+
264
+ it('keeps assignees popover open after toggling an assignee', () => {
265
+ const props = renderTaskPropertiesSection();
266
+
267
+ fireEvent.click(screen.getByLabelText('common.assignees'));
268
+ fireEvent.click(screen.getByText('Taylor'));
269
+
270
+ expect(props.onAssigneeToggle).toHaveBeenCalledWith(
271
+ expect.objectContaining({ user_id: 'user-1' })
272
+ );
273
+ expect(
274
+ screen.getByPlaceholderText('common.search_members')
275
+ ).toBeInTheDocument();
276
+ });
277
+
278
+ it('closes the active popover when clicking outside', async () => {
279
+ renderTaskPropertiesSection();
280
+
281
+ fireEvent.click(screen.getByLabelText('common.labels'));
282
+ await waitFor(() =>
283
+ expect(
284
+ screen.getByPlaceholderText('common.search_labels')
285
+ ).toBeInTheDocument()
286
+ );
287
+
288
+ await new Promise((resolve) => setTimeout(resolve, 0));
289
+ fireEvent.pointerDown(document.body, {
290
+ button: 0,
291
+ ctrlKey: false,
292
+ pointerType: 'mouse',
293
+ });
294
+ fireEvent.mouseDown(document.body, { button: 0, ctrlKey: false });
295
+ fireEvent.click(document.body);
296
+
297
+ await waitFor(() =>
298
+ expect(
299
+ screen.queryByPlaceholderText('common.search_labels')
300
+ ).not.toBeInTheDocument()
301
+ );
302
+ });
150
303
  });