@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.
- package/CHANGELOG.md +28 -0
- package/package.json +4 -4
- package/src/components/ui/calendar-app/components/habits-panel.tsx +5 -2
- package/src/components/ui/calendar-app/components/priority-view.tsx +15 -7
- package/src/components/ui/calendar-app/components/scheduling-dialog.tsx +7 -3
- package/src/components/ui/calendar-app/components/task-scheduler-panel.tsx +7 -3
- package/src/components/ui/calendar-app/components/time-tracker/index.tsx +14 -9
- package/src/components/ui/custom/settings/task-settings.tsx +61 -2
- package/src/components/ui/custom/tables/data-table.tsx +58 -8
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.test.ts +118 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/auto-scroll.ts +144 -70
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -1
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +21 -10
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +6 -0
- package/src/components/ui/tu-do/boards/boardId/kanban.tsx +4 -2
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +11 -1
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -3
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.test.ts +113 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-resource-context.ts +50 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +65 -18
- package/src/components/ui/tu-do/boards/boardId/timeline-board.tsx +1 -1
- package/src/components/ui/tu-do/cycles/task-cycles-client.tsx +23 -14
- package/src/components/ui/tu-do/estimates/use-task-estimates.ts +9 -4
- package/src/components/ui/tu-do/habits/client.tsx +4 -1
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +6 -3
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +19 -11
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-query.ts +15 -6
- package/src/components/ui/tu-do/my-tasks/use-my-tasks-state.ts +18 -13
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +17 -10
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
- package/src/components/ui/tu-do/shared/fade-setting-initializer.tsx +3 -1
- package/src/components/ui/tu-do/shared/list-view.tsx +1 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/quick-settings-popover.tsx +6 -2
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-update-shared-task.test.ts +9 -5
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +13 -8
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-overrides.ts +21 -9
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-update-shared-task.ts +10 -5
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +4 -2
- package/src/components/ui/tu-do/shared/task-estimation-picker.tsx +5 -2
- package/src/components/ui/tu-do/shared/task-quick-create-target-list.ts +20 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +1 -1
- package/src/hooks/__tests__/use-task-actions.test.tsx +51 -0
- package/src/hooks/task-actions-personal-external.ts +2 -3
- package/src/hooks/use-board-actions.ts +27 -17
- package/src/hooks/use-calendar.tsx +7 -3
- package/src/hooks/use-settings-dialog-shortcut.ts +51 -0
- package/src/hooks/use-user-config.ts +10 -2
- package/src/lib/task-personal-external.ts +49 -0
- package/src/lib/tasks-app-url.ts +85 -0
|
@@ -1,86 +1,160 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type {
|
|
4
|
+
DragMoveEvent,
|
|
5
|
+
DragOverEvent,
|
|
6
|
+
DragStartEvent,
|
|
7
|
+
} from '@dnd-kit/core';
|
|
8
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
9
|
+
|
|
10
|
+
export const KANBAN_EDGE_AUTO_SCROLL_THRESHOLD = 100;
|
|
11
|
+
const KANBAN_EDGE_AUTO_SCROLL_SPEED = 10;
|
|
12
|
+
const KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED = 30;
|
|
13
|
+
|
|
14
|
+
type KanbanDragAutoScrollEvent = DragMoveEvent | DragOverEvent | DragStartEvent;
|
|
15
|
+
|
|
16
|
+
interface HorizontalRect {
|
|
17
|
+
left: number;
|
|
18
|
+
right: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getTouchListClientX(touches: unknown) {
|
|
22
|
+
if (!touches || typeof touches !== 'object') return null;
|
|
23
|
+
|
|
24
|
+
const list = touches as ArrayLike<{ clientX?: unknown }> & {
|
|
25
|
+
item?: (index: number) => { clientX?: unknown } | null;
|
|
26
|
+
};
|
|
27
|
+
const touch = list.item?.(0) ?? list[0];
|
|
28
|
+
|
|
29
|
+
return typeof touch?.clientX === 'number' ? touch.clientX : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getPointerEventClientX(event: Event) {
|
|
33
|
+
if ('clientX' in event && typeof event.clientX === 'number') {
|
|
34
|
+
return event.clientX;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ('touches' in event) {
|
|
38
|
+
const touchX = getTouchListClientX(event.touches);
|
|
39
|
+
if (touchX !== null) return touchX;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if ('changedTouches' in event) {
|
|
43
|
+
return getTouchListClientX(event.changedTouches);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getDragEventDeltaX(event: KanbanDragAutoScrollEvent) {
|
|
50
|
+
return 'delta' in event && typeof event.delta?.x === 'number'
|
|
51
|
+
? event.delta.x
|
|
52
|
+
: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getDragActiveCenterX(event: KanbanDragAutoScrollEvent) {
|
|
56
|
+
const activeRect =
|
|
57
|
+
event.active.rect.current.translated ?? event.active.rect.current.initial;
|
|
58
|
+
|
|
59
|
+
return activeRect ? activeRect.left + activeRect.width / 2 : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getKanbanDragAutoScrollPointerX(
|
|
63
|
+
event: KanbanDragAutoScrollEvent
|
|
64
|
+
) {
|
|
65
|
+
const pointerStartX = getPointerEventClientX(event.activatorEvent);
|
|
66
|
+
|
|
67
|
+
if (pointerStartX !== null) {
|
|
68
|
+
return pointerStartX + getDragEventDeltaX(event);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return getDragActiveCenterX(event);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getKanbanEdgeAutoScrollAmount(
|
|
75
|
+
pointerX: number | null,
|
|
76
|
+
rect: HorizontalRect,
|
|
77
|
+
options: {
|
|
78
|
+
maxSpeed?: number;
|
|
79
|
+
speed?: number;
|
|
80
|
+
threshold?: number;
|
|
81
|
+
} = {}
|
|
82
|
+
) {
|
|
83
|
+
if (pointerX === null) return 0;
|
|
84
|
+
|
|
85
|
+
const threshold = options.threshold ?? KANBAN_EDGE_AUTO_SCROLL_THRESHOLD;
|
|
86
|
+
const speed = options.speed ?? KANBAN_EDGE_AUTO_SCROLL_SPEED;
|
|
87
|
+
const maxSpeed = options.maxSpeed ?? KANBAN_EDGE_AUTO_SCROLL_MAX_SPEED;
|
|
88
|
+
|
|
89
|
+
if (pointerX <= rect.left + threshold) {
|
|
90
|
+
const distanceFromLeft = Math.max(0, pointerX - rect.left);
|
|
91
|
+
const intensity = 1 - distanceFromLeft / threshold;
|
|
92
|
+
return -Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (pointerX >= rect.right - threshold) {
|
|
96
|
+
const distanceFromRight = Math.max(0, rect.right - pointerX);
|
|
97
|
+
const intensity = 1 - distanceFromRight / threshold;
|
|
98
|
+
return Math.min(speed + intensity * (maxSpeed - speed), maxSpeed);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
4
103
|
|
|
5
104
|
export function useAutoScroll(
|
|
6
|
-
isDraggingRef: React.MutableRefObject<boolean>,
|
|
7
105
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>
|
|
8
106
|
) {
|
|
9
107
|
const autoScrollRafRef = useRef<number | null>(null);
|
|
108
|
+
const isAutoScrollActiveRef = useRef(false);
|
|
109
|
+
const pointerXRef = useRef<number | null>(null);
|
|
10
110
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const MAX_SCROLL_SPEED = 30; // Maximum scroll speed (px per frame)
|
|
111
|
+
const stopAutoScroll = useCallback(() => {
|
|
112
|
+
isAutoScrollActiveRef.current = false;
|
|
113
|
+
pointerXRef.current = null;
|
|
15
114
|
|
|
16
|
-
|
|
115
|
+
if (autoScrollRafRef.current !== null) {
|
|
116
|
+
cancelAnimationFrame(autoScrollRafRef.current);
|
|
117
|
+
autoScrollRafRef.current = null;
|
|
118
|
+
}
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const autoScroll = useCallback(() => {
|
|
122
|
+
if (!isAutoScrollActiveRef.current || !scrollContainerRef.current) {
|
|
123
|
+
autoScrollRafRef.current = null;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const container = scrollContainerRef.current;
|
|
128
|
+
const scrollAmount = getKanbanEdgeAutoScrollAmount(
|
|
129
|
+
pointerXRef.current,
|
|
130
|
+
container.getBoundingClientRect()
|
|
131
|
+
);
|
|
17
132
|
|
|
18
|
-
|
|
19
|
-
|
|
133
|
+
if (scrollAmount !== 0) {
|
|
134
|
+
container.scrollLeft += scrollAmount;
|
|
20
135
|
}
|
|
21
136
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const rect = container.getBoundingClientRect();
|
|
30
|
-
|
|
31
|
-
// Calculate distance from edges
|
|
32
|
-
const distanceFromLeft = currentPointerX - rect.left;
|
|
33
|
-
const distanceFromRight = rect.right - currentPointerX;
|
|
34
|
-
|
|
35
|
-
let scrollAmount = 0;
|
|
36
|
-
|
|
37
|
-
// Scroll left when near left edge
|
|
38
|
-
if (distanceFromLeft < EDGE_THRESHOLD && distanceFromLeft > 0) {
|
|
39
|
-
const intensity = 1 - distanceFromLeft / EDGE_THRESHOLD;
|
|
40
|
-
scrollAmount = -Math.min(
|
|
41
|
-
SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
|
|
42
|
-
MAX_SCROLL_SPEED
|
|
43
|
-
);
|
|
44
|
-
}
|
|
45
|
-
// Scroll right when near right edge
|
|
46
|
-
else if (distanceFromRight < EDGE_THRESHOLD && distanceFromRight > 0) {
|
|
47
|
-
const intensity = 1 - distanceFromRight / EDGE_THRESHOLD;
|
|
48
|
-
scrollAmount = Math.min(
|
|
49
|
-
SCROLL_SPEED + intensity * (MAX_SCROLL_SPEED - SCROLL_SPEED),
|
|
50
|
-
MAX_SCROLL_SPEED
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Apply scroll if needed
|
|
55
|
-
if (scrollAmount !== 0) {
|
|
56
|
-
container.scrollLeft += scrollAmount;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Continue the animation loop
|
|
137
|
+
autoScrollRafRef.current = requestAnimationFrame(autoScroll);
|
|
138
|
+
}, [scrollContainerRef]);
|
|
139
|
+
|
|
140
|
+
const startAutoScroll = useCallback(() => {
|
|
141
|
+
isAutoScrollActiveRef.current = true;
|
|
142
|
+
|
|
143
|
+
if (autoScrollRafRef.current === null) {
|
|
60
144
|
autoScrollRafRef.current = requestAnimationFrame(autoScroll);
|
|
61
145
|
}
|
|
146
|
+
}, [autoScroll]);
|
|
147
|
+
|
|
148
|
+
const updateAutoScrollPointerX = useCallback((pointerX: number | null) => {
|
|
149
|
+
pointerXRef.current = pointerX;
|
|
150
|
+
}, []);
|
|
151
|
+
|
|
152
|
+
useEffect(() => stopAutoScroll, [stopAutoScroll]);
|
|
62
153
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
autoScrollRafRef.current = requestAnimationFrame(autoScroll);
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// Poll for drag start (alternative: trigger from onDragStart)
|
|
74
|
-
const pollInterval = setInterval(startAutoScrollLoop, 100);
|
|
75
|
-
|
|
76
|
-
return () => {
|
|
77
|
-
window.removeEventListener('pointermove', handlePointerMove);
|
|
78
|
-
clearInterval(pollInterval);
|
|
79
|
-
if (autoScrollRafRef.current) {
|
|
80
|
-
cancelAnimationFrame(autoScrollRafRef.current);
|
|
81
|
-
}
|
|
82
|
-
};
|
|
83
|
-
}, [isDraggingRef, scrollContainerRef]);
|
|
84
|
-
|
|
85
|
-
return { autoScrollRafRef };
|
|
154
|
+
return {
|
|
155
|
+
autoScrollRafRef,
|
|
156
|
+
startAutoScroll,
|
|
157
|
+
stopAutoScroll,
|
|
158
|
+
updateAutoScrollPointerX,
|
|
159
|
+
};
|
|
86
160
|
}
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { QueryClient } from '@tanstack/react-query';
|
|
2
2
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
3
3
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
4
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
vi.mock('@tuturuuu/utils/task-helper', () => {
|
|
7
|
+
const prefix = 'personal-external-staging:';
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
getPersonalExternalStagingBoardId: (listId: string | null) =>
|
|
11
|
+
listId?.startsWith(prefix) && listId.length > prefix.length
|
|
12
|
+
? listId.slice(prefix.length)
|
|
13
|
+
: null,
|
|
14
|
+
getPersonalExternalStagingListId: (boardId: string) =>
|
|
15
|
+
`${prefix}${boardId}`,
|
|
16
|
+
isPersonalExternalStagingListId: (listId: string | null) =>
|
|
17
|
+
Boolean(listId?.startsWith(prefix) && listId.length > prefix.length),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
5
21
|
import {
|
|
6
22
|
applyTaskDropPreviewToCache,
|
|
7
23
|
getProjectedTaskDropOrderFromPreview,
|
|
@@ -14,6 +30,7 @@ import {
|
|
|
14
30
|
insertTaskAtDropPosition,
|
|
15
31
|
mergePersonalPlacementMutationTask,
|
|
16
32
|
mergeTaskIntoBoardTaskCache,
|
|
33
|
+
usesPersonalPlacement,
|
|
17
34
|
} from './use-kanban-dnd';
|
|
18
35
|
|
|
19
36
|
function createTask(overrides: Partial<Task> = {}): Task {
|
|
@@ -116,6 +133,51 @@ describe('mergePersonalPlacementMutationTask', () => {
|
|
|
116
133
|
});
|
|
117
134
|
});
|
|
118
135
|
|
|
136
|
+
describe('usesPersonalPlacement', () => {
|
|
137
|
+
it('does not treat a personal-workspace task as external from personal_board_id alone', () => {
|
|
138
|
+
const personalTask = createTask({
|
|
139
|
+
is_personal_external: false,
|
|
140
|
+
personal_board_id: 'personal-board',
|
|
141
|
+
personal_list_id: 'list-1',
|
|
142
|
+
source_board_id: null,
|
|
143
|
+
source_list_id: null,
|
|
144
|
+
source_workspace_id: null,
|
|
145
|
+
} as Partial<Task>);
|
|
146
|
+
|
|
147
|
+
expect(usesPersonalPlacement(personalTask)).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('does not treat an explicitly native personal task as external when source metadata is present', () => {
|
|
151
|
+
const personalTask = createTask({
|
|
152
|
+
is_personal_external: false,
|
|
153
|
+
personal_board_id: 'personal-board',
|
|
154
|
+
personal_list_id: 'list-1',
|
|
155
|
+
source_workspace_id: 'personal-workspace',
|
|
156
|
+
source_board_id: 'personal-board',
|
|
157
|
+
source_list_id: 'list-1',
|
|
158
|
+
} as Partial<Task>);
|
|
159
|
+
|
|
160
|
+
expect(usesPersonalPlacement(personalTask)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not treat same-board personal metadata as external without an explicit flag', () => {
|
|
164
|
+
const personalTask = createTask({
|
|
165
|
+
is_personal_external: undefined,
|
|
166
|
+
personal_board_id: 'personal-board',
|
|
167
|
+
personal_list_id: 'list-1',
|
|
168
|
+
source_workspace_id: 'personal-workspace',
|
|
169
|
+
source_board_id: 'personal-board',
|
|
170
|
+
source_list_id: 'list-1',
|
|
171
|
+
} as Partial<Task>);
|
|
172
|
+
|
|
173
|
+
expect(usesPersonalPlacement(personalTask)).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('keeps explicit external overlays on the personal-placement path', () => {
|
|
177
|
+
expect(usesPersonalPlacement(createTask())).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
119
181
|
describe('mergeTaskIntoBoardTaskCache', () => {
|
|
120
182
|
it('updates an existing task in the board cache', () => {
|
|
121
183
|
const existingTask = createTask({ list_id: 'source-list' });
|
|
@@ -21,10 +21,11 @@ import {
|
|
|
21
21
|
} from '@tuturuuu/utils/task-helper';
|
|
22
22
|
import { hasDraggableData } from '@tuturuuu/utils/task-helpers';
|
|
23
23
|
import { useCallback, useRef, useState } from 'react';
|
|
24
|
+
import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-personal-external';
|
|
24
25
|
import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
|
|
25
26
|
import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
|
|
26
27
|
import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
|
|
27
|
-
import { useAutoScroll } from './auto-scroll';
|
|
28
|
+
import { getKanbanDragAutoScrollPointerX, useAutoScroll } from './auto-scroll';
|
|
28
29
|
import { getColumnReorderUpdates } from './column-reorder';
|
|
29
30
|
import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
|
|
30
31
|
import {
|
|
@@ -121,12 +122,8 @@ interface UseKanbanDndProps {
|
|
|
121
122
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
function usesPersonalPlacement(task: Task) {
|
|
125
|
-
return (
|
|
126
|
-
task.is_personal_external === true ||
|
|
127
|
-
Boolean(task.personal_board_id) ||
|
|
128
|
-
isPersonalExternalStagingListId(task.list_id)
|
|
129
|
-
);
|
|
125
|
+
export function usesPersonalPlacement(task: Task) {
|
|
126
|
+
return isPersonalExternalOverlayTask(task);
|
|
130
127
|
}
|
|
131
128
|
|
|
132
129
|
function getTaskDropPosition(
|
|
@@ -569,8 +566,15 @@ export function useKanbanDnd({
|
|
|
569
566
|
[wsId]
|
|
570
567
|
);
|
|
571
568
|
|
|
572
|
-
|
|
573
|
-
|
|
569
|
+
const { startAutoScroll, stopAutoScroll, updateAutoScrollPointerX } =
|
|
570
|
+
useAutoScroll(scrollContainerRef);
|
|
571
|
+
|
|
572
|
+
const updateAutoScrollFromDragEvent = useCallback(
|
|
573
|
+
(event: DragMoveEvent | DragOverEvent | DragStartEvent) => {
|
|
574
|
+
updateAutoScrollPointerX(getKanbanDragAutoScrollPointerX(event));
|
|
575
|
+
},
|
|
576
|
+
[updateAutoScrollPointerX]
|
|
577
|
+
);
|
|
574
578
|
|
|
575
579
|
const resetDragState = useCallback(
|
|
576
580
|
(clearOptimisticUpdates = false) => {
|
|
@@ -585,12 +589,13 @@ export function useKanbanDnd({
|
|
|
585
589
|
dragSessionMetricsRef.current = null;
|
|
586
590
|
dragStartTaskIndexesByListRef.current.clear();
|
|
587
591
|
isDraggingRef.current = false;
|
|
592
|
+
stopAutoScroll();
|
|
588
593
|
|
|
589
594
|
if (clearOptimisticUpdates) {
|
|
590
595
|
setOptimisticUpdateInProgress(new Set());
|
|
591
596
|
}
|
|
592
597
|
},
|
|
593
|
-
[setDragPreviewPosition]
|
|
598
|
+
[setDragPreviewPosition, stopAutoScroll]
|
|
594
599
|
);
|
|
595
600
|
|
|
596
601
|
const markTaskIdsPending = useCallback((taskIds: string[]) => {
|
|
@@ -609,6 +614,8 @@ export function useKanbanDnd({
|
|
|
609
614
|
|
|
610
615
|
const processTaskDragPreview = useCallback(
|
|
611
616
|
(event: DragMoveEvent) => {
|
|
617
|
+
updateAutoScrollFromDragEvent(event);
|
|
618
|
+
|
|
612
619
|
const { active, over } = event;
|
|
613
620
|
const activeType = active.data?.current?.type;
|
|
614
621
|
if (!activeType) return;
|
|
@@ -733,6 +740,7 @@ export function useKanbanDnd({
|
|
|
733
740
|
getDragPreviewForList,
|
|
734
741
|
getDragPreviewForListSurface,
|
|
735
742
|
setDragPreviewPosition,
|
|
743
|
+
updateAutoScrollFromDragEvent,
|
|
736
744
|
wsId,
|
|
737
745
|
]
|
|
738
746
|
);
|
|
@@ -745,6 +753,8 @@ export function useKanbanDnd({
|
|
|
745
753
|
|
|
746
754
|
// Enable auto-scroll
|
|
747
755
|
isDraggingRef.current = true;
|
|
756
|
+
updateAutoScrollFromDragEvent(event);
|
|
757
|
+
startAutoScroll();
|
|
748
758
|
|
|
749
759
|
const { type } = active.data.current;
|
|
750
760
|
if (type === 'Column') {
|
|
@@ -753,6 +763,7 @@ export function useKanbanDnd({
|
|
|
753
763
|
}
|
|
754
764
|
if (type === 'Task') {
|
|
755
765
|
if (!wsId) {
|
|
766
|
+
resetDragState(true);
|
|
756
767
|
return;
|
|
757
768
|
}
|
|
758
769
|
|
|
@@ -79,6 +79,7 @@ interface KanbanColumnsProps {
|
|
|
79
79
|
pin: SpecialTaskListPin,
|
|
80
80
|
pinned: boolean
|
|
81
81
|
) => void;
|
|
82
|
+
onHoveredTaskListChange?: (listId: string | null) => void;
|
|
82
83
|
readOnly?: boolean;
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -156,6 +157,7 @@ export function KanbanColumns({
|
|
|
156
157
|
onDeadlineSectionCollapsedChange,
|
|
157
158
|
specialTaskListPins,
|
|
158
159
|
onSpecialTaskListPinnedChange,
|
|
160
|
+
onHoveredTaskListChange,
|
|
159
161
|
readOnly = false,
|
|
160
162
|
}: KanbanColumnsProps) {
|
|
161
163
|
const initialScrollAnchoredBoardRef = useRef<string | null>(null);
|
|
@@ -270,6 +272,7 @@ export function KanbanColumns({
|
|
|
270
272
|
return (
|
|
271
273
|
<div
|
|
272
274
|
ref={boardRef}
|
|
275
|
+
onPointerLeave={() => onHoveredTaskListChange?.(null)}
|
|
273
276
|
className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent relative flex h-full w-full snap-x snap-mandatory gap-3 overflow-x-auto overscroll-x-contain scroll-smooth"
|
|
274
277
|
style={
|
|
275
278
|
{
|
|
@@ -416,6 +419,9 @@ export function KanbanColumns({
|
|
|
416
419
|
onSpecialTaskListPinnedChange?.('closed_tasks', pinned);
|
|
417
420
|
}
|
|
418
421
|
}}
|
|
422
|
+
onHoverTaskList={
|
|
423
|
+
list.is_external_staging ? undefined : onHoveredTaskListChange
|
|
424
|
+
}
|
|
419
425
|
readOnly={readOnly}
|
|
420
426
|
/>
|
|
421
427
|
);
|
|
@@ -104,6 +104,7 @@ interface Props {
|
|
|
104
104
|
onBulkSelectionActiveChange?: (active: boolean) => void;
|
|
105
105
|
canUseBoardAssignees?: boolean;
|
|
106
106
|
assigneeMemberSource?: 'workspace' | 'board' | 'workspace-and-board';
|
|
107
|
+
onHoveredTaskListChange?: (listId: string | null) => void;
|
|
107
108
|
readOnly?: boolean;
|
|
108
109
|
}
|
|
109
110
|
|
|
@@ -129,6 +130,7 @@ export function KanbanBoard({
|
|
|
129
130
|
onBulkSelectionActiveChange,
|
|
130
131
|
canUseBoardAssignees,
|
|
131
132
|
assigneeMemberSource,
|
|
133
|
+
onHoveredTaskListChange,
|
|
132
134
|
readOnly = false,
|
|
133
135
|
}: Props) {
|
|
134
136
|
const tCommon = useTranslations('common');
|
|
@@ -150,7 +152,6 @@ export function KanbanBoard({
|
|
|
150
152
|
const [assigneeSearchQuery, setAssigneeSearchQuery] = useState('');
|
|
151
153
|
|
|
152
154
|
// Refs
|
|
153
|
-
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
154
155
|
const taskHeightsRef = useRef<Map<string, number>>(new Map());
|
|
155
156
|
const boardRef = useRef<HTMLDivElement>(null);
|
|
156
157
|
|
|
@@ -412,7 +413,7 @@ export function KanbanBoard({
|
|
|
412
413
|
),
|
|
413
414
|
reorderTaskMutation,
|
|
414
415
|
taskHeightsRef,
|
|
415
|
-
scrollContainerRef,
|
|
416
|
+
scrollContainerRef: boardRef,
|
|
416
417
|
});
|
|
417
418
|
|
|
418
419
|
const sensors = useSensors(
|
|
@@ -565,6 +566,7 @@ export function KanbanBoard({
|
|
|
565
566
|
onTaskListCollapsedChange={onTaskListCollapsedChange}
|
|
566
567
|
specialTaskListPins={specialTaskListPins}
|
|
567
568
|
onSpecialTaskListPinnedChange={onSpecialTaskListPinnedChange}
|
|
569
|
+
onHoveredTaskListChange={onHoveredTaskListChange}
|
|
568
570
|
readOnly={readOnly}
|
|
569
571
|
/>
|
|
570
572
|
|
|
@@ -76,7 +76,7 @@ describe('getTaskCardHydratingOpenOptions', () => {
|
|
|
76
76
|
taskWsId: 'source-workspace',
|
|
77
77
|
taskWorkspacePersonal: false,
|
|
78
78
|
canUseBoardAssignees: true,
|
|
79
|
-
assigneeMemberSource: '
|
|
79
|
+
assigneeMemberSource: 'board',
|
|
80
80
|
initialSharedContext: {
|
|
81
81
|
boardConfig: {
|
|
82
82
|
id: 'source-board',
|
|
@@ -151,4 +151,14 @@ describe('getTaskCardHydratingOpenOptions', () => {
|
|
|
151
151
|
).toBe(true);
|
|
152
152
|
expect(isExternalTaskSnapshot(task)).toBe(false);
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('does not treat personal placement metadata alone as an external snapshot', () => {
|
|
156
|
+
expect(
|
|
157
|
+
isExternalTaskSnapshot({
|
|
158
|
+
...task,
|
|
159
|
+
personal_board_id: 'board-1',
|
|
160
|
+
personal_list_id: 'list-1',
|
|
161
|
+
})
|
|
162
|
+
).toBe(false);
|
|
163
|
+
});
|
|
154
164
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
2
2
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
3
|
+
import { isPersonalExternalOverlayTask } from '../../../../../../lib/task-personal-external';
|
|
3
4
|
import type { SharedTaskContext } from '../../../shared/task-edit-dialog/hooks/use-task-data';
|
|
4
5
|
|
|
5
6
|
interface TaskCardOpenOptionsInput {
|
|
@@ -13,9 +14,12 @@ interface TaskCardOpenOptionsInput {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export function isExternalTaskSnapshot(task: Task) {
|
|
17
|
+
if (task.is_personal_external === false) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
return (
|
|
17
|
-
task
|
|
18
|
-
Boolean(task.personal_board_id) ||
|
|
22
|
+
isPersonalExternalOverlayTask(task) ||
|
|
19
23
|
Boolean(task.source_workspace_id) ||
|
|
20
24
|
Boolean(task.source_board_id)
|
|
21
25
|
);
|
|
@@ -131,7 +135,7 @@ export function getTaskCardHydratingOpenOptions({
|
|
|
131
135
|
taskWorkspacePersonal: sourceWorkspaceId ? false : isPersonalWorkspace,
|
|
132
136
|
canUseBoardAssignees: canUseBoardAssignees ?? sourceBoardAssigneesEnabled,
|
|
133
137
|
assigneeMemberSource:
|
|
134
|
-
assigneeMemberSource ?? (sourceWorkspaceId ? '
|
|
138
|
+
assigneeMemberSource ?? (sourceWorkspaceId ? 'board' : undefined),
|
|
135
139
|
initialSharedContext,
|
|
136
140
|
};
|
|
137
141
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
2
|
+
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { getTaskCardResourceContext } from './task-card-resource-context';
|
|
5
|
+
|
|
6
|
+
const pageList = {
|
|
7
|
+
id: 'page-list',
|
|
8
|
+
name: 'Page list',
|
|
9
|
+
board_id: 'page-board',
|
|
10
|
+
position: 0,
|
|
11
|
+
status: 'not_started',
|
|
12
|
+
color: 'BLUE',
|
|
13
|
+
created_at: '2026-07-04T00:00:00.000Z',
|
|
14
|
+
creator_id: 'user-1',
|
|
15
|
+
archived: false,
|
|
16
|
+
deleted: false,
|
|
17
|
+
} satisfies TaskList;
|
|
18
|
+
|
|
19
|
+
const task = {
|
|
20
|
+
id: 'task-1',
|
|
21
|
+
name: 'Task',
|
|
22
|
+
description: '',
|
|
23
|
+
list_id: 'page-list',
|
|
24
|
+
display_number: 1,
|
|
25
|
+
created_at: '2026-07-04T00:00:00.000Z',
|
|
26
|
+
end_date: null,
|
|
27
|
+
priority: 'normal',
|
|
28
|
+
} satisfies Task;
|
|
29
|
+
|
|
30
|
+
describe('getTaskCardResourceContext', () => {
|
|
31
|
+
it('keeps local task resources on the page workspace and board', () => {
|
|
32
|
+
expect(
|
|
33
|
+
getTaskCardResourceContext({
|
|
34
|
+
boardId: 'page-board',
|
|
35
|
+
pageWorkspaceId: 'page-workspace',
|
|
36
|
+
propAvailableLists: [pageList],
|
|
37
|
+
task,
|
|
38
|
+
})
|
|
39
|
+
).toEqual({
|
|
40
|
+
boardViewableMembersBoardId: 'page-board',
|
|
41
|
+
boardViewableMembersWorkspaceId: 'page-workspace',
|
|
42
|
+
effectiveWorkspaceId: 'page-workspace',
|
|
43
|
+
initialAvailableLists: [pageList],
|
|
44
|
+
isSourceWorkspaceTask: false,
|
|
45
|
+
taskBoardId: 'page-board',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('routes source task labels, projects, lists, and members to the source workspace and board', () => {
|
|
50
|
+
expect(
|
|
51
|
+
getTaskCardResourceContext({
|
|
52
|
+
boardId: 'personal-board',
|
|
53
|
+
pageWorkspaceId: 'personal-workspace',
|
|
54
|
+
propAvailableLists: [pageList],
|
|
55
|
+
task: {
|
|
56
|
+
...task,
|
|
57
|
+
list_id: 'personal-list',
|
|
58
|
+
source_board_id: 'source-board',
|
|
59
|
+
source_workspace_id: 'source-workspace',
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
).toEqual({
|
|
63
|
+
boardViewableMembersBoardId: 'source-board',
|
|
64
|
+
boardViewableMembersWorkspaceId: 'source-workspace',
|
|
65
|
+
effectiveWorkspaceId: 'source-workspace',
|
|
66
|
+
initialAvailableLists: undefined,
|
|
67
|
+
isSourceWorkspaceTask: true,
|
|
68
|
+
taskBoardId: 'source-board',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('uses the page workspace when a source board is present without source workspace metadata', () => {
|
|
73
|
+
expect(
|
|
74
|
+
getTaskCardResourceContext({
|
|
75
|
+
boardId: 'page-board',
|
|
76
|
+
pageWorkspaceId: 'page-workspace',
|
|
77
|
+
propAvailableLists: [pageList],
|
|
78
|
+
task: {
|
|
79
|
+
...task,
|
|
80
|
+
source_board_id: 'source-board',
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
).toMatchObject({
|
|
84
|
+
boardViewableMembersBoardId: 'source-board',
|
|
85
|
+
boardViewableMembersWorkspaceId: 'page-workspace',
|
|
86
|
+
effectiveWorkspaceId: 'page-workspace',
|
|
87
|
+
initialAvailableLists: undefined,
|
|
88
|
+
isSourceWorkspaceTask: true,
|
|
89
|
+
taskBoardId: 'source-board',
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps page board list data when only source workspace metadata is available', () => {
|
|
94
|
+
expect(
|
|
95
|
+
getTaskCardResourceContext({
|
|
96
|
+
boardId: 'page-board',
|
|
97
|
+
pageWorkspaceId: 'page-workspace',
|
|
98
|
+
propAvailableLists: [pageList],
|
|
99
|
+
task: {
|
|
100
|
+
...task,
|
|
101
|
+
source_workspace_id: 'source-workspace',
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
).toMatchObject({
|
|
105
|
+
boardViewableMembersBoardId: 'page-board',
|
|
106
|
+
boardViewableMembersWorkspaceId: 'source-workspace',
|
|
107
|
+
effectiveWorkspaceId: 'source-workspace',
|
|
108
|
+
initialAvailableLists: [pageList],
|
|
109
|
+
isSourceWorkspaceTask: true,
|
|
110
|
+
taskBoardId: 'page-board',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|