@tuturuuu/ui 0.5.0 → 0.6.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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/package.json +41 -34
  3. package/src/components/ui/currency-input.tsx +65 -23
  4. package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +64 -0
  5. package/src/components/ui/custom/__tests__/sidebar-remote-behavior-bridge.test.tsx +109 -0
  6. package/src/components/ui/custom/combobox.test.tsx +141 -0
  7. package/src/components/ui/custom/combobox.tsx +105 -36
  8. package/src/components/ui/custom/settings/task-settings.tsx +50 -0
  9. package/src/components/ui/custom/settings/task-sound-settings.test.tsx +21 -1
  10. package/src/components/ui/custom/sidebar-context.tsx +68 -6
  11. package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +21 -2
  12. package/src/components/ui/finance/finance-layout.tsx +2 -4
  13. package/src/components/ui/finance/shared/balance-mode-toggle.tsx +35 -0
  14. package/src/components/ui/finance/shared/finance-layout-controls.tsx +43 -0
  15. package/src/components/ui/finance/shared/quick-actions.tsx +14 -6
  16. package/src/components/ui/finance/shared/use-finance-balance-mode.ts +72 -0
  17. package/src/components/ui/finance/shared/wallet-balance-mode.test.ts +66 -0
  18. package/src/components/ui/finance/shared/wallet-balance-mode.ts +42 -0
  19. package/src/components/ui/finance/transactions/form-types.ts +23 -0
  20. package/src/components/ui/finance/transactions/form.tsx +81 -22
  21. package/src/components/ui/finance/transactions/wallet-filter.tsx +21 -2
  22. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +73 -26
  23. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +617 -0
  24. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-panel.tsx +2 -1
  25. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-sections.tsx +4 -4
  26. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +298 -34
  27. package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +219 -46
  28. package/src/components/ui/finance/wallets/columns-rendering.test.tsx +125 -0
  29. package/src/components/ui/finance/wallets/columns.test.ts +56 -0
  30. package/src/components/ui/finance/wallets/columns.tsx +196 -43
  31. package/src/components/ui/finance/wallets/form.test.tsx +79 -14
  32. package/src/components/ui/finance/wallets/form.tsx +41 -197
  33. package/src/components/ui/finance/wallets/query-invalidation.ts +1 -0
  34. package/src/components/ui/finance/wallets/wallet-basics-fields.tsx +141 -0
  35. package/src/components/ui/finance/wallets/wallet-credit-fields.tsx +136 -0
  36. package/src/components/ui/finance/wallets/walletId/credit-wallet-summary.tsx +143 -68
  37. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +105 -0
  38. package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +120 -16
  39. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.test.tsx +64 -0
  40. package/src/components/ui/finance/wallets/walletId/wallet-details-amount.tsx +226 -6
  41. package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +64 -2
  42. package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +42 -35
  43. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +171 -0
  44. package/src/components/ui/finance/wallets/wallets-data-table.tsx +132 -29
  45. package/src/components/ui/finance/wallets/wallets-page.test.tsx +111 -37
  46. package/src/components/ui/finance/wallets/wallets-page.tsx +38 -78
  47. package/src/components/ui/storefront/accent-button.tsx +33 -0
  48. package/src/components/ui/storefront/cart-summary.tsx +140 -0
  49. package/src/components/ui/storefront/empty-listings.tsx +32 -0
  50. package/src/components/ui/storefront/hero-panel.tsx +70 -0
  51. package/src/components/ui/storefront/image-panel.tsx +40 -0
  52. package/src/components/ui/storefront/index.ts +12 -0
  53. package/src/components/ui/storefront/listing-card.tsx +129 -0
  54. package/src/components/ui/storefront/storefront-surface.test.tsx +85 -0
  55. package/src/components/ui/storefront/storefront-surface.tsx +235 -0
  56. package/src/components/ui/storefront/types.ts +99 -0
  57. package/src/components/ui/storefront/utils.ts +90 -0
  58. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +134 -0
  59. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +127 -0
  60. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +17 -42
  61. package/src/components/ui/tu-do/boards/boardId/timeline-board-open-task.test.tsx +164 -0
  62. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +25 -16
  63. package/src/components/ui/tu-do/hooks/useTaskDialog.ts +15 -1
  64. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +2 -0
  65. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +114 -7
  66. package/src/components/ui/tu-do/providers/__tests__/task-dialog-provider.test.tsx +217 -5
  67. package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +180 -35
  68. package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +222 -26
  69. package/src/components/ui/tu-do/shared/board-client.tsx +1 -3
  70. package/src/components/ui/tu-do/shared/list-view-context-menu.test.tsx +55 -2
  71. package/src/components/ui/tu-do/shared/list-view.tsx +23 -16
  72. package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +93 -76
  73. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +11 -0
  74. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +128 -1
  75. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +104 -69
  76. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.test.tsx +129 -0
  77. package/src/components/ui/tu-do/shared/task-edit-dialog/components/smart-task-suggestions-panel.tsx +358 -0
  78. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +1 -1
  79. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +6 -2
  80. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.test.tsx +17 -1
  81. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-name-input.tsx +151 -111
  82. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-form-reset.ts +18 -2
  83. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-realtime-sync.ts +1 -2
  84. package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +5 -3
  85. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +584 -53
  86. package/src/hooks/useBoardRealtime.ts +54 -1
  87. package/src/hooks/useBoardRealtimeEventHandler.ts +169 -4
  88. package/src/hooks/useTaskUserRealtime.ts +338 -0
