@tuturuuu/ui 0.13.0 → 0.16.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 +90 -0
- package/biome.json +1 -1
- package/package.json +62 -62
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +12 -0
- package/src/components/ui/custom/__tests__/sidebar-context.test.tsx +93 -6
- package/src/components/ui/custom/loading-statistic-card.tsx +18 -0
- package/src/components/ui/custom/notification-popover-client.tsx +5 -1
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.test.tsx +34 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +106 -0
- package/src/components/ui/custom/settings/sidebar-settings.tsx +18 -6
- package/src/components/ui/custom/settings/task-settings.tsx +55 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +18 -12
- package/src/components/ui/custom/sidebar-context.tsx +58 -3
- package/src/components/ui/custom/sidebar-remote-behavior-bridge.tsx +5 -3
- package/src/components/ui/custom/statistic-card.tsx +104 -0
- package/src/components/ui/custom/structure.test.tsx +29 -0
- package/src/components/ui/custom/structure.tsx +103 -88
- package/src/components/ui/finance/invoices/civil-month.ts +224 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.test.tsx +49 -0
- package/src/components/ui/finance/invoices/components/subscription-invoice-proposal.tsx +81 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/new-invoice-page.test.tsx +90 -2
- package/src/components/ui/finance/invoices/new-invoice-page.tsx +40 -4
- package/src/components/ui/finance/invoices/pending-columns.test.ts +26 -0
- package/src/components/ui/finance/invoices/pending-columns.tsx +45 -14
- package/src/components/ui/finance/invoices/standard-invoice.test.ts +31 -0
- package/src/components/ui/finance/invoices/standard-invoice.tsx +33 -16
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +331 -0
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +53 -28
- package/src/components/ui/finance/invoices/utils.test.ts +84 -0
- package/src/components/ui/finance/invoices/utils.ts +56 -134
- package/src/components/ui/resizable.test.ts +15 -0
- package/src/components/ui/resizable.tsx +28 -6
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +4 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/bulk/__tests__/bulk-mutations-move.test.tsx +62 -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 +48 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +50 -6
- 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/menus/__tests__/task-menus.test.tsx +41 -3
- package/src/components/ui/tu-do/boards/boardId/menus/task-due-date-menu.tsx +31 -10
- package/src/components/ui/tu-do/boards/boardId/menus/task-priority-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/menus/task-scheduling-menu.tsx +7 -5
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.test.ts +44 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-archive-visibility.ts +17 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.test.ts +2 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card-open-options.ts +7 -0
- package/src/components/ui/tu-do/boards/boardId/task-card/task-card.tsx +44 -30
- package/src/components/ui/tu-do/my-tasks/__tests__/use-task-context-actions.test.ts +54 -0
- package/src/components/ui/tu-do/my-tasks/task-list-with-completion.tsx +43 -1
- package/src/components/ui/tu-do/my-tasks/use-task-context-actions.ts +33 -15
- package/src/components/ui/tu-do/progress/task-progress-leaderboards-panel.tsx +3 -3
- package/src/components/ui/tu-do/progress/task-progress-page.tsx +74 -28
- package/src/components/ui/tu-do/progress/task-progress-panels.tsx +12 -10
- package/src/components/ui/tu-do/projects/components/project-grid-card.tsx +22 -6
- package/src/components/ui/tu-do/projects/components/project-list-item.tsx +24 -8
- package/src/components/ui/tu-do/projects/projectId/components/__tests__/tasks-tab-layout.test.tsx +6 -0
- package/src/components/ui/tu-do/projects/projectId/components/tasks-tab.tsx +3 -1
- package/src/components/ui/tu-do/projects/projectId/task-project-detail-page-client.tsx +11 -3
- package/src/components/ui/tu-do/projects/projectId/task-project-detail.tsx +2 -0
- package/src/components/ui/tu-do/projects/projectId/types.ts +1 -0
- package/src/components/ui/tu-do/projects/task-projects-client.tsx +45 -1
- package/src/components/ui/tu-do/projects/types.ts +4 -1
- package/src/components/ui/tu-do/providers/task-dialog-provider.tsx +10 -0
- package/src/components/ui/tu-do/shared/__tests__/board-client.test.tsx +7 -1
- package/src/components/ui/tu-do/shared/__tests__/board-header.test.tsx +87 -2
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +70 -0
- package/src/components/ui/tu-do/shared/__tests__/task-board-loading-state.test.tsx +27 -3
- package/src/components/ui/tu-do/shared/__tests__/task-dialog-manager.test.tsx +41 -3
- package/src/components/ui/tu-do/shared/__tests__/use-progressive-board-loader.test.tsx +54 -0
- package/src/components/ui/tu-do/shared/board-header.tsx +20 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +33 -3
- package/src/components/ui/tu-do/shared/task-board-loading-state.tsx +4 -4
- package/src/components/ui/tu-do/shared/task-dialog-manager.tsx +2 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-dialog-header.tsx +7 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/__tests__/use-task-mutations.test.tsx +231 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-mutations.ts +139 -68
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.test.tsx +68 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/task-dialog-actions.tsx +17 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +45 -23
- 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.test.tsx +138 -0
- package/src/components/ui/tu-do/shared/task-row-actions-menu.tsx +15 -9
- package/src/components/ui/tu-do/templates/client.tsx +97 -78
- package/src/components/ui/tu-do/templates/marketplace/client.tsx +171 -135
- package/src/components/ui/tu-do/templates/task-templates-hub.tsx +3 -0
- package/src/components/ui/tu-do/templates/templateId/client.tsx +24 -9
- package/src/components/ui/tu-do/templates/templateId/task-template-detail-page-client.tsx +8 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -0
- package/src/hooks/__tests__/use-task-actions.test.tsx +14 -39
- package/src/hooks/task-actions-personal-external.ts +12 -6
- package/src/hooks/use-notifications.ts +14 -2
- package/src/hooks/use-user-config.ts +10 -2
- package/vitest.config.ts +4 -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
|
}
|
|
@@ -20,6 +20,7 @@ vi.mock('@tuturuuu/utils/task-helper', () => {
|
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
applyTaskDropPreviewToCache,
|
|
23
|
+
getPersonalPlacementTargetBoardId,
|
|
23
24
|
getProjectedTaskDropOrderFromPreview,
|
|
24
25
|
getTaskDropEndPreviewFromRects,
|
|
25
26
|
getTaskDropPositionFromRects,
|
|
@@ -178,6 +179,53 @@ describe('usesPersonalPlacement', () => {
|
|
|
178
179
|
});
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
describe('getPersonalPlacementTargetBoardId', () => {
|
|
183
|
+
it('uses the target list board id for real-list moves', () => {
|
|
184
|
+
expect(
|
|
185
|
+
getPersonalPlacementTargetBoardId({
|
|
186
|
+
boardId: 'fallback-board',
|
|
187
|
+
columns: [
|
|
188
|
+
createList({
|
|
189
|
+
board_id: 'canonical-board',
|
|
190
|
+
id: 'target-list',
|
|
191
|
+
} as Partial<TaskList>),
|
|
192
|
+
],
|
|
193
|
+
targetListId: 'target-list',
|
|
194
|
+
})
|
|
195
|
+
).toBe('canonical-board');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('keeps staging board ids from virtual staging lanes', () => {
|
|
199
|
+
expect(
|
|
200
|
+
getPersonalPlacementTargetBoardId({
|
|
201
|
+
boardId: 'fallback-board',
|
|
202
|
+
columns: [
|
|
203
|
+
createList({
|
|
204
|
+
board_id: 'canonical-board',
|
|
205
|
+
id: 'target-list',
|
|
206
|
+
} as Partial<TaskList>),
|
|
207
|
+
],
|
|
208
|
+
targetListId: 'personal-external-staging:staging-board',
|
|
209
|
+
})
|
|
210
|
+
).toBe('staging-board');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('falls back to the current board id when list metadata is absent', () => {
|
|
214
|
+
expect(
|
|
215
|
+
getPersonalPlacementTargetBoardId({
|
|
216
|
+
boardId: 'fallback-board',
|
|
217
|
+
columns: [
|
|
218
|
+
createList({
|
|
219
|
+
board_id: 'other-board',
|
|
220
|
+
id: 'other-list',
|
|
221
|
+
} as Partial<TaskList>),
|
|
222
|
+
],
|
|
223
|
+
targetListId: 'target-list',
|
|
224
|
+
})
|
|
225
|
+
).toBe('fallback-board');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
181
229
|
describe('mergeTaskIntoBoardTaskCache', () => {
|
|
182
230
|
it('updates an existing task in the board cache', () => {
|
|
183
231
|
const existingTask = createTask({ list_id: 'source-list' });
|
|
@@ -25,7 +25,7 @@ import { isPersonalExternalOverlayTask } from '../../../../../../../lib/task-per
|
|
|
25
25
|
import { useBoardBroadcast } from '../../../../shared/board-broadcast-context';
|
|
26
26
|
import { invalidateKanbanDeadlineTasks } from '../data/kanban-deadline-query';
|
|
27
27
|
import { MAX_SAFE_INTEGER_SORT } from '../kanban-constants';
|
|
28
|
-
import { useAutoScroll } from './auto-scroll';
|
|
28
|
+
import { getKanbanDragAutoScrollPointerX, useAutoScroll } from './auto-scroll';
|
|
29
29
|
import { getColumnReorderUpdates } from './column-reorder';
|
|
30
30
|
import { calculateSortKeyWithRetry as createCalculateSortKeyWithRetry } from './kanban-sort-helpers';
|
|
31
31
|
import {
|
|
@@ -126,6 +126,23 @@ export function usesPersonalPlacement(task: Task) {
|
|
|
126
126
|
return isPersonalExternalOverlayTask(task);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
export function getPersonalPlacementTargetBoardId({
|
|
130
|
+
boardId,
|
|
131
|
+
columns,
|
|
132
|
+
targetListId,
|
|
133
|
+
}: {
|
|
134
|
+
boardId: string | null;
|
|
135
|
+
columns: Pick<TaskList, 'board_id' | 'id'>[];
|
|
136
|
+
targetListId: string;
|
|
137
|
+
}) {
|
|
138
|
+
const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
|
|
139
|
+
if (stagingBoardId) return stagingBoardId;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
columns.find((column) => column.id === targetListId)?.board_id ?? boardId
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
129
146
|
function getTaskDropPosition(
|
|
130
147
|
event: DragMoveEvent | DragEndEvent,
|
|
131
148
|
pointerY?: number | null,
|
|
@@ -442,8 +459,20 @@ export function useKanbanDnd({
|
|
|
442
459
|
}
|
|
443
460
|
|
|
444
461
|
const stagingBoardId = getPersonalExternalStagingBoardId(targetListId);
|
|
445
|
-
const targetBoardId =
|
|
462
|
+
const targetBoardId = getPersonalPlacementTargetBoardId({
|
|
463
|
+
boardId,
|
|
464
|
+
columns,
|
|
465
|
+
targetListId,
|
|
466
|
+
});
|
|
467
|
+
if (!targetBoardId) {
|
|
468
|
+
throw new Error('Board ID is required');
|
|
469
|
+
}
|
|
446
470
|
const isStagingTarget = stagingBoardId !== null;
|
|
471
|
+
const targetList = columns.find((column) => column.id === targetListId);
|
|
472
|
+
const terminalStatus =
|
|
473
|
+
targetList?.status === 'done' || targetList?.status === 'closed'
|
|
474
|
+
? targetList.status
|
|
475
|
+
: undefined;
|
|
447
476
|
const nextTask = {
|
|
448
477
|
...task,
|
|
449
478
|
list_id: isStagingTarget
|
|
@@ -483,6 +512,7 @@ export function useKanbanDnd({
|
|
|
483
512
|
personal_sort_key: newSortKey,
|
|
484
513
|
previous_task_id: order?.previousTaskId ?? null,
|
|
485
514
|
next_task_id: order?.nextTaskId ?? null,
|
|
515
|
+
terminal_status: terminalStatus,
|
|
486
516
|
});
|
|
487
517
|
|
|
488
518
|
if (isStagingTarget) {
|
|
@@ -540,7 +570,7 @@ export function useKanbanDnd({
|
|
|
540
570
|
throw error;
|
|
541
571
|
}
|
|
542
572
|
},
|
|
543
|
-
[boardId, queryClient]
|
|
573
|
+
[boardId, columns, queryClient]
|
|
544
574
|
);
|
|
545
575
|
|
|
546
576
|
// Use the extracted calculateSortKeyWithRetry helper
|
|
@@ -566,8 +596,15 @@ export function useKanbanDnd({
|
|
|
566
596
|
[wsId]
|
|
567
597
|
);
|
|
568
598
|
|
|
569
|
-
|
|
570
|
-
|
|
599
|
+
const { startAutoScroll, stopAutoScroll, updateAutoScrollPointerX } =
|
|
600
|
+
useAutoScroll(scrollContainerRef);
|
|
601
|
+
|
|
602
|
+
const updateAutoScrollFromDragEvent = useCallback(
|
|
603
|
+
(event: DragMoveEvent | DragOverEvent | DragStartEvent) => {
|
|
604
|
+
updateAutoScrollPointerX(getKanbanDragAutoScrollPointerX(event));
|
|
605
|
+
},
|
|
606
|
+
[updateAutoScrollPointerX]
|
|
607
|
+
);
|
|
571
608
|
|
|
572
609
|
const resetDragState = useCallback(
|
|
573
610
|
(clearOptimisticUpdates = false) => {
|
|
@@ -582,12 +619,13 @@ export function useKanbanDnd({
|
|
|
582
619
|
dragSessionMetricsRef.current = null;
|
|
583
620
|
dragStartTaskIndexesByListRef.current.clear();
|
|
584
621
|
isDraggingRef.current = false;
|
|
622
|
+
stopAutoScroll();
|
|
585
623
|
|
|
586
624
|
if (clearOptimisticUpdates) {
|
|
587
625
|
setOptimisticUpdateInProgress(new Set());
|
|
588
626
|
}
|
|
589
627
|
},
|
|
590
|
-
[setDragPreviewPosition]
|
|
628
|
+
[setDragPreviewPosition, stopAutoScroll]
|
|
591
629
|
);
|
|
592
630
|
|
|
593
631
|
const markTaskIdsPending = useCallback((taskIds: string[]) => {
|
|
@@ -606,6 +644,8 @@ export function useKanbanDnd({
|
|
|
606
644
|
|
|
607
645
|
const processTaskDragPreview = useCallback(
|
|
608
646
|
(event: DragMoveEvent) => {
|
|
647
|
+
updateAutoScrollFromDragEvent(event);
|
|
648
|
+
|
|
609
649
|
const { active, over } = event;
|
|
610
650
|
const activeType = active.data?.current?.type;
|
|
611
651
|
if (!activeType) return;
|
|
@@ -730,6 +770,7 @@ export function useKanbanDnd({
|
|
|
730
770
|
getDragPreviewForList,
|
|
731
771
|
getDragPreviewForListSurface,
|
|
732
772
|
setDragPreviewPosition,
|
|
773
|
+
updateAutoScrollFromDragEvent,
|
|
733
774
|
wsId,
|
|
734
775
|
]
|
|
735
776
|
);
|
|
@@ -742,6 +783,8 @@ export function useKanbanDnd({
|
|
|
742
783
|
|
|
743
784
|
// Enable auto-scroll
|
|
744
785
|
isDraggingRef.current = true;
|
|
786
|
+
updateAutoScrollFromDragEvent(event);
|
|
787
|
+
startAutoScroll();
|
|
745
788
|
|
|
746
789
|
const { type } = active.data.current;
|
|
747
790
|
if (type === 'Column') {
|
|
@@ -750,6 +793,7 @@ export function useKanbanDnd({
|
|
|
750
793
|
}
|
|
751
794
|
if (type === 'Task') {
|
|
752
795
|
if (!wsId) {
|
|
796
|
+
resetDragState(true);
|
|
753
797
|
return;
|
|
754
798
|
}
|
|
755
799
|
|
|
@@ -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
|
|
|
@@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
|
2
2
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
4
4
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
5
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
6
|
|
|
7
7
|
const taskSchedulingApiMock = vi.hoisted(() => ({
|
|
8
8
|
getCurrentUserTaskSchedule: vi.fn(),
|
|
@@ -67,8 +67,8 @@ vi.mock('@tuturuuu/ui/dropdown-menu', () => ({
|
|
|
67
67
|
DropdownMenuSub: ({ children }: any) => (
|
|
68
68
|
<div data-testid="dropdown-sub">{children}</div>
|
|
69
69
|
),
|
|
70
|
-
DropdownMenuSubTrigger: ({ children }: any) => (
|
|
71
|
-
<button type="button" data-testid="dropdown-trigger">
|
|
70
|
+
DropdownMenuSubTrigger: ({ children, className }: any) => (
|
|
71
|
+
<button type="button" className={className} data-testid="dropdown-trigger">
|
|
72
72
|
{children}
|
|
73
73
|
</button>
|
|
74
74
|
),
|
|
@@ -151,6 +151,10 @@ describe('TaskDueDateMenu', () => {
|
|
|
151
151
|
const mockOnMenuItemSelect = vi.fn((_, action) => action());
|
|
152
152
|
const mockOnClose = vi.fn();
|
|
153
153
|
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.useRealTimers();
|
|
156
|
+
});
|
|
157
|
+
|
|
154
158
|
it('should render due date menu', () => {
|
|
155
159
|
render(
|
|
156
160
|
<TaskDueDateMenu
|
|
@@ -200,6 +204,40 @@ describe('TaskDueDateMenu', () => {
|
|
|
200
204
|
|
|
201
205
|
expect(screen.getByText('Remove Due Date')).toBeInTheDocument();
|
|
202
206
|
});
|
|
207
|
+
|
|
208
|
+
it('should render compact due date summaries without wrapping the trigger label', () => {
|
|
209
|
+
vi.useFakeTimers();
|
|
210
|
+
vi.setSystemTime(new Date('2026-01-15T12:00:00.000Z'));
|
|
211
|
+
|
|
212
|
+
render(
|
|
213
|
+
<TaskDueDateMenu
|
|
214
|
+
endDate="2026-03-16T12:00:00.000Z"
|
|
215
|
+
isLoading={false}
|
|
216
|
+
onDueDateChange={mockOnDueDateChange}
|
|
217
|
+
onCustomDateClick={mockOnCustomDateClick}
|
|
218
|
+
onMenuItemSelect={mockOnMenuItemSelect}
|
|
219
|
+
onClose={mockOnClose}
|
|
220
|
+
/>
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const triggerLabel = screen.getByText('Due Date');
|
|
224
|
+
const compactDate = screen.getByText('in 2mo');
|
|
225
|
+
|
|
226
|
+
expect(screen.getByTestId('dropdown-trigger')).toHaveClass('min-w-0');
|
|
227
|
+
expect(triggerLabel).toHaveClass(
|
|
228
|
+
'min-w-0',
|
|
229
|
+
'flex-1',
|
|
230
|
+
'truncate',
|
|
231
|
+
'whitespace-nowrap'
|
|
232
|
+
);
|
|
233
|
+
expect(compactDate).toHaveClass(
|
|
234
|
+
'shrink-0',
|
|
235
|
+
'truncate',
|
|
236
|
+
'whitespace-nowrap',
|
|
237
|
+
'text-right'
|
|
238
|
+
);
|
|
239
|
+
expect(screen.queryByText('in about 2 months')).not.toBeInTheDocument();
|
|
240
|
+
});
|
|
203
241
|
});
|
|
204
242
|
|
|
205
243
|
describe('task scheduling utilities', () => {
|
|
@@ -7,9 +7,10 @@ import {
|
|
|
7
7
|
DropdownMenuSubTrigger,
|
|
8
8
|
} from '@tuturuuu/ui/dropdown-menu';
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
differenceInCalendarDays,
|
|
11
11
|
isToday,
|
|
12
12
|
isTomorrow,
|
|
13
|
+
isValid,
|
|
13
14
|
isYesterday,
|
|
14
15
|
} from 'date-fns';
|
|
15
16
|
import { calculateDaysUntilEndOfWeek } from '../../../utils/weekDateUtils';
|
|
@@ -55,6 +56,22 @@ const calculateDaysForPreset = (
|
|
|
55
56
|
}
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const formatCompactRelativeDate = (date: Date, now = new Date()) => {
|
|
60
|
+
const days = differenceInCalendarDays(date, now);
|
|
61
|
+
const absoluteDays = Math.abs(days);
|
|
62
|
+
|
|
63
|
+
const [value, unit] =
|
|
64
|
+
absoluteDays < 7
|
|
65
|
+
? [absoluteDays, 'd']
|
|
66
|
+
: absoluteDays < 30
|
|
67
|
+
? [Math.max(1, Math.round(absoluteDays / 7)), 'wk']
|
|
68
|
+
: absoluteDays < 365
|
|
69
|
+
? [Math.max(1, Math.round(absoluteDays / 30)), 'mo']
|
|
70
|
+
: [Math.max(1, Math.round(absoluteDays / 365)), 'y'];
|
|
71
|
+
|
|
72
|
+
return days > 0 ? `in ${value}${unit}` : `${value}${unit} ago`;
|
|
73
|
+
};
|
|
74
|
+
|
|
58
75
|
export function TaskDueDateMenu({
|
|
59
76
|
endDate,
|
|
60
77
|
isLoading,
|
|
@@ -82,7 +99,7 @@ export function TaskDueDateMenu({
|
|
|
82
99
|
if (isToday(date)) return t.today;
|
|
83
100
|
if (isTomorrow(date)) return t.tomorrow;
|
|
84
101
|
if (isYesterday(date)) return t.yesterday;
|
|
85
|
-
return
|
|
102
|
+
return formatCompactRelativeDate(date);
|
|
86
103
|
};
|
|
87
104
|
|
|
88
105
|
const dueDateOptionsTranslated = [
|
|
@@ -104,19 +121,23 @@ export function TaskDueDateMenu({
|
|
|
104
121
|
},
|
|
105
122
|
];
|
|
106
123
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
const parsedEndDate = endDate ? new Date(endDate) : null;
|
|
125
|
+
const currentDateDisplay =
|
|
126
|
+
parsedEndDate && isValid(parsedEndDate)
|
|
127
|
+
? formatSmartDateTranslated(parsedEndDate)
|
|
128
|
+
: t.none;
|
|
110
129
|
|
|
111
130
|
return (
|
|
112
131
|
<DropdownMenuSub>
|
|
113
|
-
<DropdownMenuSubTrigger>
|
|
114
|
-
<div className="h-4 w-4">
|
|
132
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
133
|
+
<div className="h-4 w-4 shrink-0">
|
|
115
134
|
<Calendar className="h-4 w-4 text-dynamic-purple" />
|
|
116
135
|
</div>
|
|
117
|
-
<div className="flex w-
|
|
118
|
-
<span>
|
|
119
|
-
|
|
136
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
137
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
138
|
+
{t.dueDate}
|
|
139
|
+
</span>
|
|
140
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
120
141
|
{currentDateDisplay}
|
|
121
142
|
</span>
|
|
122
143
|
</div>
|
|
@@ -104,13 +104,15 @@ export function TaskPriorityMenu({
|
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
106
|
<DropdownMenuSub>
|
|
107
|
-
<DropdownMenuSubTrigger>
|
|
108
|
-
<div className="h-4 w-4">
|
|
107
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
108
|
+
<div className="h-4 w-4 shrink-0">
|
|
109
109
|
<Flag className="h-4 w-4 text-dynamic-orange" />
|
|
110
110
|
</div>
|
|
111
|
-
<div className="flex w-
|
|
112
|
-
<span>
|
|
113
|
-
|
|
111
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
112
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
113
|
+
{t.priority}
|
|
114
|
+
</span>
|
|
115
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
114
116
|
{currentPriority
|
|
115
117
|
? priorityLabelTranslated[currentPriority]
|
|
116
118
|
: t.none}
|
|
@@ -296,12 +296,14 @@ export function TaskSchedulingMenu({
|
|
|
296
296
|
|
|
297
297
|
return (
|
|
298
298
|
<DropdownMenuSub open={open} onOpenChange={setOpen}>
|
|
299
|
-
<DropdownMenuSubTrigger>
|
|
300
|
-
<CalendarClock className="h-4 w-4 text-dynamic-amber" />
|
|
301
|
-
<div className="flex w-
|
|
302
|
-
<span>
|
|
299
|
+
<DropdownMenuSubTrigger className="min-w-0">
|
|
300
|
+
<CalendarClock className="h-4 w-4 shrink-0 text-dynamic-amber" />
|
|
301
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
302
|
+
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
|
303
|
+
{t.schedule}
|
|
304
|
+
</span>
|
|
303
305
|
{formattedDuration && (
|
|
304
|
-
<span className="
|
|
306
|
+
<span className="max-w-20 shrink-0 truncate whitespace-nowrap text-right text-muted-foreground text-xs">
|
|
305
307
|
{formattedDuration}
|
|
306
308
|
</span>
|
|
307
309
|
)}
|