@tuturuuu/ui 0.11.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.1](https://github.com/tutur3u/platform/compare/ui-v0.11.0...ui-v0.11.1) (2026-07-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **cli:** preserve markdown tables in task descriptions ([9faf8ba](https://github.com/tutur3u/platform/commit/9faf8ba44b92565a9dfc2949c496654fc1fea9b3))
9
+ * **tasks:** refresh task template placeholders ([0c3c309](https://github.com/tutur3u/platform/commit/0c3c30935f67e3e64d548c8a13584fd597e887b8))
10
+ * **ui:** stabilize task property popover handoff ([8fb2e62](https://github.com/tutur3u/platform/commit/8fb2e62904d34a3ae7894bfbbb66c285fcf1a1bd))
11
+
3
12
  ## [0.11.0](https://github.com/tutur3u/platform/compare/ui-v0.10.0...ui-v0.11.0) (2026-06-29)
4
13
 
5
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,42 +24,42 @@
24
24
  "@hookform/resolvers": "^5.4.0",
25
25
  "@json-render/core": "^0.19.0",
26
26
  "@json-render/react": "^0.19.0",
27
- "@radix-ui/react-accordion": "^1.2.14",
28
- "@radix-ui/react-alert-dialog": "^1.1.17",
29
- "@radix-ui/react-aspect-ratio": "^1.1.10",
30
- "@radix-ui/react-avatar": "^1.2.0",
31
- "@radix-ui/react-checkbox": "^1.3.5",
32
- "@radix-ui/react-collapsible": "^1.1.14",
33
- "@radix-ui/react-context-menu": "^2.3.1",
34
- "@radix-ui/react-dialog": "^1.1.17",
35
- "@radix-ui/react-dropdown-menu": "^2.1.18",
36
- "@radix-ui/react-hover-card": "^1.1.17",
37
- "@radix-ui/react-label": "^2.1.10",
38
- "@radix-ui/react-menubar": "^1.1.18",
39
- "@radix-ui/react-navigation-menu": "^1.2.16",
40
- "@radix-ui/react-popover": "^1.1.17",
41
- "@radix-ui/react-progress": "^1.1.10",
42
- "@radix-ui/react-radio-group": "^1.4.1",
43
- "@radix-ui/react-scroll-area": "^1.2.12",
44
- "@radix-ui/react-select": "^2.3.1",
45
- "@radix-ui/react-separator": "^1.1.10",
46
- "@radix-ui/react-slider": "^1.4.1",
27
+ "@radix-ui/react-accordion": "^1.2.15",
28
+ "@radix-ui/react-alert-dialog": "^1.1.18",
29
+ "@radix-ui/react-aspect-ratio": "^1.1.11",
30
+ "@radix-ui/react-avatar": "^1.2.1",
31
+ "@radix-ui/react-checkbox": "^1.3.6",
32
+ "@radix-ui/react-collapsible": "^1.1.15",
33
+ "@radix-ui/react-context-menu": "^2.3.2",
34
+ "@radix-ui/react-dialog": "^1.1.18",
35
+ "@radix-ui/react-dropdown-menu": "^2.1.19",
36
+ "@radix-ui/react-hover-card": "^1.1.18",
37
+ "@radix-ui/react-label": "^2.1.11",
38
+ "@radix-ui/react-menubar": "^1.1.19",
39
+ "@radix-ui/react-navigation-menu": "^1.2.17",
40
+ "@radix-ui/react-popover": "^1.1.18",
41
+ "@radix-ui/react-progress": "^1.1.11",
42
+ "@radix-ui/react-radio-group": "^1.4.2",
43
+ "@radix-ui/react-scroll-area": "^1.2.13",
44
+ "@radix-ui/react-select": "^2.3.2",
45
+ "@radix-ui/react-separator": "^1.1.11",
46
+ "@radix-ui/react-slider": "^1.4.2",
47
47
  "@radix-ui/react-slot": "^1.3.0",
48
- "@radix-ui/react-switch": "^1.3.1",
49
- "@radix-ui/react-tabs": "^1.1.15",
50
- "@radix-ui/react-toast": "^1.2.17",
51
- "@radix-ui/react-toggle": "^1.1.12",
52
- "@radix-ui/react-toggle-group": "^1.1.13",
53
- "@radix-ui/react-tooltip": "^1.2.10",
48
+ "@radix-ui/react-switch": "^1.3.2",
49
+ "@radix-ui/react-tabs": "^1.1.16",
50
+ "@radix-ui/react-toast": "^1.2.18",
51
+ "@radix-ui/react-toggle": "^1.1.13",
52
+ "@radix-ui/react-toggle-group": "^1.1.14",
53
+ "@radix-ui/react-tooltip": "^1.2.11",
54
54
  "@streamdown/cjk": "^1.0.3",
55
55
  "@streamdown/code": "^1.1.1",
56
56
  "@streamdown/math": "^1.0.2",
57
57
  "@streamdown/mermaid": "^1.0.2",
58
58
  "@tanstack/react-hotkeys": "^0.10.0",
59
59
  "@tanstack/react-pacer": "^0.22.1",
60
- "@tanstack/react-query": "^5.101.1",
60
+ "@tanstack/react-query": "^5.101.2",
61
61
  "@tanstack/react-table": "^8.21.3",
62
- "@tanstack/react-virtual": "^3.14.3",
62
+ "@tanstack/react-virtual": "^3.14.5",
63
63
  "@tiptap/core": "3.27.1",
64
64
  "@tiptap/extension-collaboration": "3.27.1",
65
65
  "@tiptap/extension-collaboration-caret": "3.27.1",
@@ -88,9 +88,9 @@
88
88
  "@tuturuuu/apis": "0.7.0",
89
89
  "@tuturuuu/hooks": "0.0.2",
90
90
  "@tuturuuu/icons": "0.0.6",
91
- "@tuturuuu/internal-api": "0.12.0",
91
+ "@tuturuuu/internal-api": "0.13.0",
92
92
  "@tuturuuu/supabase": "0.4.0",
93
- "@tuturuuu/utils": "0.11.0",
93
+ "@tuturuuu/utils": "0.12.0",
94
94
  "@types/debug": "^4.1.13",
95
95
  "browser-image-compression": "^2.0.2",
96
96
  "class-variance-authority": "^0.7.1",
@@ -102,18 +102,18 @@
102
102
  "debug": "^4.4.3",
103
103
  "embla-carousel-react": "^8.6.0",
104
104
  "eventemitter3": "^5.0.4",
105
- "framer-motion": "^12.41.0",
105
+ "framer-motion": "^12.42.2",
106
106
  "gsap": "^3.15.0",
107
- "html2canvas-pro": "^2.1.1",
107
+ "html2canvas-pro": "^2.2.1",
108
108
  "input-otp": "^1.4.2",
109
109
  "katex": "^0.17.0",
110
110
  "lodash": "4.18.1",
111
111
  "moment": "^2.30.1",
112
- "motion": "^12.41.0",
113
- "next": "^16.2.9",
114
- "next-intl": "^4.13.0",
112
+ "motion": "^12.42.2",
113
+ "next": "^16.3.0-preview.5",
114
+ "next-intl": "^4.13.1",
115
115
  "next-themes": "^0.4.6",
116
- "nuqs": "^2.8.9",
116
+ "nuqs": "^2.9.0",
117
117
  "prosemirror-state": "^1.4.4",
118
118
  "qrcode.react": "^4.2.0",
119
119
  "react": "^19.2.7",
@@ -126,9 +126,9 @@
126
126
  "react-papaparse": "^4.4.0",
127
127
  "react-pdf": "^10.4.1",
128
128
  "react-resizable": "^4.0.2",
129
- "react-resizable-panels": "^4.11.0",
129
+ "react-resizable-panels": "^4.12.0",
130
130
  "react-syntax-highlighter": "^16.1.1",
131
- "recharts": "3.9.0",
131
+ "recharts": "3.9.1",
132
132
  "sonner": "^2.0.7",
133
133
  "streamdown": "^2.5.0",
134
134
  "tailwind-scrollbar": "^4.0.2",
@@ -143,34 +143,34 @@
143
143
  "zod": "^4.4.3"
144
144
  },
145
145
  "devDependencies": {
146
- "@tailwindcss/postcss": "^4.3.1",
146
+ "@tailwindcss/postcss": "^4.3.2",
147
147
  "@tailwindcss/typography": "^0.5.20",
148
- "@tanstack/react-query": "^5.101.1",
148
+ "@tanstack/react-query": "^5.101.2",
149
149
  "@tanstack/react-table": "^8.21.3",
150
150
  "@testing-library/jest-dom": "^6.9.1",
151
151
  "@testing-library/react": "^16.3.2",
152
- "@tuturuuu/types": "0.13.0",
152
+ "@tuturuuu/types": "0.13.1",
153
153
  "@tuturuuu/typescript-config": "0.1.1",
154
154
  "@types/html2canvas": "^1.0.0",
155
155
  "@types/lodash": "^4.17.24",
156
- "@types/node": "^26.0.0",
156
+ "@types/node": "^26.0.1",
157
157
  "@types/react": "^19.2.17",
158
158
  "@types/react-dom": "^19.2.3",
159
159
  "@types/react-resizable": "^4.0.0",
160
160
  "@types/react-syntax-highlighter": "^15.5.13",
161
161
  "@types/uuid": "^11.0.0",
162
162
  "@vitejs/plugin-react": "^6.0.3",
163
- "autoprefixer": "^10.5.0",
163
+ "autoprefixer": "^10.5.2",
164
164
  "jsdom": "^29.1.1",
165
- "postcss": "^8.5.14",
165
+ "postcss": "^8.5.16",
166
166
  "postcss-load-config": "^6.0.1",
167
- "tailwindcss": "^4.3.1",
167
+ "tailwindcss": "^4.3.2",
168
168
  "typescript": "7.0.1-rc",
169
169
  "uuid": "^14.0.1",
170
170
  "vitest": "^4.1.9"
171
171
  },
172
172
  "peerDependencies": {
173
- "@tanstack/react-query": "^5.101.1",
173
+ "@tanstack/react-query": "^5.101.2",
174
174
  "@tanstack/react-table": "^8.21.3"
175
175
  },
176
176
  "exports": {
@@ -1,5 +1,6 @@
1
1
  import { generateHTML, generateJSON } from '@tiptap/core';
2
2
  import type SupabaseProvider from '@tuturuuu/ui/hooks/supabase-provider';
3
+ import { parseTaskDescriptionInput } from '@tuturuuu/utils/task-description-codec';
3
4
  import { describe, expect, it } from 'vitest';
4
5
  import * as Y from 'yjs';
5
6
  import { getEditorExtensions } from '../extensions';
@@ -157,6 +158,27 @@ describe('text editor extensions', () => {
157
158
  expect(html).toContain('#FFF59D');
158
159
  });
159
160
 
161
+ it('renders shared markdown-codec table JSON with editor table extensions', () => {
162
+ const extensions = getEditorExtensions({ readOnly: true });
163
+ const content = parseTaskDescriptionInput(
164
+ ['| Field | Value |', '| --- | --- |', '| Owner | Platform |'].join('\n'),
165
+ 'markdown'
166
+ );
167
+
168
+ const html = generateHTML(content, extensions);
169
+
170
+ expect(html).toContain('<table');
171
+ expect(html).toContain('<th');
172
+ expect(html).toContain('<td');
173
+ expect(html).toContain('Platform');
174
+
175
+ const parsed = generateJSON(html, extensions);
176
+ expect(parsed.content?.[0]?.type).toBe('table');
177
+ expect(parsed.content?.[0]?.content?.[0]?.content?.[0]?.type).toBe(
178
+ 'tableHeader'
179
+ );
180
+ });
181
+
160
182
  it('round-trips task mention workspace metadata through HTML attrs', () => {
161
183
  const renderOutput = (Mention.config as any).renderHTML({
162
184
  HTMLAttributes: {
@@ -5,7 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
5
5
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
6
6
  import { cn } from '@tuturuuu/utils/format';
7
7
  import { useTranslations } from 'next-intl';
8
- import { useMemo, useState } from 'react';
8
+ import { type ComponentProps, useMemo, useState } from 'react';
9
9
  import { CreateListDialog } from '../../create-list-dialog';
10
10
  import { translateTaskListNameForDisplay } from '../../utils/translate-task-list-display-name';
11
11
  import { TaskListPickerPanel } from './task-list-picker-panel';
@@ -23,7 +23,14 @@ interface TaskListSelectorProps {
23
23
  compact?: boolean;
24
24
  open?: boolean;
25
25
  onOpenChange?: (open: boolean) => void;
26
+ onPopoverCloseAutoFocus?: ComponentProps<
27
+ typeof PopoverContent
28
+ >['onCloseAutoFocus'];
29
+ onPopoverInteractOutside?: ComponentProps<
30
+ typeof PopoverContent
31
+ >['onInteractOutside'];
26
32
  onListChange: (listId: string) => void;
33
+ propertyPopoverId?: string;
27
34
  }
28
35
 
29
36
  export function TaskListSelector({
@@ -35,7 +42,10 @@ export function TaskListSelector({
35
42
  compact = false,
36
43
  open,
37
44
  onOpenChange,
45
+ onPopoverCloseAutoFocus,
46
+ onPopoverInteractOutside,
38
47
  onListChange,
48
+ propertyPopoverId,
39
49
  }: TaskListSelectorProps) {
40
50
  const t = useTranslations();
41
51
  const [uncontrolledPopoverOpen, setUncontrolledPopoverOpen] = useState(false);
@@ -80,6 +90,7 @@ export function TaskListSelector({
80
90
  const triggerButton = (
81
91
  <button
82
92
  type="button"
93
+ data-task-property-popover-trigger={propertyPopoverId}
83
94
  disabled={disabled}
84
95
  aria-label={compact ? triggerLabel : undefined}
85
96
  className={cn(
@@ -111,7 +122,12 @@ export function TaskListSelector({
111
122
  ) : (
112
123
  <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
113
124
  )}
114
- <PopoverContent align="start" className="w-80 p-0">
125
+ <PopoverContent
126
+ align="start"
127
+ className="w-80 p-0"
128
+ onCloseAutoFocus={onPopoverCloseAutoFocus}
129
+ onInteractOutside={onPopoverInteractOutside}
130
+ >
115
131
  <TaskListPickerPanel
116
132
  selectedListId={selectedListId}
117
133
  availableLists={availableLists}
@@ -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
  });
@@ -42,6 +42,10 @@ import { computeAccessibleLabelStyles } from '@tuturuuu/utils/label-colors';
42
42
  import dayjs from 'dayjs';
43
43
  import { useTranslations } from 'next-intl';
44
44
  import {
45
+ type ComponentProps,
46
+ cloneElement,
47
+ isValidElement,
48
+ type ReactElement,
45
49
  type ReactNode,
46
50
  useCallback,
47
51
  useEffect,
@@ -162,29 +166,75 @@ type TaskPropertyPopoverId =
162
166
  | 'assignees'
163
167
  | 'scheduling';
164
168
 
169
+ const TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE =
170
+ 'data-task-property-popover-trigger';
171
+
172
+ const taskPropertyPopoverIds = new Set<TaskPropertyPopoverId>([
173
+ 'priority',
174
+ 'list',
175
+ 'dates',
176
+ 'estimation',
177
+ 'labels',
178
+ 'projects',
179
+ 'assignees',
180
+ 'scheduling',
181
+ ]);
182
+
183
+ function isTaskPropertyPopoverId(
184
+ value: string | null
185
+ ): value is TaskPropertyPopoverId {
186
+ return !!value && taskPropertyPopoverIds.has(value as TaskPropertyPopoverId);
187
+ }
188
+
189
+ function getTaskPropertyPopoverIdFromTarget(
190
+ target: EventTarget | null
191
+ ): TaskPropertyPopoverId | null {
192
+ if (typeof Element === 'undefined' || !(target instanceof Element)) {
193
+ return null;
194
+ }
195
+
196
+ const trigger = target.closest(
197
+ `[${TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE}]`
198
+ );
199
+ const popoverId =
200
+ trigger?.getAttribute(TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE) ?? null;
201
+
202
+ return isTaskPropertyPopoverId(popoverId) ? popoverId : null;
203
+ }
204
+
165
205
  function TaskPropertyPopoverTrigger({
166
206
  children,
167
207
  compact,
168
208
  label,
209
+ popoverId,
169
210
  }: {
170
211
  children: ReactNode;
171
212
  compact: boolean;
172
213
  label: ReactNode;
214
+ popoverId: TaskPropertyPopoverId;
173
215
  }) {
216
+ const trigger = isValidElement(children)
217
+ ? cloneElement(children as ReactElement<Record<string, unknown>>, {
218
+ [TASK_PROPERTY_POPOVER_TRIGGER_ATTRIBUTE]: popoverId,
219
+ })
220
+ : children;
221
+
174
222
  if (!compact) {
175
- return <PopoverTrigger asChild>{children}</PopoverTrigger>;
223
+ return <PopoverTrigger asChild>{trigger}</PopoverTrigger>;
176
224
  }
177
225
 
178
226
  return (
179
227
  <Tooltip>
180
228
  <TooltipTrigger asChild>
181
- <PopoverTrigger asChild>{children}</PopoverTrigger>
229
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
182
230
  </TooltipTrigger>
183
231
  <TooltipContent side="bottom">{label}</TooltipContent>
184
232
  </Tooltip>
185
233
  );
186
234
  }
187
235
 
236
+ type TaskPropertyPopoverContentProps = ComponentProps<typeof PopoverContent>;
237
+
188
238
  // Calendar hours type options
189
239
  const getCalendarHoursOptions = (t: any) => [
190
240
  {
@@ -422,16 +472,92 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
422
472
  [activePopover]
423
473
  );
424
474
 
475
+ const openPropertyPopover = useCallback(
476
+ (popoverId: TaskPropertyPopoverId) => {
477
+ setActivePopover(popoverId);
478
+ },
479
+ []
480
+ );
481
+
482
+ const closePropertyPopover = useCallback(
483
+ (popoverId: TaskPropertyPopoverId) => {
484
+ setActivePopover((currentPopover) =>
485
+ currentPopover === popoverId ? null : currentPopover
486
+ );
487
+ },
488
+ []
489
+ );
490
+
425
491
  const setPopoverOpen = useCallback(
426
492
  (popoverId: TaskPropertyPopoverId, open: boolean) => {
427
- setActivePopover((currentPopover) => {
428
- if (open) return popoverId;
429
- return currentPopover === popoverId ? null : currentPopover;
430
- });
493
+ if (open) {
494
+ openPropertyPopover(popoverId);
495
+ return;
496
+ }
497
+
498
+ closePropertyPopover(popoverId);
431
499
  },
432
- []
500
+ [closePropertyPopover, openPropertyPopover]
501
+ );
502
+
503
+ const handlePropertyPopoverCloseAutoFocus = useCallback<
504
+ NonNullable<TaskPropertyPopoverContentProps['onCloseAutoFocus']>
505
+ >((event) => {
506
+ event.preventDefault();
507
+ }, []);
508
+
509
+ const handlePropertyPopoverInteractOutside = useCallback<
510
+ NonNullable<TaskPropertyPopoverContentProps['onInteractOutside']>
511
+ >(
512
+ (event) => {
513
+ const targetPopoverId = getTaskPropertyPopoverIdFromTarget(event.target);
514
+
515
+ if (!targetPopoverId || targetPopoverId === activePopover) {
516
+ return;
517
+ }
518
+
519
+ event.preventDefault();
520
+
521
+ const openTargetPopover = () => {
522
+ openPropertyPopover(targetPopoverId);
523
+ };
524
+
525
+ if (typeof window !== 'undefined' && window.requestAnimationFrame) {
526
+ window.requestAnimationFrame(openTargetPopover);
527
+ return;
528
+ }
529
+
530
+ openTargetPopover();
531
+ },
532
+ [activePopover, openPropertyPopover]
433
533
  );
434
534
 
535
+ const propertyPopoverContentProps = useMemo(
536
+ () => ({
537
+ onCloseAutoFocus: handlePropertyPopoverCloseAutoFocus,
538
+ onInteractOutside: handlePropertyPopoverInteractOutside,
539
+ }),
540
+ [handlePropertyPopoverCloseAutoFocus, handlePropertyPopoverInteractOutside]
541
+ );
542
+
543
+ useEffect(() => {
544
+ if (activePopover !== 'labels') {
545
+ setLabelSearchQuery('');
546
+ }
547
+ }, [activePopover]);
548
+
549
+ useEffect(() => {
550
+ if (activePopover !== 'projects') {
551
+ setProjectSearchQuery('');
552
+ }
553
+ }, [activePopover]);
554
+
555
+ useEffect(() => {
556
+ if (activePopover !== 'assignees') {
557
+ setAssigneeSearchQuery('');
558
+ }
559
+ }, [activePopover]);
560
+
435
561
  const unselectedAvailableLabels = useMemo(
436
562
  () =>
437
563
  availableLabels.filter(
@@ -903,6 +1029,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
903
1029
  ? t(`tasks.priority_${priority}`)
904
1030
  : t('common.priority')
905
1031
  }
1032
+ popoverId="priority"
906
1033
  >
907
1034
  <button
908
1035
  type="button"
@@ -932,7 +1059,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
932
1059
  </span>
933
1060
  </button>
934
1061
  </TaskPropertyPopoverTrigger>
935
- <PopoverContent align="start" className="w-56 p-0">
1062
+ <PopoverContent
1063
+ align="start"
1064
+ className="w-56 p-0"
1065
+ {...propertyPopoverContentProps}
1066
+ >
936
1067
  <div className="p-1">
937
1068
  {[
938
1069
  {
@@ -1000,7 +1131,10 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1000
1131
  compact={isCompact}
1001
1132
  open={isPopoverOpen('list')}
1002
1133
  onOpenChange={(open) => setPopoverOpen('list', open)}
1134
+ onPopoverCloseAutoFocus={handlePropertyPopoverCloseAutoFocus}
1135
+ onPopoverInteractOutside={handlePropertyPopoverInteractOutside}
1003
1136
  onListChange={onListChange}
1137
+ propertyPopoverId="list"
1004
1138
  />
1005
1139
 
1006
1140
  {/* Dates Badge */}
@@ -1015,6 +1149,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1015
1149
  ? `${startDate ? new Date(startDate).toLocaleDateString(t('common.locale', { defaultValue: 'en-US' }), { month: 'short', day: 'numeric' }) : t('ws-task-boards.dialog.no_start_date')} → ${endDate ? new Date(endDate).toLocaleDateString(t('common.locale', { defaultValue: 'en-US' }), { month: 'short', day: 'numeric' }) : t('ws-task-boards.dialog.no_due_date')}`
1016
1150
  : t('ws-task-boards.dialog.dates')
1017
1151
  }
1152
+ popoverId="dates"
1018
1153
  >
1019
1154
  <button
1020
1155
  type="button"
@@ -1036,7 +1171,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1036
1171
  </span>
1037
1172
  </button>
1038
1173
  </TaskPropertyPopoverTrigger>
1039
- <PopoverContent align="start" className="w-80 p-0">
1174
+ <PopoverContent
1175
+ align="start"
1176
+ className="w-80 p-0"
1177
+ {...propertyPopoverContentProps}
1178
+ >
1040
1179
  <div className="rounded-lg p-3.5">
1041
1180
  <div className="space-y-3">
1042
1181
  {/* Start Date */}
@@ -1165,6 +1304,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1165
1304
  )
1166
1305
  : t('ws-task-boards.dialog.estimate')
1167
1306
  }
1307
+ popoverId="estimation"
1168
1308
  >
1169
1309
  <button
1170
1310
  type="button"
@@ -1191,7 +1331,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1191
1331
  </span>
1192
1332
  </button>
1193
1333
  </TaskPropertyPopoverTrigger>
1194
- <PopoverContent align="start" className="w-64 p-0">
1334
+ <PopoverContent
1335
+ align="start"
1336
+ className="w-64 p-0"
1337
+ {...propertyPopoverContentProps}
1338
+ >
1195
1339
  {!boardConfig?.estimation_type ? (
1196
1340
  <EmptyStateCard
1197
1341
  title={t('ws-task-boards.dialog.no_estimation_configured')}
@@ -1265,6 +1409,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1265
1409
  count: selectedLabels.length,
1266
1410
  })
1267
1411
  }
1412
+ popoverId="labels"
1268
1413
  >
1269
1414
  <button
1270
1415
  type="button"
@@ -1290,7 +1435,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1290
1435
  </span>
1291
1436
  </button>
1292
1437
  </TaskPropertyPopoverTrigger>
1293
- <PopoverContent align="start" className="w-72 p-0">
1438
+ <PopoverContent
1439
+ align="start"
1440
+ className="w-72 p-0"
1441
+ {...propertyPopoverContentProps}
1442
+ >
1294
1443
  {availableLabels.length === 0 ? (
1295
1444
  <EmptyStateCard
1296
1445
  title={t('ws-task-boards.dialog.no_labels_configured')}
@@ -1415,6 +1564,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1415
1564
  count: selectedProjects.length,
1416
1565
  })
1417
1566
  }
1567
+ popoverId="projects"
1418
1568
  >
1419
1569
  <button
1420
1570
  type="button"
@@ -1440,7 +1590,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1440
1590
  </span>
1441
1591
  </button>
1442
1592
  </TaskPropertyPopoverTrigger>
1443
- <PopoverContent align="start" className="w-72 p-0">
1593
+ <PopoverContent
1594
+ align="start"
1595
+ className="w-72 p-0"
1596
+ {...propertyPopoverContentProps}
1597
+ >
1444
1598
  {taskProjects.length === 0 ? (
1445
1599
  <EmptyStateCard
1446
1600
  title={t('ws-task-boards.dialog.no_projects_configured')}
@@ -1556,6 +1710,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1556
1710
  count: selectedAssignees.length,
1557
1711
  })
1558
1712
  }
1713
+ popoverId="assignees"
1559
1714
  >
1560
1715
  <button
1561
1716
  type="button"
@@ -1582,7 +1737,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1582
1737
  </span>
1583
1738
  </button>
1584
1739
  </TaskPropertyPopoverTrigger>
1585
- <PopoverContent align="start" className="w-72 p-0">
1740
+ <PopoverContent
1741
+ align="start"
1742
+ className="w-72 p-0"
1743
+ {...propertyPopoverContentProps}
1744
+ >
1586
1745
  {workspaceMembers.length === 0 ? (
1587
1746
  <div className="p-4 text-center text-muted-foreground text-sm">
1588
1747
  {t('ws-task-boards.dialog.no_members_found')}
@@ -1679,6 +1838,7 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1679
1838
  ? formatDuration(totalMinutes, t)
1680
1839
  : t('ws-task-boards.dialog.schedule')
1681
1840
  }
1841
+ popoverId="scheduling"
1682
1842
  >
1683
1843
  <button
1684
1844
  type="button"
@@ -1711,7 +1871,11 @@ export function TaskPropertiesSection(props: TaskPropertiesSectionProps) {
1711
1871
  )}
1712
1872
  </button>
1713
1873
  </TaskPropertyPopoverTrigger>
1714
- <PopoverContent align="start" className="w-72 p-0">
1874
+ <PopoverContent
1875
+ align="start"
1876
+ className="w-72 p-0"
1877
+ {...propertyPopoverContentProps}
1878
+ >
1715
1879
  <div className="rounded-lg p-3">
1716
1880
  <div className="space-y-3">
1717
1881
  {/* Duration */}
@@ -0,0 +1,52 @@
1
+ import '@testing-library/jest-dom';
2
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3
+ import { render, waitFor } from '@testing-library/react';
4
+ import type { ReactNode } from 'react';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { listWorkspaceTaskTemplates } from './task-template-api';
7
+ import { TaskTemplateClient } from './task-template-client';
8
+
9
+ vi.mock('next-intl', () => ({
10
+ useTranslations: () => (key: string, values?: Record<string, string>) =>
11
+ values?.count ? `${key}:${values.count}` : key,
12
+ }));
13
+
14
+ vi.mock('@tuturuuu/internal-api/tasks', () => ({
15
+ listWorkspaceTaskBoards: vi.fn(async () => ({ boards: [] })),
16
+ listWorkspaceTaskLists: vi.fn(async () => ({ lists: [] })),
17
+ }));
18
+
19
+ vi.mock('./task-template-api', () => ({
20
+ createWorkspaceTaskTemplate: vi.fn(),
21
+ deleteWorkspaceTaskTemplate: vi.fn(),
22
+ instantiateWorkspaceTaskTemplate: vi.fn(),
23
+ listWorkspaceTaskTemplates: vi.fn(async () => ({ templates: [] })),
24
+ saveWorkspaceTaskTemplateFromTask: vi.fn(),
25
+ }));
26
+
27
+ function renderWithQueryClient(ui: ReactNode) {
28
+ const queryClient = new QueryClient({
29
+ defaultOptions: {
30
+ queries: {
31
+ retry: false,
32
+ staleTime: 30_000,
33
+ },
34
+ },
35
+ });
36
+
37
+ return render(
38
+ <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
39
+ );
40
+ }
41
+
42
+ describe('TaskTemplateClient', () => {
43
+ it('fetches task templates even when the server shell starts empty', async () => {
44
+ renderWithQueryClient(
45
+ <TaskTemplateClient initialTemplates={[]} wsId="ws-1" />
46
+ );
47
+
48
+ await waitFor(() => {
49
+ expect(listWorkspaceTaskTemplates).toHaveBeenCalledWith('ws-1');
50
+ });
51
+ });
52
+ });
@@ -57,7 +57,7 @@ export function TaskTemplateClient({
57
57
  const payload = await listWorkspaceTaskTemplates(wsId);
58
58
  return payload.templates;
59
59
  },
60
- initialData: initialTemplates,
60
+ placeholderData: initialTemplates,
61
61
  });
62
62
 
63
63
  const { data: boards = [] } = useQuery({