@@ -3,6 +3,7 @@
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import { getCurrentUserProfile } from '@tuturuuu/internal-api';
5
5
  import { getWorkspaceTask } from '@tuturuuu/internal-api/tasks';
6
+ import { getUserConfig } from '@tuturuuu/internal-api/users';
6
7
  import type { Task } from '@tuturuuu/types/primitives/Task';
7
8
  import { useSearchParams } from 'next/navigation';
8
9
  import { useCallback, useEffect, useRef, useState } from 'react';
@@ -12,6 +13,10 @@ import {
12
13
  WorkspacePresenceProvider,
13
14
  } from '../providers/workspace-presence-provider';
14
15
  import { dispatchRecentSidebarVisit } from './recent-sidebar-events';
16
+ import {
17
+ normalizeTaskDialogPresentation,
18
+ TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID,
19
+ } from './task-dialog-presentation';
15
20
  import { TaskEditDialog } from './task-edit-dialog';
16
21
  import {
17
22
  dispatchTaskOpenResult,
@@ -146,19 +151,23 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
146
151
  // Read draft mode preference from user config (same query key as useUserBooleanConfig)
147
152
  const { data: draftModeRaw } = useQuery({
148
153
  queryKey: ['user-config', 'TASK_DRAFT_MODE_ENABLED'],
149
- queryFn: async () => {
150
- const res = await fetch(
151
- '/api/v1/users/me/configs/TASK_DRAFT_MODE_ENABLED',
152
- { cache: 'no-store' }
153
- );
154
- if (!res.ok) return 'false';
155
- const data = await res.json();
156
- return (data.value as string) ?? 'false';
157
- },
154
+ queryFn: async () =>
155
+ (await getUserConfig('TASK_DRAFT_MODE_ENABLED')).value ?? 'false',
158
156
  staleTime: 5 * 60 * 1000,
159
157
  });
160
158
  const draftModeEnabled = draftModeRaw === 'true';
161
159
 
160
+ const { data: defaultPresentationRaw } = useQuery({
161
+ queryKey: ['user-config', TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID],
162
+ queryFn: async () =>
163
+ (await getUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID)).value ??
164
+ 'compact',
165
+ staleTime: 5 * 60 * 1000,
166
+ });
167
+ const defaultPresentation = normalizeTaskDialogPresentation(
168
+ defaultPresentationRaw
169
+ );
170
+
162
171
  const handleClose = () => {
163
172
  triggerClose();
164
173
  };
@@ -226,30 +235,12 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
226
235
 
