@tuturuuu/ui 0.12.0 → 0.14.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 (51) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +4 -4
  3. package/src/components/ui/calendar-app/components/habits-panel.tsx +5 -2
  4. package/src/components/ui/calendar-app/components/priority-view.tsx +15 -7
  5. package/src/components/ui/calendar-app/components/scheduling-dialog.tsx +7 -3
  6. package/src/components/ui/calendar-app/components/task-scheduler-panel.tsx +7 -3
  7. package/src/components/ui/calendar-app/components/time-tracker/index.tsx +14 -9
  8. package/src/components/ui/custom/settings/task-settings.tsx +61 -2
  9. package/src/components/ui/custom/tables/data-table.tsx +58 -8
  10. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
  11. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
  12. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
  13. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -1
  14. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +21 -10
  15. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
  16. package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
  17. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +11 -1
  18. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -3
  19. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.test.ts +113 -0
  20. package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.ts +50 -0
  21. package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +65 -18
  22. package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +1 -1
  23. package/src/components/ui/tu-do/cycles/task-cycles-client.tsx +23 -14
  24. package/src/components/ui/tu-do/estimates/use-task-estimates.ts +9 -4
  25. package/src/components/ui/tu-do/habits/client.tsx +4 -1
  26. package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +6 -3
  27. package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +19 -11
  28. package/src/components/ui/tu-do/my-tasks/use-my-tasks-query.ts +15 -6
  29. package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +18 -13
  30. package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +17 -10
  31. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
  32. package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
  33. package/src/components/ui/tu-do/shared/fade-setting-initializer.tsx +3 -1
  34. package/src/components/ui/tu-do/shared/list-view.tsx +1 -1
  35. package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +6 -2
  36. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-update-shared-task.test.ts +9 -5
  37. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +13 -8
  38. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-overrides.ts +21 -9
  39. package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-update-shared-task.ts +10 -5
  40. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +4 -2
  41. package/src/components/ui/tu-do/shared/task-estimation-picker.tsx +5 -2
  42. package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
  43. package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +1 -1
  44. package/src/hooks/__tests__/use-task-actions.test.tsx +51 -0
  45. package/src/hooks/task-actions-personal-external.ts +2 -3
  46. package/src/hooks/use-board-actions.ts +27 -17
  47. package/src/hooks/use-calendar.tsx +7 -3
  48. package/src/hooks/use-settings-dialog-shortcut.ts +51 -0
  49. package/src/hooks/use-user-config.ts +10 -2
  50. package/src/lib/task-personal-external.ts +49 -0
  51. package/src/lib/tasks-app-url.ts +85 -0
@@ -17,7 +17,9 @@ import { getBoardConfigKey } from '../board-config-storage';
17
17
  import { BoardViews } from '../board-views';
18
18
 
19
19
  const listWorkspaceTasksMock = vi.hoisted(() => vi.fn());
20
+ const getUserConfigMock = vi.hoisted(() => vi.fn());
20
21
  const getUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
22
+ const updateUserConfigMock = vi.hoisted(() => vi.fn());
21
23
  const updateUserWorkspaceConfigMock = vi.hoisted(() => vi.fn());
22
24
  const createTaskMock = vi.fn();
23
25
  const loadListPageMock = vi.fn();
