@tuturuuu/ui 0.7.0 → 0.8.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 +48 -0
- package/package.json +8 -8
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/storefront/cart-summary.tsx +114 -29
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +2 -8
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
- package/src/components/ui/storefront/storefront-surface.tsx +333 -133
- package/src/components/ui/storefront/types.ts +23 -1
- package/src/components/ui/storefront/utils.ts +111 -27
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- package/src/hooks/useTaskUserRealtime.ts +5 -3
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getWorkspaceTaskBoard,
|
|
3
|
+
InternalApiError,
|
|
4
|
+
withForwardedInternalApiAuth,
|
|
5
|
+
} from '@tuturuuu/internal-api';
|
|
6
|
+
import type {
|
|
7
|
+
Workspace,
|
|
8
|
+
WorkspaceProductTier,
|
|
9
|
+
WorkspaceTaskBoard,
|
|
10
|
+
} from '@tuturuuu/types';
|
|
1
11
|
import { BoardClient } from '@tuturuuu/ui/tu-do/shared/board-client';
|
|
2
12
|
import { getCurrentUser } from '@tuturuuu/utils/user-helper';
|
|
3
13
|
import { getWorkspace } from '@tuturuuu/utils/workspace-helper';
|
|
14
|
+
import { headers } from 'next/headers';
|
|
4
15
|
import { notFound, redirect } from 'next/navigation';
|
|
5
16
|
import type { ReactNode } from 'react';
|
|
6
17
|
|
|
@@ -14,6 +25,43 @@ interface Props {
|
|
|
14
25
|
idleBottomIsland?: ReactNode;
|
|
15
26
|
}
|
|
16
27
|
|
|
28
|
+
type AuthorizedWorkspace = Workspace & {
|
|
29
|
+
joined: boolean;
|
|
30
|
+
tier: WorkspaceProductTier | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createBoardGuestWorkspace(wsId: string): AuthorizedWorkspace {
|
|
34
|
+
return {
|
|
35
|
+
id: wsId,
|
|
36
|
+
joined: false,
|
|
37
|
+
personal: false,
|
|
38
|
+
tier: null,
|
|
39
|
+
} as AuthorizedWorkspace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function getAuthorizedBoard(wsId: string, boardId: string) {
|
|
43
|
+
try {
|
|
44
|
+
const { board } = await getWorkspaceTaskBoard(
|
|
45
|
+
wsId,
|
|
46
|
+
boardId,
|
|
47
|
+
withForwardedInternalApiAuth(await headers())
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return board as WorkspaceTaskBoard & {
|
|
51
|
+
access_type?: 'member' | 'guest';
|
|
52
|
+
};
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (
|
|
55
|
+
error instanceof InternalApiError &&
|
|
56
|
+
(error.status === 401 || error.status === 403 || error.status === 404)
|
|
57
|
+
) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
17
65
|
/**
|
|
18
66
|
* Shared Task Board Server Page component.
|
|
19
67
|
* Handles workspace resolution and authentication.
|
|
@@ -29,14 +77,20 @@ export default async function TaskBoardServerPage({
|
|
|
29
77
|
const user = await getCurrentUser();
|
|
30
78
|
if (!user) redirect('/login');
|
|
31
79
|
|
|
32
|
-
const
|
|
80
|
+
const board = await getAuthorizedBoard(id, boardId);
|
|
81
|
+
if (!board?.id) notFound();
|
|
82
|
+
|
|
83
|
+
const isMemberBoardAccess = board.access_type === 'member';
|
|
84
|
+
const workspace = isMemberBoardAccess
|
|
85
|
+
? await getWorkspace(id, { useAdmin: true })
|
|
86
|
+
: createBoardGuestWorkspace(board.ws_id);
|
|
33
87
|
if (!workspace) notFound();
|
|
34
88
|
|
|
35
89
|
return (
|
|
36
90
|
<BoardClient
|
|
37
91
|
boardId={boardId}
|
|
38
92
|
workspace={workspace}
|
|
39
|
-
workspaceTier={
|
|
93
|
+
workspaceTier={workspace.tier ?? null}
|
|
40
94
|
currentUserId={user.id}
|
|
41
95
|
routePrefix={routePrefix}
|
|
42
96
|
idleBottomIsland={idleBottomIsland}
|
|
@@ -14,6 +14,15 @@ export const HANDLE_WIDTH = 12;
|
|
|
14
14
|
export const DRAG_ACTIVATION_PX = 6;
|
|
15
15
|
export const COLLAPSED_UNSCHEDULED_PREVIEW_COUNT = 4;
|
|
16
16
|
|
|
17
|
+
export function getTimelineDayGridBackground(dayWidth: number, opacity = 0.45) {
|
|
18
|
+
const safeDayWidth = Math.max(1, dayWidth);
|
|
19
|
+
const lineStart = Math.max(0, safeDayWidth - 1);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
backgroundImage: `repeating-linear-gradient(to right, transparent 0, transparent ${lineStart}px, hsl(var(--border) / ${opacity}) ${lineStart}px, hsl(var(--border) / ${opacity}) ${safeDayWidth}px)`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export function getDensityConfig(density: Density) {
|
|
18
27
|
switch (density) {
|
|
19
28
|
case 'compact':
|
|
@@ -6,7 +6,10 @@ import { cn } from '@tuturuuu/utils/format';
|
|
|
6
6
|
import dayjs from 'dayjs';
|
|
7
7
|
import type { useTranslations } from 'next-intl';
|
|
8
8
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getListStatusBadgeClasses,
|
|
11
|
+
getTimelineDayGridBackground,
|
|
12
|
+
} from './timeline-display';
|
|
10
13
|
import { TimelineTaskRow } from './timeline-task-row';
|
|
11
14
|
import type {
|
|
12
15
|
TimelineInteractionMode,
|
|
@@ -197,7 +200,6 @@ export function TimelineGrid({
|
|
|
197
200
|
groupId={group.id}
|
|
198
201
|
localTasks={localTasks}
|
|
199
202
|
dayWidth={dayWidth}
|
|
200
|
-
timelineWidth={timelineWidth}
|
|
201
203
|
groupHeaderHeight={groupHeaderHeight}
|
|
202
204
|
isMoveTargetGroup={isMoveTargetGroup}
|
|
203
205
|
isPreviewGroup={isPreviewGroup}
|
|
@@ -257,7 +259,6 @@ export function TimelineGrid({
|
|
|
257
259
|
groupId={group.id}
|
|
258
260
|
localTasks={localTasks}
|
|
259
261
|
dayWidth={dayWidth}
|
|
260
|
-
timelineWidth={timelineWidth}
|
|
261
262
|
groupHeaderHeight={rowHeight}
|
|
262
263
|
isMoveTargetGroup={isMoveTargetGroup}
|
|
263
264
|
isPreviewGroup={isPreviewGroup}
|
|
@@ -281,7 +282,6 @@ function TimelineGroupDropTarget({
|
|
|
281
282
|
groupId,
|
|
282
283
|
localTasks,
|
|
283
284
|
dayWidth,
|
|
284
|
-
timelineWidth,
|
|
285
285
|
groupHeaderHeight,
|
|
286
286
|
isMoveTargetGroup,
|
|
287
287
|
isPreviewGroup,
|
|
@@ -295,7 +295,6 @@ function TimelineGroupDropTarget({
|
|
|
295
295
|
groupId: string;
|
|
296
296
|
localTasks: Task[];
|
|
297
297
|
dayWidth: number;
|
|
298
|
-
timelineWidth: number;
|
|
299
298
|
groupHeaderHeight: number;
|
|
300
299
|
isMoveTargetGroup: boolean;
|
|
301
300
|
isPreviewGroup: boolean;
|
|
@@ -364,17 +363,10 @@ function TimelineGroupDropTarget({
|
|
|
364
363
|
</div>
|
|
365
364
|
);
|
|
366
365
|
})()}
|
|
367
|
-
<div
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
<div
|
|
372
|
-
key={`${groupId}-drop-${index}`}
|
|
373
|
-
className="h-full border-border/35 border-r"
|
|
374
|
-
style={{ width: dayWidth }}
|
|
375
|
-
/>
|
|
376
|
-
))}
|
|
377
|
-
</div>
|
|
366
|
+
<div
|
|
367
|
+
className="pointer-events-none absolute inset-0"
|
|
368
|
+
style={getTimelineDayGridBackground(dayWidth, 0.35)}
|
|
369
|
+
/>
|
|
378
370
|
</div>
|
|
379
371
|
);
|
|
380
372
|
}
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
DropdownMenuSubTrigger,
|
|
19
19
|
} from '@tuturuuu/ui/dropdown-menu';
|
|
20
20
|
import { cn } from '@tuturuuu/utils/format';
|
|
21
|
-
import dayjs from 'dayjs';
|
|
22
21
|
import type { useTranslations } from 'next-intl';
|
|
23
22
|
import type { PointerEvent as ReactPointerEvent } from 'react';
|
|
24
23
|
import { useState } from 'react';
|
|
@@ -26,6 +25,7 @@ import { TaskRowActionsMenu } from '../../../shared/task-row-actions-menu';
|
|
|
26
25
|
import {
|
|
27
26
|
getStatusToneClasses,
|
|
28
27
|
getTaskEyebrow,
|
|
28
|
+
getTimelineDayGridBackground,
|
|
29
29
|
HANDLE_WIDTH,
|
|
30
30
|
} from './timeline-display';
|
|
31
31
|
import type {
|
|
@@ -263,30 +263,10 @@ export function TimelineTaskRow({
|
|
|
263
263
|
style={{ minHeight: rowHeight }}
|
|
264
264
|
onDoubleClick={() => onOpenEditor(item.task)}
|
|
265
265
|
>
|
|
266
|
-
<div
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const isToday = todayVisible && index === todayIndex;
|
|
271
|
-
const date = item.start
|
|
272
|
-
? dayjs(item.start).add(index - item.offsetDays, 'day')
|
|
273
|
-
: null;
|
|
274
|
-
const isWeekend = date ? [0, 6].includes(date.day()) : false;
|
|
275
|
-
|
|
276
|
-
return (
|
|
277
|
-
<div
|
|
278
|
-
key={`${item.task.id}-${index}`}
|
|
279
|
-
className={cn(
|
|
280
|
-
'h-full border-border/45 border-r',
|
|
281
|
-
index === 0 && 'border-l border-l-border/50',
|
|
282
|
-
isWeekend && 'bg-muted/[0.12]',
|
|
283
|
-
isToday && 'bg-dynamic-blue/[0.035]'
|
|
284
|
-
)}
|
|
285
|
-
style={{ width: dayWidth }}
|
|
286
|
-
/>
|
|
287
|
-
);
|
|
288
|
-
})}
|
|
289
|
-
</div>
|
|
266
|
+
<div
|
|
267
|
+
className="pointer-events-none absolute inset-0 border-border/50 border-l"
|
|
268
|
+
style={getTimelineDayGridBackground(dayWidth)}
|
|
269
|
+
/>
|
|
290
270
|
|
|
291
271
|
{todayVisible && (
|
|
292
272
|
<div
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { Task } from '@tuturuuu/types/primitives/Task';
|
|
2
2
|
import type { TaskList } from '@tuturuuu/types/primitives/TaskList';
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildTimelineModel,
|
|
6
|
+
computeTimelineSpans,
|
|
7
|
+
MAX_TIMELINE_DAYS,
|
|
8
|
+
} from './timeline-utils';
|
|
5
9
|
|
|
6
10
|
const lists: TaskList[] = [
|
|
7
11
|
{
|
|
@@ -108,6 +112,37 @@ describe('timeline row model', () => {
|
|
|
108
112
|
);
|
|
109
113
|
});
|
|
110
114
|
|
|
115
|
+
it('caps the rendered day window for extreme scheduled task ranges', () => {
|
|
116
|
+
vi.useFakeTimers();
|
|
117
|
+
vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z'));
|
|
118
|
+
|
|
119
|
+
const model = buildTimelineModel(
|
|
120
|
+
[
|
|
121
|
+
task({
|
|
122
|
+
id: 'old-task',
|
|
123
|
+
name: 'Very old task',
|
|
124
|
+
start_date: '2020-01-01T00:00:00.000Z',
|
|
125
|
+
end_date: '2020-01-02T23:59:59.999Z',
|
|
126
|
+
}),
|
|
127
|
+
task({
|
|
128
|
+
id: 'future-task',
|
|
129
|
+
name: 'Very future task',
|
|
130
|
+
start_date: '2035-01-01T00:00:00.000Z',
|
|
131
|
+
end_date: '2035-01-02T23:59:59.999Z',
|
|
132
|
+
}),
|
|
133
|
+
],
|
|
134
|
+
lists
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(model.days).toHaveLength(MAX_TIMELINE_DAYS);
|
|
138
|
+
expect(model.rangeStart.getTime()).toBeLessThanOrEqual(
|
|
139
|
+
new Date('2026-05-07T00:00:00.000Z').getTime()
|
|
140
|
+
);
|
|
141
|
+
expect(model.rangeEnd.getTime()).toBeGreaterThanOrEqual(
|
|
142
|
+
new Date('2026-05-07T00:00:00.000Z').getTime()
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
111
146
|
it('keeps span computation stable for existing analytics exports', () => {
|
|
112
147
|
vi.useFakeTimers();
|
|
113
148
|
vi.setSystemTime(new Date('2026-05-07T12:00:00.000Z'));
|
|
@@ -62,6 +62,7 @@ const DEFAULT_PAST_PADDING_DAYS = 3;
|
|
|
62
62
|
const DEFAULT_FUTURE_PADDING_DAYS = 6;
|
|
63
63
|
const EMPTY_PAST_PADDING_DAYS = 7;
|
|
64
64
|
const EMPTY_FUTURE_PADDING_DAYS = 14;
|
|
65
|
+
export const MAX_TIMELINE_DAYS = 180;
|
|
65
66
|
const TIMELINE_LIST_STATUS_ORDER: Record<string, number> = {
|
|
66
67
|
not_started: 0,
|
|
67
68
|
active: 1,
|
|
@@ -126,6 +127,48 @@ export function buildMonthSegments(days: Date[]) {
|
|
|
126
127
|
return segments;
|
|
127
128
|
}
|
|
128
129
|
|
|
130
|
+
function capTimelineRange(
|
|
131
|
+
rangeStart: Date,
|
|
132
|
+
rangeEnd: Date,
|
|
133
|
+
todayMid: dayjs.Dayjs
|
|
134
|
+
) {
|
|
135
|
+
const start = dayjs(rangeStart).startOf('day');
|
|
136
|
+
const end = dayjs(rangeEnd).startOf('day');
|
|
137
|
+
const totalDays = end.diff(start, 'day') + 1;
|
|
138
|
+
|
|
139
|
+
if (totalDays <= MAX_TIMELINE_DAYS) {
|
|
140
|
+
return {
|
|
141
|
+
rangeStart: start.toDate(),
|
|
142
|
+
rangeEnd: end.toDate(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const latestStart = end.subtract(MAX_TIMELINE_DAYS - 1, 'day');
|
|
147
|
+
let cappedStart = start;
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
(todayMid.isAfter(start) || todayMid.isSame(start, 'day')) &&
|
|
151
|
+
(todayMid.isBefore(end) || todayMid.isSame(end, 'day'))
|
|
152
|
+
) {
|
|
153
|
+
cappedStart = todayMid.subtract(Math.floor(MAX_TIMELINE_DAYS / 2), 'day');
|
|
154
|
+
} else if (todayMid.isAfter(end)) {
|
|
155
|
+
cappedStart = latestStart;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (cappedStart.isBefore(start)) {
|
|
159
|
+
cappedStart = start;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (cappedStart.isAfter(latestStart)) {
|
|
163
|
+
cappedStart = latestStart;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
rangeStart: cappedStart.toDate(),
|
|
168
|
+
rangeEnd: cappedStart.add(MAX_TIMELINE_DAYS - 1, 'day').toDate(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
129
172
|
function withTaskRows(
|
|
130
173
|
items: Array<Omit<TimelineItem, 'offsetDays'>>,
|
|
131
174
|
rangeStart: Date
|
|
@@ -280,7 +323,7 @@ export function buildTimelineModel(
|
|
|
280
323
|
});
|
|
281
324
|
}
|
|
282
325
|
|
|
283
|
-
const
|
|
326
|
+
const rawRangeStart = scheduled.length
|
|
284
327
|
? dayjs(
|
|
285
328
|
scheduled.reduce(
|
|
286
329
|
(min, item) => (item.start < min ? item.start : min),
|
|
@@ -292,7 +335,7 @@ export function buildTimelineModel(
|
|
|
292
335
|
.toDate()
|
|
293
336
|
: todayMid.subtract(EMPTY_PAST_PADDING_DAYS, 'day').toDate();
|
|
294
337
|
|
|
295
|
-
const
|
|
338
|
+
const rawRangeEnd = scheduled.length
|
|
296
339
|
? dayjs(
|
|
297
340
|
scheduled.reduce(
|
|
298
341
|
(max, item) => (item.end > max ? item.end : max),
|
|
@@ -304,6 +347,12 @@ export function buildTimelineModel(
|
|
|
304
347
|
.toDate()
|
|
305
348
|
: todayMid.add(EMPTY_FUTURE_PADDING_DAYS, 'day').toDate();
|
|
306
349
|
|
|
350
|
+
const { rangeStart, rangeEnd } = capTimelineRange(
|
|
351
|
+
rawRangeStart,
|
|
352
|
+
rawRangeEnd,
|
|
353
|
+
todayMid
|
|
354
|
+
);
|
|
355
|
+
|
|
307
356
|
const days = enumerateDays(rangeStart, rangeEnd);
|
|
308
357
|
const monthSegments = buildMonthSegments(days);
|
|
309
358
|
const todayIndex = Math.round(
|
|
@@ -72,6 +72,15 @@ export default function WorkspaceProjectsClientPage({
|
|
|
72
72
|
const isPermissionLoading = permissionQuery.isLoading;
|
|
73
73
|
|
|
74
74
|
const resolvedWsId = workspace?.id;
|
|
75
|
+
const workspaceAccess = workspace as
|
|
76
|
+
| { access_type?: string; guest_products?: string[] }
|
|
77
|
+
| null
|
|
78
|
+
| undefined;
|
|
79
|
+
const isWorkspaceGuestTasksAccess =
|
|
80
|
+
workspaceAccess?.access_type === 'guest' &&
|
|
81
|
+
workspaceAccess.guest_products?.includes('tasks');
|
|
82
|
+
const canReadBoards =
|
|
83
|
+
canManageProjects === true || isWorkspaceGuestTasksAccess;
|
|
75
84
|
|
|
76
85
|
const {
|
|
77
86
|
data: boardsPayload,
|
|
@@ -85,9 +94,10 @@ export default function WorkspaceProjectsClientPage({
|
|
|
85
94
|
page: Number.parseInt(page, 10),
|
|
86
95
|
pageSize: Number.parseInt(pageSize, 10),
|
|
87
96
|
}),
|
|
88
|
-
enabled: Boolean(resolvedWsId),
|
|
97
|
+
enabled: Boolean(resolvedWsId && canReadBoards),
|
|
89
98
|
});
|
|
90
|
-
const isGuestBoardAccess =
|
|
99
|
+
const isGuestBoardAccess =
|
|
100
|
+
isWorkspaceGuestTasksAccess || boardsPayload?.access_type === 'guest';
|
|
91
101
|
|
|
92
102
|
useEffect(() => {
|
|
93
103
|
if (
|
|
@@ -112,7 +122,7 @@ export default function WorkspaceProjectsClientPage({
|
|
|
112
122
|
isWorkspacePending ||
|
|
113
123
|
isWorkspaceUserLoading ||
|
|
114
124
|
(isPermissionLoading && !isGuestBoardAccess) ||
|
|
115
|
-
isBoardsPending
|
|
125
|
+
(canReadBoards && isBoardsPending)
|
|
116
126
|
) {
|
|
117
127
|
return <BoardsListSkeleton />;
|
|
118
128
|
}
|
|
@@ -167,6 +167,7 @@ const mockTasks: Task[] = [
|
|
|
167
167
|
const mockWorkspaceLabels: WorkspaceLabel[] = [];
|
|
168
168
|
|
|
169
169
|
function renderBoardViews(overrides?: {
|
|
170
|
+
board?: Record<string, unknown>;
|
|
170
171
|
idleBottomIsland?: React.ReactNode;
|
|
171
172
|
lists?: TaskList[];
|
|
172
173
|
tasks?: Task[];
|
|
@@ -184,7 +185,7 @@ function renderBoardViews(overrides?: {
|
|
|
184
185
|
<QueryClientProvider client={queryClient}>
|
|
185
186
|
<HotkeysProvider>
|
|
186
187
|
<BoardViews
|
|
187
|
-
board={mockBoard as any}
|
|
188
|
+
board={(overrides?.board ?? mockBoard) as any}
|
|
188
189
|
currentUserId="user-1"
|
|
189
190
|
lists={overrides?.lists ?? mockLists}
|
|
190
191
|
tasks={overrides?.tasks ?? mockTasks}
|
|
@@ -323,6 +324,38 @@ describe('BoardViews', () => {
|
|
|
323
324
|
);
|
|
324
325
|
});
|
|
325
326
|
|
|
327
|
+
it('creates a task in the board default list when configured', () => {
|
|
328
|
+
renderBoardViews({
|
|
329
|
+
board: { ...mockBoard, default_list_id: 'list-2' },
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
fireEvent.keyDown(document, { key: 'c' });
|
|
333
|
+
|
|
334
|
+
expect(createTaskMock).toHaveBeenCalledTimes(1);
|
|
335
|
+
expect(createTaskMock).toHaveBeenCalledWith(
|
|
336
|
+
'board-1',
|
|
337
|
+
'list-2',
|
|
338
|
+
mockLists,
|
|
339
|
+
expect.objectContaining({ labels: [] })
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('falls back to the first list when the default list is unavailable', () => {
|
|
344
|
+
renderBoardViews({
|
|
345
|
+
board: { ...mockBoard, default_list_id: 'list-does-not-exist' },
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
fireEvent.keyDown(document, { key: 'c' });
|
|
349
|
+
|
|
350
|
+
expect(createTaskMock).toHaveBeenCalledTimes(1);
|
|
351
|
+
expect(createTaskMock).toHaveBeenCalledWith(
|
|
352
|
+
'board-1',
|
|
353
|
+
'list-1',
|
|
354
|
+
mockLists,
|
|
355
|
+
expect.objectContaining({ labels: [] })
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
326
359
|
it('pins a collapsed virtual external task list on personal boards without assigned external tasks', () => {
|
|
327
360
|
renderBoardViews({
|
|
328
361
|
workspace: {
|
|
@@ -104,6 +104,7 @@ interface Props {
|
|
|
104
104
|
> & {
|
|
105
105
|
ws_id?: WorkspaceTaskBoard['ws_id'] | null;
|
|
106
106
|
icon?: WorkspaceTaskBoard['icon'];
|
|
107
|
+
default_list_id?: WorkspaceTaskBoard['default_list_id'] | null;
|
|
107
108
|
};
|
|
108
109
|
currentUserId?: string;
|
|
109
110
|
currentView: ViewType;
|
|
@@ -158,6 +159,9 @@ export function BoardHeader({
|
|
|
158
159
|
const [showArchiveDialog, setShowArchiveDialog] = useState(false);
|
|
159
160
|
const [showUnarchiveDialog, setShowUnarchiveDialog] = useState(false);
|
|
160
161
|
const [ticketPrefix, setTicketPrefix] = useState(board.ticket_prefix || '');
|
|
162
|
+
const [defaultListId, setDefaultListId] = useState<string | null>(
|
|
163
|
+
board.default_list_id ?? null
|
|
164
|
+
);
|
|
161
165
|
const [localSearchQuery, setLocalSearchQuery] = useState(
|
|
162
166
|
filters.searchQuery || ''
|
|
163
167
|
);
|
|
@@ -247,6 +251,7 @@ export function BoardHeader({
|
|
|
247
251
|
|
|
248
252
|
await updateWorkspaceTaskBoard(workspaceId, board.id, {
|
|
249
253
|
ticket_prefix: nextTicketPrefix,
|
|
254
|
+
default_list_id: defaultListId,
|
|
250
255
|
});
|
|
251
256
|
|
|
252
257
|
syncBoardTicketPrefixCaches({
|
|
@@ -911,6 +916,7 @@ export function BoardHeader({
|
|
|
911
916
|
<DropdownMenuItem
|
|
912
917
|
onClick={() => {
|
|
913
918
|
setTicketPrefix(board.ticket_prefix || '');
|
|
919
|
+
setDefaultListId(board.default_list_id ?? null);
|
|
914
920
|
setBoardSettingsOpen(true);
|
|
915
921
|
setBoardMenuOpen(false);
|
|
916
922
|
}}
|
|
@@ -1163,6 +1169,39 @@ export function BoardHeader({
|
|
|
1163
1169
|
{t('ws-task-boards.settings.ticket_prefix_description')}
|
|
1164
1170
|
</p>
|
|
1165
1171
|
</div>
|
|
1172
|
+
<div className="grid gap-2">
|
|
1173
|
+
<label htmlFor="defaultList" className="font-medium text-sm">
|
|
1174
|
+
{t('ws-task-boards.settings.default_list')}
|
|
1175
|
+
</label>
|
|
1176
|
+
<Select
|
|
1177
|
+
value={defaultListId ?? '__none__'}
|
|
1178
|
+
onValueChange={(value) =>
|
|
1179
|
+
setDefaultListId(value === '__none__' ? null : value)
|
|
1180
|
+
}
|
|
1181
|
+
>
|
|
1182
|
+
<SelectTrigger id="defaultList">
|
|
1183
|
+
<SelectValue />
|
|
1184
|
+
</SelectTrigger>
|
|
1185
|
+
<SelectContent>
|
|
1186
|
+
<SelectItem value="__none__">
|
|
1187
|
+
{t('ws-task-boards.settings.default_list_none')}
|
|
1188
|
+
</SelectItem>
|
|
1189
|
+
{lists
|
|
1190
|
+
.filter(
|
|
1191
|
+
(list) => !list.deleted && !list.is_external_staging
|
|
1192
|
+
)
|
|
1193
|
+
.map((list) => (
|
|
1194
|
+
<SelectItem key={list.id} value={list.id}>
|
|
1195
|
+
{list.name ||
|
|
1196
|
+
t('ws-task-boards.settings.untitled_list')}
|
|
1197
|
+
</SelectItem>
|
|
1198
|
+
))}
|
|
1199
|
+
</SelectContent>
|
|
1200
|
+
</Select>
|
|
1201
|
+
<p className="text-muted-foreground text-xs">
|
|
1202
|
+
{t('ws-task-boards.settings.default_list_description')}
|
|
1203
|
+
</p>
|
|
1204
|
+
</div>
|
|
1166
1205
|
</div>
|
|
1167
1206
|
<DialogFooter>
|
|
1168
1207
|
<Button
|
|
@@ -519,14 +519,16 @@ export function BoardViews({
|
|
|
519
519
|
useHotkey(
|
|
520
520
|
HOTKEY_CREATE_TASK,
|
|
521
521
|
() => {
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
createTask(
|
|
525
|
-
board.id,
|
|
526
|
-
firstList.id,
|
|
527
|
-
filteredLists.filter((list) => !list.is_external_staging),
|
|
528
|
-
filters
|
|
522
|
+
const selectableLists = filteredLists.filter(
|
|
523
|
+
(list) => !list.is_external_staging
|
|
529
524
|
);
|
|
525
|
+
// Prefer the board's configured default list for new tasks, falling back
|
|
526
|
+
// to the first selectable list when unset or the list is unavailable.
|
|
527
|
+
const targetList =
|
|
528
|
+
selectableLists.find((list) => list.id === board.default_list_id) ??
|
|
529
|
+
selectableLists[0];
|
|
530
|
+
if (!targetList) return;
|
|
531
|
+
createTask(board.id, targetList.id, selectableLists, filters);
|
|
530
532
|
},
|
|
531
533
|
{
|
|
532
534
|
enabled: filteredLists.some((list) => !list.is_external_staging),
|
|
@@ -11,6 +11,14 @@ import type { ListStatusFilter } from './board-header';
|
|
|
11
11
|
import CursorOverlay from './cursor-overlay';
|
|
12
12
|
import type { BoardFiltersMetadata, TaskFilters } from './task-filter.types';
|
|
13
13
|
|
|
14
|
+
type CursorScopeMetadata =
|
|
15
|
+
| { type: 'board'; boardId: string }
|
|
16
|
+
| { type: 'task-description'; taskId: string };
|
|
17
|
+
|
|
18
|
+
type CursorOverlayMetadata = Partial<BoardFiltersMetadata> & {
|
|
19
|
+
cursorScope: CursorScopeMetadata;
|
|
20
|
+
};
|
|
21
|
+
|
|
14
22
|
/**
|
|
15
23
|
* Efficiently compares two sorted arrays of primitive values
|
|
16
24
|
*/
|
|
@@ -26,10 +34,16 @@ function arraysEqual<T extends string | number>(arr1: T[], arr2: T[]): boolean {
|
|
|
26
34
|
* Checks if two cursor metadata objects represent the same view configuration
|
|
27
35
|
*/
|
|
28
36
|
function isMatchingFilters(
|
|
29
|
-
metadata1?:
|
|
30
|
-
metadata2?:
|
|
37
|
+
metadata1?: CursorOverlayMetadata,
|
|
38
|
+
metadata2?: CursorOverlayMetadata
|
|
31
39
|
): boolean {
|
|
32
40
|
if (!metadata1 || !metadata2) return true; // Show all cursors if no metadata
|
|
41
|
+
if (!isMatchingCursorScope(metadata1.cursorScope, metadata2.cursorScope)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!metadata1.filters || !metadata2.filters) return true;
|
|
46
|
+
if (!metadata1.listStatusFilter || !metadata2.listStatusFilter) return true;
|
|
33
47
|
|
|
34
48
|
// Check list status filter
|
|
35
49
|
if (metadata1.listStatusFilter !== metadata2.listStatusFilter) return false;
|
|
@@ -81,14 +95,33 @@ function isMatchingFilters(
|
|
|
81
95
|
return true;
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
function isMatchingCursorScope(
|
|
99
|
+
scope1: CursorScopeMetadata,
|
|
100
|
+
scope2: CursorScopeMetadata
|
|
101
|
+
) {
|
|
102
|
+
if (scope1.type !== scope2.type) return false;
|
|
103
|
+
if (scope1.type === 'board' && scope2.type === 'board') {
|
|
104
|
+
return scope1.boardId === scope2.boardId;
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
scope1.type === 'task-description' &&
|
|
108
|
+
scope2.type === 'task-description'
|
|
109
|
+
) {
|
|
110
|
+
return scope1.taskId === scope2.taskId;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
84
115
|
export default function CursorOverlayMultiWrapper({
|
|
85
116
|
channelName,
|
|
86
117
|
containerRef,
|
|
118
|
+
cursorScope,
|
|
87
119
|
listStatusFilter,
|
|
88
120
|
filters,
|
|
89
121
|
}: {
|
|
90
122
|
channelName: string;
|
|
91
123
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
124
|
+
cursorScope: CursorScopeMetadata;
|
|
92
125
|
listStatusFilter?: ListStatusFilter;
|
|
93
126
|
filters?: TaskFilters;
|
|
94
127
|
}) {
|
|
@@ -97,6 +130,9 @@ export default function CursorOverlayMultiWrapper({
|
|
|
97
130
|
height: number;
|
|
98
131
|
}>({ width: 0, height: 0 });
|
|
99
132
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
133
|
+
const cursorScopeType = cursorScope.type;
|
|
134
|
+
const cursorScopeId =
|
|
135
|
+
cursorScope.type === 'board' ? cursorScope.boardId : cursorScope.taskId;
|
|
100
136
|
|
|
101
137
|
// Fetch current user
|
|
102
138
|
useEffect(() => {
|
|
@@ -105,10 +141,9 @@ export default function CursorOverlayMultiWrapper({
|
|
|
105
141
|
const userData = await getCurrentUserProfile();
|
|
106
142
|
if (!userData?.id) return;
|
|
107
143
|
setCurrentUser({
|
|
108
|
-
id: userData.id,
|
|
109
|
-
display_name: userData.display_name,
|
|
110
|
-
email: userData.email,
|
|
111
144
|
avatar_url: userData.avatar_url,
|
|
145
|
+
display_name: userData.display_name,
|
|
146
|
+
id: userData.id,
|
|
112
147
|
});
|
|
113
148
|
} catch (err) {
|
|
114
149
|
console.warn('Error fetching user:', err);
|
|
@@ -118,14 +153,22 @@ export default function CursorOverlayMultiWrapper({
|
|
|
118
153
|
fetchUser();
|
|
119
154
|
}, []);
|
|
120
155
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
const metadata: CursorOverlayMetadata = useMemo(() => {
|
|
157
|
+
const resolvedCursorScope =
|
|
158
|
+
cursorScopeType === 'board'
|
|
159
|
+
? ({ boardId: cursorScopeId, type: 'board' } as const)
|
|
160
|
+
: ({ taskId: cursorScopeId, type: 'task-description' } as const);
|
|
161
|
+
|
|
162
|
+
if (!listStatusFilter || !filters) {
|
|
163
|
+
return { cursorScope: resolvedCursorScope };
|
|
164
|
+
}
|
|
165
|
+
|
|
124
166
|
return {
|
|
167
|
+
cursorScope: resolvedCursorScope,
|
|
125
168
|
listStatusFilter,
|
|
126
169
|
filters,
|
|
127
170
|
};
|
|
128
|
-
}, [listStatusFilter, filters]);
|
|
171
|
+
}, [cursorScopeId, cursorScopeType, listStatusFilter, filters]);
|
|
129
172
|
|
|
130
173
|
const { cursors, error } = useCursorTracking(
|
|
131
174
|
channelName,
|
|
@@ -139,15 +182,13 @@ export default function CursorOverlayMultiWrapper({
|
|
|
139
182
|
const filtered = new Map<string, CursorPosition>();
|
|
140
183
|
|
|
141
184
|
for (const [userId, cursor] of cursors.entries()) {
|
|
142
|
-
// Always show cursors without metadata (backward compatibility)
|
|
143
185
|
if (!cursor.metadata) {
|
|
144
|
-
filtered.set(userId, cursor);
|
|
145
186
|
continue;
|
|
146
187
|
}
|
|
147
188
|
|
|
148
189
|
// Filter based on view matching
|
|
149
190
|
if (
|
|
150
|
-
isMatchingFilters(metadata, cursor.metadata as
|
|
191
|
+
isMatchingFilters(metadata, cursor.metadata as CursorOverlayMetadata)
|
|
151
192
|
) {
|
|
152
193
|
filtered.set(userId, cursor);
|
|
153
194
|
}
|