227
236
  const openTaskFromCurrentWorkspace = useCallback(
228
237
  async (taskId: string) => {
229
- try {
230
- const { task } = await getWorkspaceTask(wsId, taskId, {
231
- fetch: (input, init) =>
232
- fetch(new URL(String(input), window.location.origin).toString(), {
233
- ...init,
234
- cache: 'no-store',
235
- }),
236
- });
237
-
238
- const boardId = task.board_id;
239
- if (!boardId) {
240
- return false;
241
- }
242
-
243
- openTask(task as Task, boardId, undefined, false, {
244
- taskWsId: wsId,
245
- taskWorkspacePersonal: isPersonalWorkspace,
246
- });
247
- return true;
248
- } catch {
249
- return false;
250
- }
238
+ return openTaskById(taskId, {
239
+ taskWsId: wsId,
240
+ taskWorkspacePersonal: isPersonalWorkspace,
241
+ });
251
242
  },
252
- [isPersonalWorkspace, openTask, wsId]
243
+ [isPersonalWorkspace, openTaskById, wsId]
253
244
  );
254
245
 
255
246
  useEffect(() => {
@@ -269,40 +260,12 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
269
260
  };
270
261
 
271
262
  void (async () => {
272
- if (requestedWsId) {
273
- try {
274
- const { task } = await getWorkspaceTask(requestedWsId, taskId, {
275
- fetch: (input, init) =>
276
- fetch(
277
- new URL(String(input), window.location.origin).toString(),
278
- {
279
- ...init,
280
- cache: 'no-store',
281
- }
282
- ),
283
- });
284
-
285
- const taskWithList = task as {
286
- board_id?: string | null;
287
- list?: {
288
- board_id?: string | null;
289
- } | null;
290
- };
291
- const boardId =
292
- taskWithList.board_id || taskWithList.list?.board_id;
293
- if (boardId) {
294
- openTask(task as Task, boardId, undefined, false, {
295
- taskWsId: requestedWsId,
296
- });
297
- emitOpenResult(true);
298
- return;
299
- }
300
- } catch {
301
- // Fall through to the generic current-user lookup below.
302
- }
303
- }
304
-
305
- const opened = await openTaskById(taskId);
263
+ const opened = await openTaskById(taskId, {
264
+ taskWsId: requestedWsId,
265
+ taskWorkspacePersonal: requestedWsId
266
+ ? undefined
267
+ : isPersonalWorkspace,
268
+ });
306
269
  emitOpenResult(opened);
307
270
  })();
308
271
  };
@@ -318,7 +281,7 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
318
281
  handleTaskOpenRequest as EventListener
319
282
  );
320
283
  };
321
- }, [openTaskById, openTask]);
284
+ }, [isPersonalWorkspace, openTaskById]);
322
285
 
323
286
  useEffect(() => {
324
287
  const canonicalTaskId = searchParams.get('task');
@@ -348,7 +311,10 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
348
311
  return;
349
312
  }
350
313
 
351
- void openTaskById(legacyTaskId);
314
+ void openTaskById(legacyTaskId, {
315
+ taskWsId: wsId,
316
+ taskWorkspacePersonal: isPersonalWorkspace,
317
+ });
352
318
  const nextSearchParams = new URLSearchParams(searchParams.toString());
353
319
  nextSearchParams.delete('openTaskId');
354
320
 
@@ -358,7 +324,7 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
358
324
  : window.location.pathname;
359
325
 
360
326
  window.history.replaceState(window.history.state, '', nextUrl);
361
- }, [openTaskById, searchParams]);
327
+ }, [isPersonalWorkspace, openTaskById, searchParams, wsId]);
362
328
 
363
329
  // Open subtask creation dialog for the current task