@@ -64,8 +66,11 @@ vi.mock('@tuturuuu/internal-api/tasks', () => ({
64
66
  vi.mock('@tuturuuu/internal-api/users', () => ({
65
67
  TASK_BOARD_PINNED_SPECIAL_LISTS_CONFIG_ID: 'TASK_BOARD_PINNED_SPECIAL_LISTS',
66
68
  TASK_LAST_BOARD_VIEW_CONFIG_ID: 'TASK_LAST_BOARD_VIEW',
69
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID: 'TASK_QUICK_CREATE_TARGET_LIST',
70
+ getUserConfig: (...args: unknown[]) => getUserConfigMock(...args),
67
71
  getUserWorkspaceConfig: (...args: unknown[]) =>
68
72
  getUserWorkspaceConfigMock(...args),
73
+ updateUserConfig: (...args: unknown[]) => updateUserConfigMock(...args),
69
74
  updateUserWorkspaceConfig: (...args: unknown[]) =>
70
75
  updateUserWorkspaceConfigMock(...args),
71
76
  }));
@@ -260,8 +265,12 @@ describe('BoardViews', () => {
260
265
  progressivePagination = {};
261
266
  listWorkspaceTasksMock.mockReset();
262
267
  listWorkspaceTasksMock.mockResolvedValue({ tasks: mockTasks });
268
+ getUserConfigMock.mockReset();
269
+ getUserConfigMock.mockResolvedValue({ value: null });
263
270
  getUserWorkspaceConfigMock.mockReset();
264
271
  getUserWorkspaceConfigMock.mockResolvedValue({ value: null });
272
+ updateUserConfigMock.mockReset();
273
+ updateUserConfigMock.mockResolvedValue({ message: 'ok' });
265
274
  updateUserWorkspaceConfigMock.mockReset();
266
275
  updateUserWorkspaceConfigMock.mockResolvedValue({ message: 'ok' });
267
276
  window.localStorage.clear();
@@ -312,6 +321,7 @@ describe('BoardViews', () => {
312
321
  expect(boardHeaderProps?.titlePrefix).toBeDefined();
313
322
  expect(kanbanBoardProps?.readOnly).toBe(true);
314
323
  expect(listWorkspaceTasksMock).not.toHaveBeenCalled();
324
+ expect(getUserConfigMock).not.toHaveBeenCalled();
315
325
  });
316
326
 
317
327
  it('enables assignees for personal boards that have guest access', async () => {
@@ -544,6 +554,66 @@ describe('BoardViews', () => {
544
554
  );
545
555
  });
546
556
 
557
+ it('creates a task in the hovered list when the quick-create target prefers hovered lists', async () => {
558
+ getUserConfigMock.mockResolvedValue({ value: 'hovered_list' });
559
+ const { queryClient } = renderBoardViews({
560
+ board: { ...mockBoard, default_list_id: 'list-1' },
561
+ });
562
+
563
+ await waitFor(() => {
564
+ expect(
565
+ queryClient.getQueryData([
566
+ 'user-config',
567
+ 'TASK_QUICK_CREATE_TARGET_LIST',
568
+ ])
569
+ ).toBe('hovered_list');
570
+ });
571
+
572
+ await act(async () => {
573
+ kanbanBoardProps?.onHoveredTaskListChange?.('list-2');
574
+ });
575
+
576
+ fireEvent.keyDown(document, { key: 'c' });
577
+
578
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
579
+ expect(createTaskMock).toHaveBeenCalledWith(
580
+ 'board-1',
581
+ 'list-2',
582
+ mockLists,
583
+ expect.objectContaining({ labels: [] })
584
+ );
585
+ });
586
+
587
+ it('falls back to the board default list in hovered-list mode when no hovered list is selectable', async () => {
588
+ getUserConfigMock.mockResolvedValue({ value: 'hovered_list' });
589
+ const { queryClient } = renderBoardViews({
590
+ board: { ...mockBoard, default_list_id: 'list-2' },
591
+ });
592
+
593
+ await waitFor(() => {
594
+ expect(
595
+ queryClient.getQueryData([
596
+ 'user-config',
597
+ 'TASK_QUICK_CREATE_TARGET_LIST',
598
+ ])
599
+ ).toBe('hovered_list');
600
+ });
601
+
602
+ await act(async () => {
603
+ kanbanBoardProps?.onHoveredTaskListChange?.('external-list');
604
+ });
605
+
606
+ fireEvent.keyDown(document, { key: 'c' });
607
+
608
+ expect(createTaskMock).toHaveBeenCalledTimes(1);
609
+ expect(createTaskMock).toHaveBeenCalledWith(
610
+ 'board-1',
611
+ 'list-2',
612
+ mockLists,
613
+ expect.objectContaining({ labels: [] })
614
+ );
615
+ });
616
+
547
617
  it('falls back to the first list when the default list is unavailable', () => {
548
618
  renderBoardViews({
549
619
  board: { ...mockBoard, default_list_id: 'list-does-not-exist' },
@@ -13,6 +13,7 @@ import {
13
13
  import {
14
14
  TASK_BOARD_PINNED_SPECIAL_LISTS_CONFIG_ID,
15
15
  TASK_LAST_BOARD_VIEW_CONFIG_ID,
16
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
16
17
  } from '@tuturuuu/internal-api/users';
17
18
  import type {
18
19
  Workspace,
@@ -35,6 +36,7 @@ import {
35
36
  useMemo,
36
37
  useState,
37
38
  } from 'react';
39
+ import { useUserConfig } from '../../../../hooks/use-user-config';
38
40
  import {
39
41
  useUpdateUserWorkspaceConfig,
40
42
  useUserWorkspaceConfig,
@@ -58,6 +60,10 @@ import {
58
60
  type SpecialTaskListPin,
59
61
  serializeSpecialTaskListPins,
60
62
  } from './special-task-list-pins';
63
+ import {
64
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
65
+ normalizeTaskQuickCreateTargetList,
66
+ } from './task-quick-create-target-list';
61
67
 
62
68
  export type ViewType =
63
69
  | 'kanban'
@@ -290,8 +296,19 @@ export function BoardViews({
290
296
  const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
291
297
  const [kanbanBulkSelectionActive, setKanbanBulkSelectionActive] =
292
298
  useState(false);
299
+ const [hoveredTaskListId, setHoveredTaskListId] = useState<string | null>(
300
+ null
301
+ );
293
302
  const { createTask } = useTaskDialog();
294
303
  const localTaskState = readOnly || publicView;
304
+ const { data: quickCreateTargetListRaw } = useUserConfig(
305
+ TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
306
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
307
+ { enabled: !localTaskState }
308
+ );
309
+ const quickCreateTargetList = normalizeTaskQuickCreateTargetList(
310
+ quickCreateTargetListRaw
311
+ );
295
312
  const boardAssigneesEnabled =
296
313
  !workspace.personal ||
297
314
  board.access_type === 'guest' ||
@@ -820,6 +837,15 @@ export function BoardViews({
820
837
  statusFilteredLists,
821
838
  ]);
822
839
 
840
+ useEffect(() => {
841
+ if (!hoveredTaskListId) return;
842
+
843
+ const stillSelectable = filteredLists.some(
844
+ (list) => list.id === hoveredTaskListId && !list.is_external_staging
845
+ );
846
+ if (!stillSelectable) setHoveredTaskListId(null);
847
+ }, [filteredLists, hoveredTaskListId]);
848
+
823
849
  const sourceTasks = useMemo(() => {
824
850
  if (localTaskState) return locallyFilteredTasks;
825
851
 
@@ -902,11 +928,14 @@ export function BoardViews({
902
928
  const selectableLists = filteredLists.filter(
903
929
  (list) => !list.is_external_staging
904
930
  );
905
- // Prefer the board's configured default list for new tasks, falling back
906
- // to the first selectable list when unset or the list is unavailable.
907
- const targetList =
931
+ const defaultTargetList =
908
932
  selectableLists.find((list) => list.id === board.default_list_id) ??
909
933
  selectableLists[0];
934
+ const hoveredTargetList =
935
+ quickCreateTargetList === 'hovered_list'
936
+ ? selectableLists.find((list) => list.id === hoveredTaskListId)
937
+ : undefined;
938
+ const targetList = hoveredTargetList ?? defaultTargetList;
910
939
  if (!targetList) return;
911
940
  createTask(board.id, targetList.id, selectableLists, filters);
912
941
  },
@@ -1022,6 +1051,7 @@ export function BoardViews({
1022
1051
  onBulkSelectionActiveChange={setKanbanBulkSelectionActive}
1023
1052
  canUseBoardAssignees={boardAssigneesEnabled}
1024
1053
  assigneeMemberSource={assigneeMemberSource}
1054
+ onHoveredTaskListChange={setHoveredTaskListId}
1025
1055
  readOnly={readOnly}
1026
1056
  />
1027
1057
  );
@@ -3,6 +3,7 @@
3
3
  import { useQuery } from '@tanstack/react-query';
4
4
  import { createClient } from '@tuturuuu/supabase/next/client';
5
5
  import { useEffect, useState } from 'react';
6
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
6
7
 
7
8
  interface TaskSettingsData {
8
9
  task_auto_assign_to_self: boolean;
@@ -32,8 +33,9 @@ export function FadeSettingInitializer() {
32
33
  const { data: settings } = useQuery({
33
34
  queryKey: ['user-task-settings'],
34
35
  queryFn: async (): Promise<TaskSettingsData> => {
35
- const res = await fetch('/api/v1/users/task-settings', {
36
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
36
37
  cache: 'no-store',
38
+ credentials: 'include',
37
39
  });
38
40
  if (!res.ok) {
39
41
  // Return defaults if API fails
@@ -528,7 +528,7 @@ function InteractiveListView({
528
528
  isPersonalWorkspace,
529
529
  canUseBoardAssignees: task.source_workspace_id ? true : showAssignees,
530
530
  assigneeMemberSource: task.source_workspace_id
531
- ? 'workspace'
531
+ ? 'board'
532
532
  : effectiveAssigneeMemberSource,
533
533
  })
534
534
  );
@@ -9,6 +9,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
9
9
  import { Switch } from '@tuturuuu/ui/switch';
10
10
  import { Tooltip, TooltipContent, TooltipTrigger } from '@tuturuuu/ui/tooltip';
11
11
  import { useTranslations } from 'next-intl';
12
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
12
13
  import { TASK_SOUND_EFFECTS_ENABLED_CONFIG_ID } from '../../task-sound-effects';
13
14
 
14
15
  interface TaskSettingsData {
@@ -54,7 +55,9 @@ export function QuickSettingsPopover({
54
55
  const { data: settings, isLoading } = useQuery({
55
56
  queryKey: ['user-task-settings'],
56
57
  queryFn: async (): Promise<TaskSettingsData> => {
57
- const res = await fetch('/api/v1/users/task-settings');
58
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
59
+ credentials: 'include',
60
+ });
58
61
  if (!res.ok) {
59
62
  // Return defaults if API fails
60
63
  return { task_auto_assign_to_self: false, fade_completed_tasks: false };
@@ -68,8 +71,9 @@ export function QuickSettingsPopover({
68
71
 
69
72
  const updateSettings = useMutation({
70
73
  mutationFn: async (data: Partial<TaskSettingsData>) => {
71
- const res = await fetch('/api/v1/users/task-settings', {
74
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
72
75
  method: 'PATCH',
76
+ credentials: 'include',
73
77
  headers: { 'Content-Type': 'application/json' },
74
78
  body: JSON.stringify(data),
75
79
  });
@@ -48,11 +48,15 @@ describe('useUpdateSharedTask', () => {
48
48
  // Cast to any to access mutationFn which is available in the mock return value
49
49
  const response = await (result.current as any).mutationFn(payload);
50
50
 
51
- expect(global.fetch).toHaveBeenCalledWith('/api/v1/shared/tasks/SHARE123', {
52
- method: 'PATCH',
53
- headers: { 'Content-Type': 'application/json' },
54
- body: JSON.stringify(payload.updates),
55
- });
51
+ expect(global.fetch).toHaveBeenCalledWith(
52
+ 'http://localhost:7809/api/v1/shared/tasks/SHARE123',
53
+ {
54
+ method: 'PATCH',
55
+ credentials: 'include',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(payload.updates),
58
+ }
59
+ );
56
60
  expect(response).toEqual(mockResponse);
57
61
  });
58
62
 
@@ -14,6 +14,7 @@ import {
14
14
  type WorkspaceTaskDescriptionUpdatePayload,
15
15
  type WorkspaceTaskUpdatePayload,
16
16
  } from '@tuturuuu/internal-api/tasks';
17
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
17
18
  import type { WorkspaceTaskLabel } from '../types';
18
19
 
19
20
  const TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES = 192 * 1024;
@@ -261,14 +262,18 @@ export async function createWorkspaceLabel(
261
262
  wsId: string,
262
263
  payload: { name: string; color: string }
263
264
  ) {
264
- const response = await fetch(`/api/v1/workspaces/${wsId}/labels`, {
265
- method: 'POST',
266
- headers: {
267
- 'Content-Type': 'application/json',
268
- },
269
- body: JSON.stringify(payload),
270
- cache: 'no-store',
271
- });
265
+ const response = await fetch(
266
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/labels`),
267
+ {
268
+ method: 'POST',
269
+ credentials: 'include',
270
+ headers: {
271
+ 'Content-Type': 'application/json',
272
+ },
273
+ body: JSON.stringify(payload),
274
+ cache: 'no-store',
275
+ }
276
+ );
272
277
 
273
278
  if (!response.ok) {
274
279
  throw new Error(await getErrorMessage(response, 'Failed to create label'));
@@ -3,6 +3,7 @@
3
3
  import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import type { TaskUserOverride } from '@tuturuuu/types';
5
5
  import { toast } from '@tuturuuu/ui/sonner';
6
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
6
7
 
7
8
  type OverrideInput = Partial<
8
9
  Omit<TaskUserOverride, 'task_id' | 'user_id' | 'created_at' | 'updated_at'>
@@ -23,7 +24,10 @@ export function useTaskOverrides(
23
24
  queryKey,
24
25
  queryFn: async (): Promise<TaskUserOverride | null> => {
25
26
  if (!taskId) return null;
26
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`);
27
+ const res = await fetch(
28
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
29
+ { credentials: 'include' }
30
+ );
27
31
  if (!res.ok) throw new Error('Failed to fetch override');
28
32
  const json = await res.json();
29
33
  return json.data ?? null;
@@ -35,11 +39,15 @@ export function useTaskOverrides(
35
39
  const upsertMutation = useMutation({
36
40
  mutationFn: async (input: OverrideInput): Promise<TaskUserOverride> => {
37
41
  if (!taskId) throw new Error('No task ID');
38
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`, {
39
- method: 'PUT',
40
- headers: { 'Content-Type': 'application/json' },
41
- body: JSON.stringify(input),
42
- });
42
+ const res = await fetch(
43
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
44
+ {
45
+ method: 'PUT',
46
+ credentials: 'include',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(input),
49
+ }
50
+ );
43
51
  if (!res.ok) {
44
52
  const err = await res.json().catch(() => null);
45
53
  throw new Error(err?.error || 'Failed to save override');
@@ -85,9 +93,13 @@ export function useTaskOverrides(
85
93
  const deleteMutation = useMutation({
86
94
  mutationFn: async () => {
87
95
  if (!taskId) throw new Error('No task ID');
88
- const res = await fetch(`/api/v1/users/me/tasks/${taskId}/overrides`, {
89
- method: 'DELETE',
90
- });
96
+ const res = await fetch(
97
+ getTaskApiUrl(`/api/v1/users/me/tasks/${taskId}/overrides`),
98
+ {
99
+ method: 'DELETE',
100
+ credentials: 'include',
101
+ }
102
+ );
91
103
  if (!res.ok) throw new Error('Failed to delete override');
92
104
  },
93
105
  onMutate: async () => {
@@ -4,6 +4,7 @@ import {
4
4
  useQueryClient,
5
5
  } from '@tanstack/react-query';
6
6
  import type { Task } from '@tuturuuu/types/primitives/Task';
7
+ import { getTaskApiUrl } from '../../../../../../lib/tasks-app-url';
7
8
 
8
9
  export interface TaskUpdatePayload {
9
10
  name?: string;
@@ -33,11 +34,15 @@ export function useUpdateSharedTask(): UseMutationResult<
33
34
  shareCode: string;
34
35
  updates: TaskUpdatePayload;
35
36
  }) => {
36
- const response = await fetch(`/api/v1/shared/tasks/${shareCode}`, {
37
- method: 'PATCH',
38
- headers: { 'Content-Type': 'application/json' },
39
- body: JSON.stringify(updates),
40
- });
37
+ const response = await fetch(
38
+ getTaskApiUrl(`/api/v1/shared/tasks/${shareCode}`),
39
+ {
40
+ method: 'PATCH',
41
+ credentials: 'include',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(updates),
44
+ }
45
+ );
41
46
 
42
47
  if (!response.ok) {
43
48
  const error = await response.json().catch(() => null);
@@ -35,6 +35,7 @@ import { usePathname } from 'next/navigation';
35
35
  import { useLocale, useTranslations } from 'next-intl';
36
36
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
37
37
  import * as Y from 'yjs';
38
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
38
39
  import { BoardEstimationConfigDialog } from '../boards/boardId/task-dialogs/BoardEstimationConfigDialog';
39
40
  import { TaskNewLabelDialog } from '../boards/boardId/task-dialogs/TaskNewLabelDialog';
40
41
  import { TaskNewProjectDialog } from '../boards/boardId/task-dialogs/TaskNewProjectDialog';
@@ -391,8 +392,9 @@ export function TaskEditDialog({
391
392
  const { data: userTaskSettings } = useQuery({
392
393
  queryKey: ['user-task-settings'],
393
394
  queryFn: async () => {
394
- const res = await fetch('/api/v1/users/task-settings', {
395
+ const res = await fetch(getTaskApiUrl('/api/v1/users/task-settings'), {
395
396
  cache: 'no-store',
397
+ credentials: 'include',
396
398
  });
397
399
  if (!res.ok) return { task_auto_assign_to_self: false };
398
400
  return res.json() as Promise<{ task_auto_assign_to_self: boolean }>;
@@ -793,7 +795,7 @@ export function TaskEditDialog({
793
795
  enabled: !!isOpen && !isCreateMode && !!task?.id,
794
796
  queryFn: async () => {
795
797
  const response = await fetch(
796
- `/api/v1/users/me/tasks/${task!.id}/schedule`,
798
+ getTaskApiUrl(`/api/v1/users/me/tasks/${task!.id}/schedule`),
797
799
  { cache: 'no-store' }
798
800
  );
799
801
  if (!response.ok) return null;
@@ -17,6 +17,7 @@ import { Label } from '@tuturuuu/ui/label';
17
17
  import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
18
18
  import { cn } from '@tuturuuu/utils/format';
19
19
  import { useMemo, useState } from 'react';
20
+ import { getTaskApiUrl } from '../../../../lib/tasks-app-url';
20
21
  import {
21
22
  buildEstimationIndices,
22
23
  mapEstimationPoints,
@@ -58,11 +59,13 @@ export function TaskEstimationPicker({
58
59
  queryFn: async () => {
59
60
  if (!boardId) return null;
60
61
  const response = await fetch(
61
- `/api/v1/workspaces/${wsId}/boards/${boardId}/estimation`
62
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}/estimation`)
62
63
  );
63
64
  if (!response.ok) {
64
65
  // If the endpoint doesn't exist, we'll create a fallback
65
- const boardResponse = await fetch(`/api/v1/workspaces/${wsId}/boards`);
66
+ const boardResponse = await fetch(
67
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards`)
68
+ );
66
69
  if (!boardResponse.ok) throw new Error('Failed to fetch boards');
67
70
  const boards = await boardResponse.json();
68
71
  const board = boards.find((b: any) => b.id === boardId);
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_TASK_QUICK_CREATE_TARGET_LIST = 'default_list';
2
+
3
+ export const TASK_QUICK_CREATE_TARGET_LIST_VALUES = [
4
+ DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
5
+ 'hovered_list',
6
+ ] as const;
7
+
8
+ export type TaskQuickCreateTargetList =
9
+ (typeof TASK_QUICK_CREATE_TARGET_LIST_VALUES)[number];
10
+
11
+ export function normalizeTaskQuickCreateTargetList(
12
+ value: string | null | undefined,
13
+ fallback: TaskQuickCreateTargetList = DEFAULT_TASK_QUICK_CREATE_TARGET_LIST
14
+ ): TaskQuickCreateTargetList {
15
+ return TASK_QUICK_CREATE_TARGET_LIST_VALUES.includes(
16
+ value as TaskQuickCreateTargetList
17
+ )
18
+ ? (value as TaskQuickCreateTargetList)
19
+ : fallback;
20
+ }
@@ -180,7 +180,7 @@ export function TaskRowActionsMenu({
180
180
  canUseBoardAssignees ??
181
181
  (task.source_workspace_id ? true : !isPersonalWorkspace),
182
182
  assigneeMemberSource: task.source_workspace_id
183
- ? 'workspace'
183
+ ? 'board'
184
184
  : assigneeMemberSource,
185
185
  }
186
186
  );
@@ -41,6 +41,8 @@ vi.mock('@tuturuuu/internal-api/tasks', () => ({
41
41
  }));
42
42
 
43
43
  vi.mock('@tuturuuu/utils/task-helper', () => ({
44
+ isPersonalExternalStagingListId: (listId: string | null) =>
45
+ listId?.startsWith('personal-external-staging:') ?? false,
44
46
  useUpdateTask: vi.fn(),
45
47
  }));
46
48
 
@@ -1689,6 +1691,55 @@ describe('useTaskActions', () => {
1689
1691
  expect(setMenuOpen).toHaveBeenCalledWith(false);
1690
1692
  });
1691
1693
 
1694
+ it('moves a personal-workspace task with personal board metadata through the normal task route', async () => {
1695
+ const personalTask = {
1696
+ ...mockTask,
1697
+ personal_board_id: 'board-1',
1698
+ personal_list_id: 'list-1',
1699
+ source_workspace_id: 'ws-1',
1700
+ source_board_id: 'board-1',
1701
+ source_list_id: 'list-1',
1702
+ } as unknown as Task;
1703
+ const targetList = {
1704
+ id: 'list-2',
1705
+ name: 'Later',
1706
+ board_id: 'board-1',
1707
+ status: 'active',
1708
+ created_at: '2025-01-01T00:00:00Z',
1709
+ archived: false,
1710
+ deleted: false,
1711
+ creator_id: 'user-1',
1712
+ color: null,
1713
+ position: 3,
1714
+ } as unknown as TaskList;
1715
+
1716
+ queryClient.setQueryData(['tasks', 'board-1'], [personalTask]);
1717
+
1718
+ const { result } = renderHook(
1719
+ () =>
1720
+ useTaskActions({
1721
+ task: personalTask,
1722
+ boardId: 'board-1',
1723
+ targetCompletionList: mockCompletionList,
1724
+ targetClosedList: mockClosedList,
1725
+ availableLists: [...mockAvailableLists, targetList],
1726
+ onUpdate: vi.fn(),
1727
+ setIsLoading: vi.fn(),
1728
+ setMenuOpen: vi.fn(),
1729
+ }),
1730
+ { wrapper }
1731
+ );
1732
+
1733
+ await act(async () => {
1734
+ await result.current.handleMoveToList('list-2');
1735
+ });
1736
+
1737
+ expect(mockUpdateWorkspaceTask).toHaveBeenCalledWith('ws-1', 'task-1', {
1738
+ list_id: 'list-2',
1739
+ });
1740
+ expect(mockUpsertCurrentUserTaskPersonalPlacement).not.toHaveBeenCalled();
1741
+ });
1742
+
1692
1743
  it('moves an external task between personal lists through personal placement only', async () => {
1693
1744
  const targetList = {
1694
1745
  id: 'list-2',
@@ -11,11 +11,10 @@ import {
11
11
  isTaskBoardCompletedStatus,
12
12
  isTaskBoardTerminalStatus,
13
13
  } from '@tuturuuu/utils/task-list-status';
14
+ import { isPersonalExternalOverlayTask } from '../lib/task-personal-external';
14
15
 
15
16
  export function isPersonalExternalTask(task?: Task) {
16
- return (
17
- task?.is_personal_external === true || Boolean(task?.personal_board_id)
18
- );
17
+ return isPersonalExternalOverlayTask(task);
19
18
  }
20
19
 
21
20
  function findFirstListByStatus(lists: TaskList[], status: TaskBoardStatus) {
@@ -1,5 +1,6 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from '@tuturuuu/ui/sonner';
3
+ import { getTaskApiUrl } from '../lib/tasks-app-url';
3
4
 
4
5
  async function boardAction(
5
6
  wsId: string,
@@ -7,13 +8,17 @@ async function boardAction(
7
8
  method: 'PUT' | 'DELETE' | 'PATCH',
8
9
  body?: any
9
10
  ) {
10
- const response = await fetch(`/api/v1/workspaces/${wsId}/boards/${boardId}`, {
11
- method,
12
- headers: {
13
- 'Content-Type': 'application/json',
14
- },
15
- body: body ? JSON.stringify(body) : undefined,
16
- });
11
+ const response = await fetch(
12
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}`),
13
+ {
14
+ method,
15
+ credentials: 'include',
16
+ headers: {
17
+ 'Content-Type': 'application/json',
18
+ },
19
+ body: body ? JSON.stringify(body) : undefined,
20
+ }
21
+ );
17
22
 
18
23
  if (!response.ok) {
19
24
  const errorData = await response.json();
@@ -29,9 +34,10 @@ async function archiveAction(
29
34
  method: 'POST' | 'DELETE'
30
35
  ) {
31
36
  const response = await fetch(
32
- `/api/v1/workspaces/${wsId}/boards/${boardId}/archive`,
37
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/boards/${boardId}/archive`),
33
38
  {
34
39
  method,
40
+ credentials: 'include',
35
41
  headers: {
36
42
  'Content-Type': 'application/json',
37
43
  },
@@ -166,15 +172,19 @@ export function useBoardActions(wsId: string) {
166
172
  { boardId: string; options?: BoardActionOptions }
167
173
  >({
168
174
  mutationFn: ({ boardId }) =>
169
- fetch(`/api/v1/workspaces/${wsId}/task-boards/${boardId}/copy`, {
170
- method: 'POST',
171
- headers: {
172
- 'Content-Type': 'application/json',
173
- },
174
- body: JSON.stringify({
175
- targetWorkspaceId: wsId,
176
- }),
177
- }).then(async (res) => {
175
+ fetch(
176
+ getTaskApiUrl(`/api/v1/workspaces/${wsId}/task-boards/${boardId}/copy`),
177
+ {
178
+ method: 'POST',
179
+ credentials: 'include',
180
+ headers: {
181
+ 'Content-Type': 'application/json',
182
+ },
183
+ body: JSON.stringify({
184
+ targetWorkspaceId: wsId,
185
+ }),
186
+ }
187
+ ).then(async (res) => {
178
188
  if (!res.ok) {
179
189
  const errorData = await res.json();
180
190
  throw new Error(errorData.error || 'Failed to duplicate board');
@@ -29,6 +29,7 @@ import {
29
29
  useRef,
30
30
  useState,
31
31
  } from 'react';
32
+ import { getTaskApiUrl } from '../lib/tasks-app-url';
32
33
  import { useCalendarSync } from './use-calendar-sync';
33
34
 
34
35
  // Utility function to round time to nearest 15-minute interval
@@ -319,11 +320,14 @@ async function syncTaskDurationAfterEventChange(
319
320
  let totalScheduledMinutes = resizedEventMinutes;
320
321
 
321
322
  const scheduleResponse = await fetch(
322
- options?.isPersonalCalendar
323
- ? `/api/v1/users/me/tasks/${taskId}/schedule`
324
- : `/api/v1/workspaces/${calendarWsId}/tasks/${taskId}/schedule`,
323
+ getTaskApiUrl(
324
+ options?.isPersonalCalendar
325
+ ? `/api/v1/users/me/tasks/${taskId}/schedule`
326
+ : `/api/v1/workspaces/${calendarWsId}/tasks/${taskId}/schedule`
327
+ ),
325
328
  {
326
329
  cache: 'no-store',
330
+ credentials: 'include',
327
331
  }
328
332
  );
329
333