@tuturuuu/ui 0.13.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 +12 -0
- package/package.json +3 -3
- package/src/components/ui/custom/settings/task-settings.tsx +55 -0
- 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.ts +18 -4
- 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/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/task-quick-create-target-list.ts +20 -0
- package/src/hooks/use-user-config.ts +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0](https://github.com/tutur3u/platform/compare/ui-v0.13.0...ui-v0.14.0) (2026-07-06)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **tasks:** add quick-create targeting and edge autoscroll ([f03e932](https://github.com/tutur3u/platform/commit/f03e9324b0cce18e9f9974cc8fe251bb58b686bd))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **tasks:** scroll kanban board at drag edges ([1053f6c](https://github.com/tutur3u/platform/commit/1053f6cc6db9788b7e184ed8bc7febdb2adacf97))
|
|
14
|
+
|
|
3
15
|
## [0.13.0](https://github.com/tutur3u/platform/compare/ui-v0.12.0...ui-v0.13.0) (2026-07-05)
|
|
4
16
|
|
|
5
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -85,10 +85,10 @@
|
|
|
85
85
|
"@tiptap/react": "3.27.1",
|
|
86
86
|
"@tiptap/starter-kit": "3.27.1",
|
|
87
87
|
"@tuturuuu/ai": "0.2.2",
|
|
88
|
-
"@tuturuuu/apis": "0.7.
|
|
88
|
+
"@tuturuuu/apis": "0.7.2",
|
|
89
89
|
"@tuturuuu/hooks": "0.0.2",
|
|
90
90
|
"@tuturuuu/icons": "0.0.6",
|
|
91
|
-
"@tuturuuu/internal-api": "0.
|
|
91
|
+
"@tuturuuu/internal-api": "0.15.0",
|
|
92
92
|
"@tuturuuu/supabase": "0.4.0",
|
|
93
93
|
"@tuturuuu/utils": "0.13.0",
|
|
94
94
|
"@types/debug": "^4.1.13",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
TASK_NAVIGATION_LEADERBOARDS_CONFIG_ID,
|
|
8
8
|
TASK_NAVIGATION_PROGRESS_CONFIG_ID,
|
|
9
9
|
TASK_NAVIGATION_STATS_CONFIG_ID,
|
|
10
|
+
TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
|
|
10
11
|
} from '@tuturuuu/internal-api/users';
|
|
11
12
|
import type { Workspace } from '@tuturuuu/types';
|
|
12
13
|
import { SettingItemTab } from '@tuturuuu/ui/custom/settings-item-tab';
|
|
@@ -34,6 +35,11 @@ import {
|
|
|
34
35
|
type TaskDialogPresentation,
|
|
35
36
|
} from '../../tu-do/shared/task-dialog-presentation';
|
|
36
37
|
import { TASKS_SHOW_REVIEW_DUE_DATES_CONFIG_ID } from '../../tu-do/shared/task-due-date-visibility';
|
|
38
|
+
import {
|
|
39
|
+
DEFAULT_TASK_QUICK_CREATE_TARGET_LIST,
|
|
40
|
+
normalizeTaskQuickCreateTargetList,
|
|
41
|
+
type TaskQuickCreateTargetList,
|
|
42
|
+
} from '../../tu-do/shared/task-quick-create-target-list';
|
|
37
43
|
import {
|
|
38
44
|
clampTaskSoundEffectsVolume,
|
|
39
45
|
DEFAULT_TASK_SOUND_EFFECTS_VOLUME,
|
|
@@ -142,6 +148,14 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
142
148
|
const { data: dialogPresentationRaw, isLoading: dialogPresentationLoading } =
|
|
143
149
|
useUserConfig(TASK_DIALOG_DEFAULT_PRESENTATION_CONFIG_ID, 'compact');
|
|
144
150
|
const updateDialogPresentation = useUpdateUserConfig();
|
|
151
|
+
const {
|
|
152
|
+
data: quickCreateTargetListRaw,
|
|
153
|
+
isLoading: quickCreateTargetListLoading,
|
|
154
|
+
} = useUserConfig(
|
|
155
|
+
TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
|
|
156
|
+
DEFAULT_TASK_QUICK_CREATE_TARGET_LIST
|
|
157
|
+
);
|
|
158
|
+
const updateQuickCreateTargetList = useUpdateUserConfig();
|
|
145
159
|
|
|
146
160
|
const { data: settings, isLoading } = useQuery({
|
|
147
161
|
queryKey: ['user-task-settings'],
|
|
@@ -211,6 +225,9 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
211
225
|
const dialogPresentation = normalizeTaskDialogPresentation(
|
|
212
226
|
dialogPresentationRaw
|
|
213
227
|
);
|
|
228
|
+
const quickCreateTargetList = normalizeTaskQuickCreateTargetList(
|
|
229
|
+
quickCreateTargetListRaw
|
|
230
|
+
);
|
|
214
231
|
|
|
215
232
|
const handleDialogPresentationChange = (value: string) => {
|
|
216
233
|
const nextValue: TaskDialogPresentation = normalizeTaskDialogPresentation(
|
|
@@ -223,6 +240,15 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
223
240
|
});
|
|
224
241
|
};
|
|
225
242
|
|
|
243
|
+
const handleQuickCreateTargetListChange = (value: string) => {
|
|
244
|
+
const nextValue: TaskQuickCreateTargetList =
|
|
245
|
+
normalizeTaskQuickCreateTargetList(value);
|
|
246
|
+
updateQuickCreateTargetList.mutate({
|
|
247
|
+
configId: TASK_QUICK_CREATE_TARGET_LIST_CONFIG_ID,
|
|
248
|
+
value: nextValue,
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
|
|
226
252
|
return (
|
|
227
253
|
<div className="space-y-8">
|
|
228
254
|
<div className="grid gap-6">
|
|
@@ -330,6 +356,35 @@ export function TaskSettings({ workspace }: TaskSettingsProps) {
|
|
|
330
356
|
</Select>
|
|
331
357
|
</SettingItemTab>
|
|
332
358
|
<Separator />
|
|
359
|
+
<SettingItemTab
|
|
360
|
+
title={t('quick_create_target_list')}
|
|
361
|
+
description={t('quick_create_target_list_description')}
|
|
362
|
+
>
|
|
363
|
+
<Select
|
|
364
|
+
value={quickCreateTargetList}
|
|
365
|
+
onValueChange={handleQuickCreateTargetListChange}
|
|
366
|
+
disabled={
|
|
367
|
+
quickCreateTargetListLoading ||
|
|
368
|
+
updateQuickCreateTargetList.isPending
|
|
369
|
+
}
|
|
370
|
+
>
|
|
371
|
+
<SelectTrigger
|
|
372
|
+
aria-label={t('quick_create_target_list')}
|
|
373
|
+
className="w-44"
|
|
374
|
+
>
|
|
375
|
+
<SelectValue />
|
|
376
|
+
</SelectTrigger>
|
|
377
|
+
<SelectContent>
|
|
378
|
+
<SelectItem value="default_list">
|
|
379
|
+
{t('quick_create_target_list_default')}
|
|
380
|
+
</SelectItem>
|
|
381
|
+
<SelectItem value="hovered_list">
|
|
382
|
+
{t('quick_create_target_list_hovered')}
|
|
383
|
+
</SelectItem>
|
|
384
|
+
</SelectContent>
|
|
385
|
+
</Select>
|
|
386
|
+
</SettingItemTab>
|
|
387
|
+
<Separator />
|
|
333
388
|
<SettingItemTab
|
|
334
389
|
title={t('draft_mode')}
|
|
335
390
|
description={t('draft_mode_description')}
|
|
@@ -182,6 +182,7 @@ interface BoardColumnProps {
|
|
|
182
182
|
specialPinned?: boolean;
|
|
183
183
|
specialStickyOffset?: string;
|
|
184
184
|
onSpecialPinnedChange?: (pinned: boolean) => void;
|
|
185
|
+
onHoverTaskList?: (listId: string) => void;
|
|
185
186
|
readOnly?: boolean;
|
|
186
187
|
}
|
|
187
188
|
|
|
@@ -214,6 +215,7 @@ export function BoardColumn({
|
|
|
214
215
|
specialPinned = false,
|
|
215
216
|
specialStickyOffset,
|
|
216
217
|
onSpecialPinnedChange,
|
|
218
|
+
onHoverTaskList,
|
|
217
219
|
readOnly = false,
|
|
218
220
|
}: BoardColumnProps) {
|
|
219
221
|
const t = useTranslations('common');
|
|
@@ -530,6 +532,7 @@ export function BoardColumn({
|
|
|
530
532
|
data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
|
|
531
533
|
data-kanban-column-id={column.id}
|
|
532
534
|
data-kanban-real-column={isExternalStaging ? undefined : 'true'}
|
|
535
|
+
onPointerEnter={() => onHoverTaskList?.(column.id)}
|
|
533
536
|
className={cn(
|
|
534
537
|
'group flex h-full w-14 shrink-0 snap-start flex-col items-center rounded-xl border border-dashed transition-all duration-200',
|
|
535
538
|
'touch-none select-none overflow-hidden hover:shadow-md',
|
|
@@ -583,6 +586,7 @@ export function BoardColumn({
|
|
|
583
586
|
data-kanban-pinned-special={specialStickyOffset ? 'true' : undefined}
|
|
584
587
|
data-kanban-column-id={column.id}
|
|
585
588
|
data-kanban-real-column={isExternalStaging ? undefined : 'true'}
|
|
589
|
+
onPointerEnter={() => onHoverTaskList?.(column.id)}
|
|
586
590
|
className={cn(
|
|
587
591
|
'group flex h-full w-[var(--kanban-column-width)] shrink-0 snap-start flex-col rounded-xl transition-all duration-200 last:snap-end',
|
|
588
592
|
'touch-none select-none',
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { DragMoveEvent } from '@dnd-kit/core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
getKanbanDragAutoScrollPointerX,
|
|
5
|
+
getKanbanEdgeAutoScrollAmount,
|
|
6
|
+
} from './auto-scroll';
|
|
7
|
+
|
|
8
|
+
const rect = {
|
|
9
|
+
left: 100,
|
|
10
|
+
right: 500,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createDragMoveEvent({
|
|
14
|
+
deltaX = 0,
|
|
15
|
+
initialLeft = 200,
|
|
16
|
+
pointerStartX,
|
|
17
|
+
translatedLeft = initialLeft,
|
|
18
|
+
width = 80,
|
|
19
|
+
}: {
|
|
20
|
+
deltaX?: number;
|
|
21
|
+
initialLeft?: number;
|
|
22
|
+
pointerStartX?: number;
|
|
23
|
+
translatedLeft?: number | null;
|
|
24
|
+
width?: number;
|
|
25
|
+
}) {
|
|
26
|
+
return {
|
|
27
|
+
active: {
|
|
28
|
+
rect: {
|
|
29
|
+
current: {
|
|
30
|
+
initial: {
|
|
31
|
+
left: initialLeft,
|
|
32
|
+
width,
|
|
33
|
+
},
|
|
34
|
+
translated:
|
|
35
|
+
translatedLeft === null
|
|
36
|
+
? null
|
|
37
|
+
: {
|
|
38
|
+
left: translatedLeft,
|
|
39
|
+
width,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
activatorEvent:
|
|
45
|
+
pointerStartX === undefined
|
|
46
|
+
? ({ type: 'keydown' } as Event)
|
|
47
|
+
: ({ clientX: pointerStartX } as unknown as Event),
|
|
48
|
+
delta: {
|
|
49
|
+
x: deltaX,
|
|
50
|
+
y: 0,
|
|
51
|
+
},
|
|
52
|
+
} as unknown as DragMoveEvent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('getKanbanEdgeAutoScrollAmount', () => {
|
|
56
|
+
it('scrolls left when the drag center is near the left edge', () => {
|
|
57
|
+
expect(
|
|
58
|
+
getKanbanEdgeAutoScrollAmount(125, rect, {
|
|
59
|
+
threshold: 100,
|
|
60
|
+
speed: 10,
|
|
61
|
+
maxSpeed: 30,
|
|
62
|
+
})
|
|
63
|
+
).toBeLessThan(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('scrolls right when the drag center is near the right edge', () => {
|
|
67
|
+
expect(
|
|
68
|
+
getKanbanEdgeAutoScrollAmount(475, rect, {
|
|
69
|
+
threshold: 100,
|
|
70
|
+
speed: 10,
|
|
71
|
+
maxSpeed: 30,
|
|
72
|
+
})
|
|
73
|
+
).toBeGreaterThan(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('does nothing away from horizontal edges or without a drag center', () => {
|
|
77
|
+
expect(getKanbanEdgeAutoScrollAmount(300, rect)).toBe(0);
|
|
78
|
+
expect(getKanbanEdgeAutoScrollAmount(null, rect)).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('uses pointer position for edge auto-scroll instead of card center', () => {
|
|
82
|
+
const pointerX = getKanbanDragAutoScrollPointerX(
|
|
83
|
+
createDragMoveEvent({
|
|
84
|
+
deltaX: 215,
|
|
85
|
+
pointerStartX: 260,
|
|
86
|
+
translatedLeft: 190,
|
|
87
|
+
width: 80,
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(pointerX).toBe(475);
|
|
92
|
+
expect(
|
|
93
|
+
getKanbanEdgeAutoScrollAmount(pointerX, rect, {
|
|
94
|
+
threshold: 100,
|
|
95
|
+
speed: 10,
|
|
96
|
+
maxSpeed: 30,
|
|
97
|
+
})
|
|
98
|
+
).toBeGreaterThan(0);
|
|
99
|
+
expect(
|
|
100
|
+
getKanbanEdgeAutoScrollAmount(230, rect, {
|
|
101
|
+
threshold: 100,
|
|
102
|
+
speed: 10,
|
|
103
|
+
maxSpeed: 30,
|
|
104
|
+
})
|
|
105
|
+
).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('falls back to the active card center without pointer coordinates', () => {
|
|
109
|
+
expect(
|
|
110
|
+
getKanbanDragAutoScrollPointerX(
|
|
111
|
+
createDragMoveEvent({
|
|
112
|
+
translatedLeft: 370,
|
|
113
|
+
width: 100,
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
).toBe(420);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -566,8 +566,15 @@ export function useKanbanDnd({
|
|
|
566
566
|
[wsId]
|
|
567
567
|
);
|
|
568
568
|
|
|
569
|
-
|
|
570
|
-
|
|
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
|
+
);
|
|
571
578
|
|
|
572
579
|
const resetDragState = useCallback(
|
|
573
580
|
(clearOptimisticUpdates = false) => {
|
|
@@ -582,12 +589,13 @@ export function useKanbanDnd({
|
|
|
582
589
|
dragSessionMetricsRef.current = null;
|
|
583
590
|
dragStartTaskIndexesByListRef.current.clear();
|
|
584
591
|
isDraggingRef.current = false;
|
|
592
|
+
stopAutoScroll();
|
|
585
593
|
|
|
586
594
|
if (clearOptimisticUpdates) {
|
|
587
595
|
setOptimisticUpdateInProgress(new Set());
|
|
588
596
|
}
|
|
589
597
|
},
|
|
590
|
-
[setDragPreviewPosition]
|
|
598
|
+
[setDragPreviewPosition, stopAutoScroll]
|
|
591
599
|
);
|
|
592
600
|
|
|
593
601
|
const markTaskIdsPending = useCallback((taskIds: string[]) => {
|
|
@@ -606,6 +614,8 @@ export function useKanbanDnd({
|
|
|
606
614
|
|
|
607
615
|
const processTaskDragPreview = useCallback(
|
|
608
616
|
(event: DragMoveEvent) => {
|
|
617
|
+
updateAutoScrollFromDragEvent(event);
|
|
618
|
+
|
|
609
619
|
const { active, over } = event;
|
|
610
620
|
const activeType = active.data?.current?.type;
|
|
611
621
|
if (!activeType) return;
|
|
@@ -730,6 +740,7 @@ export function useKanbanDnd({
|
|
|
730
740
|
getDragPreviewForList,
|
|
731
741
|
getDragPreviewForListSurface,
|
|
732
742
|
setDragPreviewPosition,
|
|
743
|
+
updateAutoScrollFromDragEvent,
|
|
733
744
|
wsId,
|
|
734
745
|
]
|
|
735
746
|
);
|
|
@@ -742,6 +753,8 @@ export function useKanbanDnd({
|
|
|
742
753
|
|
|
743
754
|
// Enable auto-scroll
|
|
744
755
|
isDraggingRef.current = true;
|
|
756
|
+
updateAutoScrollFromDragEvent(event);
|
|
757
|
+
startAutoScroll();
|
|
745
758
|
|
|
746
759
|
const { type } = active.data.current;
|
|
747
760
|
if (type === 'Column') {
|
|
@@ -750,6 +763,7 @@ export function useKanbanDnd({
|
|
|
750
763
|
}
|
|
751
764
|
if (type === 'Task') {
|
|
752
765
|
if (!wsId) {
|
|
766
|
+
resetDragState(true);
|
|
753
767
|
return;
|
|
754
768
|
}
|
|
755
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
|
|
|
@@ -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
|
-
|
|
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
|
);
|
|
@@ -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
|
+
}
|
|
@@ -11,14 +11,22 @@ import { getUserConfig, updateUserConfig } from '@tuturuuu/internal-api/users';
|
|
|
11
11
|
* @param defaultValue - Default value if config doesn't exist
|
|
12
12
|
* @returns Query result with the config value
|
|
13
13
|
*/
|
|
14
|
-
export function useUserConfig(
|
|
14
|
+
export function useUserConfig(
|
|
15
|
+
configId: string,
|
|
16
|
+
defaultValue: string = '',
|
|
17
|
+
options?: {
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
staleTime?: number;
|
|
20
|
+
}
|
|
21
|
+
) {
|
|
15
22
|
return useQuery({
|
|
16
23
|
queryKey: ['user-config', configId],
|
|
17
24
|
queryFn: async () => {
|
|
18
25
|
const data = await getUserConfig(configId);
|
|
19
26
|
return (data.value as string) ?? defaultValue;
|
|
20
27
|
},
|
|
21
|
-
|
|
28
|
+
enabled: options?.enabled !== false && Boolean(configId),
|
|
29
|
+
staleTime: options?.staleTime ?? 5 * 60 * 1000, // 5 minutes
|
|
22
30
|
});
|
|
23
31
|
}
|
|
24
32
|
|