364
330
  const handleAddSubtask = useCallback(() => {
@@ -411,6 +377,31 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
411
377
  const handleAddBlockedByTask = () => handleAddRelationship('blocked-by');
412
378
  const handleAddRelatedTask = () => handleAddRelationship('related');
413
379
 
380
+ const handleRetryTaskLoad = useCallback(() => {
381
+ if (!state.task?.id) return;
382
+
383
+ void openTaskById(state.task.id, {
384
+ initialTask: state.task,
385
+ boardId: state.boardId,
386
+ availableLists: state.availableLists,
387
+ fakeTaskUrl: state.fakeTaskUrl,
388
+ taskWsId: state.taskWsId,
389
+ taskWorkspacePersonal: state.taskWorkspacePersonal,
390
+ taskWorkspaceTier: state.taskWorkspaceTier,
391
+ initialSharedContext: state.initialSharedContext,
392
+ });
393
+ }, [
394
+ openTaskById,
395
+ state.initialSharedContext,
396
+ state.availableLists,
397
+ state.boardId,
398
+ state.fakeTaskUrl,
399
+ state.task,
400
+ state.taskWorkspacePersonal,
401
+ state.taskWorkspaceTier,
402
+ state.taskWsId,
403
+ ]);
404
+
414
405
  // Track presence location when the dialog is open in edit mode.
415
406
  // On kanban boards, BoardUserPresenceAvatarsComponent also calls updateLocation
416
407
  // with the same args — this is idempotent (same location = no-op).
@@ -419,7 +410,14 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
419
410
  const wsUpdateLocation = wsPresence?.updateLocation;
420
411
 
421
412
  useEffect(() => {
422
- if (!wsUpdateLocation || !state.isOpen || state.mode === 'create') return;
413
+ if (
414
+ !wsUpdateLocation ||
415
+ !state.isOpen ||
416
+ state.mode === 'create' ||
417
+ state.isHydratingTask ||
418
+ state.taskLoadError
419
+ )
420
+ return;
423
421
  const taskId = state.task?.id;
424
422
  const boardId = state.boardId;
425
423
  if (!taskId || !boardId) return;
@@ -433,6 +431,8 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
433
431
  wsUpdateLocation,
434
432
  state.isOpen,
435
433
  state.mode,
434
+ state.isHydratingTask,
435
+ state.taskLoadError,
436
436
  state.task?.id,
437
437
  state.boardId,
438
438
  ]);
@@ -441,7 +441,9 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
441
441
  if (
442
442
  typeof window === 'undefined' ||
443
443
  !state.isOpen ||
444
- state.mode === 'create'
444
+ state.mode === 'create' ||
445
+ state.isHydratingTask ||
446
+ state.taskLoadError
445
447
  ) {
446
448
  return;
447
449
  }
@@ -493,19 +495,24 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
493
495
  }, [
494
496
  state.boardId,
495
497
  state.isOpen,
498
+ state.isHydratingTask,
496
499
  state.mode,
497
500
  state.task,
498
501
  state.taskWsId,
499
502
  state.taskWorkspacePersonal,
503
+ state.taskLoadError,
500
504
  isPersonalWorkspace,
501
505
  wsId,
502
506
  ]);
503
507
 
504
- // Determine if the task needs its own presence provider (cross-workspace tasks)
508
+ // Determine if the task needs its own presence provider (cross-workspace tasks).
509
+ // Keep the provider shell mounted from the initial snapshot when taskWsId is
510
+ // already known, otherwise hydration can wrap the open dialog in a new
511
+ // provider and Radix replays the compact dialog entrance animation.
505
512
  const needsOwnProvider =
506
- state.realtimeEnabled &&
507
- state.taskWsId &&
508
- (!wsPresence?.realtimeEnabled || state.taskWsId !== wsId);
513
+ state.taskWsId && (!wsPresence?.realtimeEnabled || state.taskWsId !== wsId);
514
+ const ownProviderEnabled =
515
+ !!state.realtimeEnabled && !state.taskWorkspacePersonal;
509
516
 
510
517
  if (!state.isOpen || !state.task) {
511
518
  return null;
@@ -519,16 +526,25 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
519
526
  boardId={state.boardId || ''}
520
527
  isOpen={state.isOpen}
521
528
  availableLists={state.availableLists}
529
+ sharedContext={
530
+ state.isHydratingTask || state.taskLoadError
531
+ ? state.initialSharedContext
532
+ : undefined
533
+ }
522
534
  filters={state.filters}
523
535
  mode={state.mode}
524
536
  collaborationMode={state.collaborationMode}
525
537
  realtimeEnabled={state.realtimeEnabled}
538
+ isHydratingTask={state.isHydratingTask}
539
+ taskLoadError={state.taskLoadError}
540
+ taskHydrationVersion={state.taskHydrationVersion}
526
541
  isPersonalWorkspace={isPersonalWorkspace}
527
542
  parentTaskId={state.parentTaskId}
528
543
  parentTaskName={state.parentTaskName}
529
544
  pendingRelationship={state.pendingRelationship}
530
545
  currentUser={currentUser || undefined}
531
546
  draftModeEnabled={draftModeEnabled}
547
+ defaultPresentation={defaultPresentation}
532
548
  draftId={state.draftId}
533
549
  onClose={handleClose}
534
550
  onUpdate={triggerUpdate}
@@ -538,6 +554,7 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
538
554
  onAddBlockingTask={handleAddBlockingTask}
539
555
  onAddBlockedByTask={handleAddBlockedByTask}
540
556
  onAddRelatedTask={handleAddRelatedTask}
557
+ onRetryTaskLoad={handleRetryTaskLoad}
541
558
  />
542
559
  );
543
560
 
@@ -546,7 +563,7 @@ export function TaskDialogManager({ wsId }: { wsId: string }) {
546
563
  <WorkspacePresenceProvider
547
564
  wsId={state.taskWsId}
548
565
  tier={state.taskWorkspaceTier ?? null}
549
- enabled={!state.taskWorkspacePersonal}
566
+ enabled={ownProviderEnabled}
550
567
  >
551
568
  {dialog}
552
569
  </WorkspacePresenceProvider>
@@ -0,0 +1,11 @@
1
+ export const TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID =
2
+ 'TASK_DIALOG_DEFAULT_PRESENTATION';
3
+
4
+ export type TaskDialogPresentation = 'compact' | 'fullscreen';
5
+
6
+ export function normalizeTaskDialogPresentation(
7
+ value: unknown,
8
+ fallback: TaskDialogPresentation = 'compact'
9
+ ): TaskDialogPresentation {
10
+ return value === 'fullscreen' || value === 'compact' ? value : fallback;
11
+ }
@@ -64,7 +64,7 @@ describe('CompactTaskCreatePopover', () => {
64
64
  it('renders compact create content with accessible icon actions', () => {
65
65
  renderCompactTaskCreatePopover();
66
66
 
67
- expect(screen.getByTestId('compact-task-create-popover')).toBeTruthy();
67
+ expect(screen.getByTestId('compact-task-dialog-panel')).toBeTruthy();
68
68
  expect(screen.getByText('Create task')).toBeTruthy();
69
69
  expect(screen.getByLabelText('Task title')).toHaveProperty(
70
70
  'value',
@@ -138,4 +138,131 @@ describe('CompactTaskCreatePopover', () => {
138
138
  screen.getAllByRole('button', { name: 'task-drafts.save_as_draft' })
139
139
  ).toHaveLength(2);
140
140
  });
141
+
142
+ it('renders compact edit content without create-only footer actions', () => {
143
+ render(
144
+ <Dialog open={true}>
145
+ <CompactTaskCreatePopover
146
+ title="Edit task"
147
+ titleInput={<input aria-label="Task title" defaultValue="Existing" />}
148
+ propertyControls={
149
+ <button type="button" aria-label="List: Inbox">
150
+ List
151
+ </button>
152
+ }
153
+ smartAction={
154
+ <button type="button" aria-label="Smart action">
155
+ Smart
156
+ </button>
157
+ }
158
+ onClose={vi.fn()}
159
+ onFullscreen={vi.fn()}
160
+ />
161
+ </Dialog>
162
+ );
163
+
164
+ expect(screen.getByText('Edit task')).toBeTruthy();
165
+ expect(screen.getByLabelText('Task title')).toHaveProperty(
166
+ 'value',
167
+ 'Existing'
168
+ );
169
+ expect(screen.getByLabelText('List: Inbox')).toBeTruthy();
170
+ expect(screen.getByLabelText('Smart action')).toBeTruthy();
171
+ expect(
172
+ screen.queryByLabelText('task-drafts.save_as_draft')
173
+ ).not.toBeInTheDocument();
174
+ expect(
175
+ screen.queryByRole('button', {
176
+ name: 'ws-task-boards.dialog.create_task',
177
+ })
178
+ ).not.toBeInTheDocument();
179
+ });
180
+
181
+ it('renders compact edit actions when provided', () => {
182
+ const onDelete = vi.fn();
183
+ const onDone = vi.fn();
184
+ const onClosed = vi.fn();
185
+
186
+ render(
187
+ <Dialog open={true}>
188
+ <CompactTaskCreatePopover
189
+ title="Edit task"
190
+ titleInput={<input aria-label="Task title" defaultValue="Existing" />}
191
+ propertyControls={
192
+ <button type="button" aria-label="List: Inbox">
193
+ List
194
+ </button>
195
+ }
196
+ editActions={
197
+ <>
198
+ <button
199
+ type="button"
200
+ aria-label="common.mark_as_done"
201
+ onClick={onDone}
202
+ >
203
+ Done
204
+ </button>
205
+ <button
206
+ type="button"
207
+ aria-label="common.archive"
208
+ onClick={onClosed}
209
+ >
210
+ Archive
211
+ </button>
212
+ <button
213
+ type="button"
214
+ aria-label="common.delete_task"
215
+ onClick={onDelete}
216
+ >
217
+ Delete
218
+ </button>
219
+ </>
220
+ }
221
+ onClose={vi.fn()}
222
+ onFullscreen={vi.fn()}
223
+ />
224
+ </Dialog>
225
+ );
226
+
227
+ fireEvent.click(screen.getByLabelText('common.mark_as_done'));
228
+ fireEvent.click(screen.getByLabelText('common.archive'));
229
+ fireEvent.click(screen.getByLabelText('common.delete_task'));
230
+
231
+ expect(
232
+ screen.getByLabelText('common.delete_task').closest('.border-b')
233
+ ).toBeTruthy();
234
+ expect(
235
+ screen.getByLabelText('common.delete_task').closest('.border-t')
236
+ ).toBeNull();
237
+ expect(onDone).toHaveBeenCalledTimes(1);
238
+ expect(onClosed).toHaveBeenCalledTimes(1);
239
+ expect(onDelete).toHaveBeenCalledTimes(1);
240
+ });
241
+
242
+ it('keeps the compact edit title accessible but not visibly rendered', () => {
243
+ render(
244
+ <Dialog open={true}>
245
+ <CompactTaskCreatePopover
246
+ title="Edit task"
247
+ showHeaderTitle={false}
248
+ titleInput={<input aria-label="Task title" defaultValue="Existing" />}
249
+ propertyControls={
250
+ <button type="button" aria-label="List: Inbox">
251
+ List
252
+ </button>
253
+ }
254
+ editActions={
255
+ <button type="button" aria-label="common.delete_task">
256
+ Delete
257
+ </button>
258
+ }
259
+ onClose={vi.fn()}
260
+ onFullscreen={vi.fn()}
261
+ />
262
+ </Dialog>
263
+ );
264
+
265
+ expect(screen.getByText('Edit task')).toHaveClass('sr-only');
266
+ expect(screen.getByLabelText('common.delete_task')).toBeTruthy();
267
+ });
141
268
  });
@@ -17,24 +17,29 @@ import { useTranslations } from 'next-intl';
17
17
  import type { ReactNode } from 'react';
18
18
  import { QuickSettingsPopover } from './quick-settings-popover';
19
19
 
20
- interface CompactTaskCreatePopoverProps {
20
+ interface CompactTaskDialogPanelProps {
21
21
  title: string;
22
22
  description?: ReactNode;
23
23
  icon?: ReactNode;
24
24
  iconBgClass?: string;
25
25
  iconRingClass?: string;
26
26
  titleInput: ReactNode;
27
+ showHeaderTitle?: boolean;
28
+ taskStatus?: ReactNode;
27
29
  propertyControls: ReactNode;
28
- saveAsDraft: boolean;
29
- createMultiple: boolean;
30
- canSave: boolean;
31
- isLoading: boolean;
30
+ editActions?: ReactNode;
31
+ smartAction?: ReactNode;
32
+ smartPanel?: ReactNode;
33
+ saveAsDraft?: boolean;
34
+ createMultiple?: boolean;
35
+ canSave?: boolean;
36
+ isLoading?: boolean;
32
37
  isPersonalWorkspace?: boolean;
33
- onSaveAsDraftChange: (value: boolean) => void;
34
- onCreateMultipleChange: (value: boolean) => void;
38
+ onSaveAsDraftChange?: (value: boolean) => void;
39
+ onCreateMultipleChange?: (value: boolean) => void;
35
40
  onClose: () => void;
36
41
  onFullscreen: () => void;
37
- onSave: () => void;
42
+ onSave?: () => void;
38
43
  }
39
44
 
40
45
  function CompactIconButton({
@@ -71,58 +76,82 @@ function CompactIconButton({
71
76
  );
72
77
  }
73
78
 
74
- export function CompactTaskCreatePopover({
79
+ export function CompactTaskDialogPanel({
75
80
  title,
76
81
  description,
77
82
  icon,
78
83
  iconBgClass = 'bg-dynamic-orange/10',
79
84
  iconRingClass = 'ring-dynamic-orange/20',
80
85
  titleInput,
86
+ showHeaderTitle = true,
87
+ taskStatus,
81
88
  propertyControls,
89
+ editActions,
90
+ smartAction,
91
+ smartPanel,
82
92
  saveAsDraft,
83
93
  createMultiple,
84
94
  canSave,
85
- isLoading,
95
+ isLoading = false,
86
96
  isPersonalWorkspace,
87
97
  onSaveAsDraftChange,
88
98
  onCreateMultipleChange,
89
99
  onClose,
90
100
  onFullscreen,
91
101
  onSave,
92
- }: CompactTaskCreatePopoverProps) {
102
+ }: CompactTaskDialogPanelProps) {
93
103
  const t = useTranslations();
104
+ const hasCreateActions =
105
+ typeof saveAsDraft === 'boolean' &&
106
+ typeof createMultiple === 'boolean' &&
107
+ typeof canSave === 'boolean' &&
108
+ !!onSave &&
109
+ !!onSaveAsDraftChange &&
110
+ !!onCreateMultipleChange;
94
111
  const saveLabel = saveAsDraft
95
112
  ? t('task-drafts.save_as_draft')
96
113
  : t('ws-task-boards.dialog.create_task');
114
+ const hasHeaderTitle = showHeaderTitle;
97
115
 
98
116
  return (
99
117
  <div
100
- data-testid="compact-task-create-popover"
118
+ data-testid="compact-task-dialog-panel"
101
119
  className="flex max-h-[calc(100vh-2rem)] min-h-0 flex-col overflow-hidden rounded-lg bg-background"
102
120
  >
103
- <div className="flex items-start justify-between gap-3 border-b px-4 py-3">
104
- <div className="flex min-w-0 items-start gap-2.5">
105
- <div
106
- className={cn(
107
- 'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
108
- iconBgClass,
109
- iconRingClass
110
- )}
111
- >
112
- {icon ?? <ListTodo className="h-4 w-4 text-dynamic-orange" />}
113
- </div>
114
- <div className="min-w-0 space-y-0.5">
115
- <DialogTitle className="truncate font-semibold text-base">
116
- {title}
117
- </DialogTitle>
118
- {description && (
119
- <DialogDescription className="truncate text-muted-foreground text-xs">
120
- {description}
121
- </DialogDescription>
122
- )}
121
+ <div
122
+ className={cn(
123
+ 'flex items-start gap-3 border-b px-4 py-3',
124
+ hasHeaderTitle ? 'justify-between' : 'justify-end'
125
+ )}
126
+ >
127
+ {hasHeaderTitle ? (
128
+ <div className="flex min-w-0 items-start gap-2.5">
129
+ <div
130
+ className={cn(
131
+ 'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
132
+ iconBgClass,
133
+ iconRingClass
134
+ )}
135
+ >
136
+ {icon ?? <ListTodo className="h-4 w-4 text-dynamic-orange" />}
137
+ </div>
138
+ <div className="min-w-0 space-y-0.5">
139
+ <DialogTitle className="truncate font-semibold text-base">
140
+ {title}
141
+ </DialogTitle>
142
+ {description && (
143
+ <DialogDescription className="truncate text-muted-foreground text-xs">
144
+ {description}
145
+ </DialogDescription>
146
+ )}
147
+ </div>
123
148
  </div>
124
- </div>
149
+ ) : (
150
+ <DialogTitle className="sr-only">{title}</DialogTitle>
151
+ )}
125
152
  <div className="flex shrink-0 items-center gap-1">
153
+ {smartAction}
154
+ {editActions}
126
155
  <Tooltip>
127
156
  <TooltipTrigger asChild>
128
157
  <Button
@@ -160,49 +189,55 @@ export function CompactTaskCreatePopover({
160
189
 
161
190
  <div className="min-h-0 space-y-3 overflow-y-auto px-4 py-3">
162
191
  {titleInput}
192
+ {taskStatus}
163
193
  <div className="flex flex-wrap items-center gap-1.5">
164
194
  {propertyControls}
165
195
  </div>
196
+ {smartPanel}
166
197
  </div>
167
198
 
168
- <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-4 py-3">
169
- <div className="flex items-center gap-1">
170
- <CompactIconButton
171
- active={saveAsDraft}
172
- label={t('task-drafts.save_as_draft')}
173
- onClick={() => onSaveAsDraftChange(!saveAsDraft)}
174
- >
175
- <FileEdit className="h-4 w-4" />
176
- </CompactIconButton>
177
- <CompactIconButton
178
- active={createMultiple}
179
- label={t('ws-task-boards.dialog.create_multiple')}
180
- onClick={() => onCreateMultipleChange(!createMultiple)}
199
+ {hasCreateActions && (
200
+ <div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-4 py-3">
201
+ <div className="flex items-center gap-1">
202
+ <CompactIconButton
203
+ active={!!saveAsDraft}
204
+ label={t('task-drafts.save_as_draft')}
205
+ onClick={() => onSaveAsDraftChange?.(!saveAsDraft)}
206
+ >
207
+ <FileEdit className="h-4 w-4" />
208
+ </CompactIconButton>
209
+ <CompactIconButton
210
+ active={!!createMultiple}
211
+ label={t('ws-task-boards.dialog.create_multiple')}
212
+ onClick={() => onCreateMultipleChange?.(!createMultiple)}
213
+ >
214
+ <Copy className="h-4 w-4" />
215
+ </CompactIconButton>
216
+ <QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
217
+ </div>
218
+ <Button
219
+ type="button"
220
+ size="sm"
221
+ disabled={!canSave}
222
+ onClick={() => onSave?.()}
223
+ className="min-w-28"
181
224
  >
182
- <Copy className="h-4 w-4" />
183
- </CompactIconButton>
184
- <QuickSettingsPopover isPersonalWorkspace={isPersonalWorkspace} />
225
+ {isLoading ? (
226
+ <>
227
+ <Loader2 className="h-4 w-4 animate-spin" />
228
+ {t('ws-task-boards.dialog.saving')}
229
+ </>
230
+ ) : (
231
+ <>
232
+ <Check className="h-4 w-4" />
233
+ {saveLabel}
234
+ </>
235
+ )}
236
+ </Button>
185
237
  </div>
186
- <Button
187
- type="button"
188
- size="sm"
189
- disabled={!canSave}
190
- onClick={onSave}
191
- className="min-w-28"
192
- >
193
- {isLoading ? (
194
- <>
195
- <Loader2 className="h-4 w-4 animate-spin" />
196
- {t('ws-task-boards.dialog.saving')}
197
- </>
198
- ) : (
199
- <>
200
- <Check className="h-4 w-4" />
201
- {saveLabel}
202
- </>
203
- )}
204
- </Button>
205
- </div>
238
+ )}
206
239
  </div>
207
240
  );
208
241
  }
242
+
243
+ export const CompactTaskCreatePopover = CompactTaskDialogPanel;