@tuturuuu/ui 0.6.2 → 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 +66 -0
- package/biome.json +1 -1
- package/package.json +11 -11
- package/src/components/ui/calendar-app/components/calendar-connections.tsx +17 -13
- package/src/components/ui/calendar-app/components/connected-accounts-dialog.tsx +2 -5
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +2 -5
- package/src/components/ui/calendar.test.tsx +24 -0
- package/src/components/ui/calendar.tsx +1 -0
- 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/date-time-picker.tsx +352 -234
- package/src/components/ui/finance/categories-tags-tabs.tsx +23 -1
- package/src/components/ui/finance/command/finance-command-actions.test.tsx +48 -0
- package/src/components/ui/finance/command/finance-command-actions.tsx +200 -0
- package/src/components/ui/finance/command/finance-command-provider.test.tsx +151 -0
- package/src/components/ui/finance/command/finance-command-provider.tsx +250 -0
- package/src/components/ui/finance/command/finance-command-results.tsx +262 -0
- package/src/components/ui/finance/invoices/pending-invoices-table.tsx +22 -9
- package/src/components/ui/finance/shared/quick-actions.tsx +39 -90
- package/src/components/ui/finance/tags/tag-manager.tsx +24 -5
- package/src/components/ui/finance/transactions/form-basic-tab.tsx +33 -49
- package/src/components/ui/finance/transactions/form-types.ts +5 -0
- package/src/components/ui/finance/transactions/form.test.tsx +105 -22
- package/src/components/ui/finance/transactions/form.tsx +116 -20
- package/src/components/ui/finance/transactions/infinite-transactions-list.tsx +13 -6
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +25 -1
- package/src/components/ui/finance/transactions/transaction-edit-dialog.tsx +16 -3
- package/src/components/ui/finance/transactions/transactionId/transaction-details-client-page.tsx +3 -0
- package/src/components/ui/finance/transactions/transactionId/transaction-details-page.tsx +3 -0
- package/src/components/ui/finance/transactions/transactions-create-summary.tsx +6 -0
- package/src/components/ui/finance/transactions/transactions-infinite-page.tsx +20 -2
- package/src/components/ui/finance/transactions/transactions-page.tsx +4 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-history-dialog.tsx +7 -2
- package/src/components/ui/finance/wallets/checkpoints/wallet-total-check-dialog.tsx +7 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.test.tsx +38 -1
- package/src/components/ui/finance/wallets/walletId/wallet-details-actions.tsx +5 -0
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.test.tsx +18 -2
- package/src/components/ui/finance/wallets/walletId/wallet-details-page.tsx +3 -0
- package/src/components/ui/finance/wallets/wallets-page.tsx +3 -0
- package/src/components/ui/legacy/calendar/settings/google-calendar-settings.tsx +2 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/optional-time-picker.tsx +95 -0
- package/src/components/ui/quick-command-center.test.tsx +90 -0
- package/src/components/ui/quick-command-center.tsx +190 -0
- package/src/components/ui/storefront/cart-summary.tsx +126 -50
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +23 -20
- 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 +132 -5
- package/src/components/ui/storefront/storefront-surface.tsx +371 -128
- package/src/components/ui/storefront/types.ts +25 -1
- package/src/components/ui/storefront/utils.ts +118 -13
- 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/hooks/task-api.test.ts +171 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/task-api.ts +200 -36
- package/src/components/ui/tu-do/shared/task-edit-dialog/hooks/use-task-save.ts +21 -2
- 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,11 +1,27 @@
|
|
|
1
1
|
import {
|
|
2
|
+
abortWorkspaceTaskDescriptionChunks,
|
|
3
|
+
appendWorkspaceTaskDescriptionChunk,
|
|
4
|
+
beginWorkspaceTaskDescriptionChunks,
|
|
5
|
+
commitWorkspaceTaskDescriptionChunks,
|
|
2
6
|
createWorkspaceTaskProject,
|
|
3
7
|
getWorkspaceTask,
|
|
8
|
+
getWorkspaceTaskDescription,
|
|
9
|
+
updateWorkspaceTaskDescription as updateWorkspaceTaskDescriptionViaApi,
|
|
4
10
|
updateWorkspaceTask as updateWorkspaceTaskViaApi,
|
|
11
|
+
type WorkspaceTaskDescriptionChunkField,
|
|
12
|
+
type WorkspaceTaskDescriptionChunkFields,
|
|
13
|
+
type WorkspaceTaskDescriptionResponse,
|
|
14
|
+
type WorkspaceTaskDescriptionUpdatePayload,
|
|
5
15
|
type WorkspaceTaskUpdatePayload,
|
|
6
16
|
} from '@tuturuuu/internal-api/tasks';
|
|
7
17
|
import type { WorkspaceTaskLabel } from '../types';
|
|
8
18
|
|
|
19
|
+
const TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES = 192 * 1024;
|
|
20
|
+
const TASK_DESCRIPTION_CHUNK_TEXT_LENGTH = 96 * 1024;
|
|
21
|
+
const TASK_DESCRIPTION_TOO_LARGE_MESSAGE =
|
|
22
|
+
'Description content is too large. Please shorten it or split it into smaller documents.';
|
|
23
|
+
const textEncoder = new TextEncoder();
|
|
24
|
+
|
|
9
25
|
async function getErrorMessage(
|
|
10
26
|
response: Response,
|
|
11
27
|
fallback: string,
|
|
@@ -38,59 +54,207 @@ export async function fetchWorkspaceTask(wsId: string, taskId: string) {
|
|
|
38
54
|
return getWorkspaceTask(wsId, taskId);
|
|
39
55
|
}
|
|
40
56
|
|
|
41
|
-
interface TaskDescriptionResponse {
|
|
42
|
-
description: string | null;
|
|
43
|
-
description_yjs_state: number[] | null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
57
|
export async function fetchWorkspaceTaskDescription(
|
|
47
58
|
wsId: string,
|
|
48
59
|
taskId: string
|
|
49
60
|
) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
method: 'GET',
|
|
53
|
-
cache: 'no-store',
|
|
54
|
-
});
|
|
61
|
+
return getWorkspaceTaskDescription(wsId, taskId);
|
|
62
|
+
}
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
export async function updateWorkspaceTaskDescription(
|
|
65
|
+
wsId: string,
|
|
66
|
+
taskId: string,
|
|
67
|
+
payload: WorkspaceTaskDescriptionUpdatePayload
|
|
68
|
+
) {
|
|
69
|
+
if (shouldChunkTaskDescriptionPayload(payload)) {
|
|
70
|
+
return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return await updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (isPayloadTooLargeError(error)) {
|
|
77
|
+
return updateWorkspaceTaskDescriptionChunked(wsId, taskId, payload);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw normalizeTaskDescriptionError(
|
|
81
|
+
error,
|
|
82
|
+
'Failed to update task description'
|
|
61
83
|
);
|
|
62
84
|
}
|
|
85
|
+
}
|
|
63
86
|
|
|
64
|
-
|
|
87
|
+
export function shouldChunkTaskDescriptionPayload(
|
|
88
|
+
payload: WorkspaceTaskDescriptionUpdatePayload
|
|
89
|
+
) {
|
|
90
|
+
return getJsonByteLength(payload) > TASK_DESCRIPTION_DIRECT_BODY_LIMIT_BYTES;
|
|
65
91
|
}
|
|
66
92
|
|
|
67
|
-
export async function
|
|
93
|
+
export async function updateWorkspaceTaskDescriptionChunked(
|
|
68
94
|
wsId: string,
|
|
69
95
|
taskId: string,
|
|
70
|
-
payload:
|
|
71
|
-
|
|
72
|
-
|
|
96
|
+
payload: WorkspaceTaskDescriptionUpdatePayload
|
|
97
|
+
): Promise<WorkspaceTaskDescriptionResponse> {
|
|
98
|
+
const chunksByField = buildTaskDescriptionChunks(payload);
|
|
99
|
+
const fields = buildTaskDescriptionChunkFields(chunksByField);
|
|
100
|
+
|
|
101
|
+
if (Object.keys(fields).length === 0) {
|
|
102
|
+
return updateWorkspaceTaskDescriptionViaApi(wsId, taskId, payload);
|
|
73
103
|
}
|
|
74
|
-
) {
|
|
75
|
-
const requestPath = `/api/v1/workspaces/${wsId}/tasks/${taskId}/description`;
|
|
76
|
-
const response = await fetch(requestPath, {
|
|
77
|
-
method: 'PATCH',
|
|
78
|
-
headers: {
|
|
79
|
-
'Content-Type': 'application/json',
|
|
80
|
-
},
|
|
81
|
-
body: JSON.stringify(payload),
|
|
82
|
-
cache: 'no-store',
|
|
83
|
-
});
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
const { session_id: sessionId } = await beginWorkspaceTaskDescriptionChunks(
|
|
106
|
+
wsId,
|
|
107
|
+
taskId,
|
|
108
|
+
fields
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
for (const field of ['description', 'description_yjs_state'] as const) {
|
|
113
|
+
const chunks = chunksByField[field];
|
|
114
|
+
if (!chunks) continue;
|
|
115
|
+
|
|
116
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
117
|
+
await appendWorkspaceTaskDescriptionChunk(wsId, taskId, {
|
|
118
|
+
chunk: chunks[index] ?? '',
|
|
119
|
+
chunk_index: index,
|
|
120
|
+
field,
|
|
121
|
+
session_id: sessionId,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return await commitWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
await abortWorkspaceTaskDescriptionChunks(wsId, taskId, sessionId).catch(
|
|
129
|
+
() => undefined
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
throw normalizeTaskDescriptionError(
|
|
133
|
+
error,
|
|
134
|
+
'Failed to update task description'
|
|
90
135
|
);
|
|
91
136
|
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getJsonByteLength(value: unknown) {
|
|
140
|
+
return textEncoder.encode(JSON.stringify(value)).byteLength;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isPayloadTooLargeError(error: unknown) {
|
|
144
|
+
return (
|
|
145
|
+
typeof error === 'object' &&
|
|
146
|
+
error !== null &&
|
|
147
|
+
'status' in error &&
|
|
148
|
+
(error as { status?: unknown }).status === 413
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeTaskDescriptionError(error: unknown, fallback: string) {
|
|
153
|
+
if (isPayloadTooLargeError(error)) {
|
|
154
|
+
return new Error(TASK_DESCRIPTION_TOO_LARGE_MESSAGE);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return error instanceof Error ? error : new Error(fallback);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function splitStringIntoChunks(value: string) {
|
|
161
|
+
const chunks: string[] = [];
|
|
162
|
+
|
|
163
|
+
for (
|
|
164
|
+
let index = 0;
|
|
165
|
+
index < value.length;
|
|
166
|
+
index += TASK_DESCRIPTION_CHUNK_TEXT_LENGTH
|
|
167
|
+
) {
|
|
168
|
+
chunks.push(value.slice(index, index + TASK_DESCRIPTION_CHUNK_TEXT_LENGTH));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return chunks.length > 0 ? chunks : [''];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function bytesToBase64(bytes: number[]) {
|
|
175
|
+
const byteArray = Uint8Array.from(bytes);
|
|
176
|
+
|
|
177
|
+
if (typeof btoa === 'function') {
|
|
178
|
+
let binary = '';
|
|
179
|
+
const step = 0x8000;
|
|
180
|
+
|
|
181
|
+
for (let index = 0; index < byteArray.length; index += step) {
|
|
182
|
+
binary += String.fromCharCode(...byteArray.subarray(index, index + step));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return btoa(binary);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nodeBuffer = (
|
|
189
|
+
globalThis as typeof globalThis & {
|
|
190
|
+
Buffer?: {
|
|
191
|
+
from: (value: Uint8Array) => {
|
|
192
|
+
toString: (encoding: 'base64') => string;
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
).Buffer;
|
|
197
|
+
|
|
198
|
+
if (!nodeBuffer) {
|
|
199
|
+
throw new Error('Unable to encode task description state.');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return nodeBuffer.from(byteArray).toString('base64');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildTaskDescriptionChunks(
|
|
206
|
+
payload: WorkspaceTaskDescriptionUpdatePayload
|
|
207
|
+
): Partial<Record<WorkspaceTaskDescriptionChunkField, string[] | null>> {
|
|
208
|
+
const chunks: Partial<
|
|
209
|
+
Record<WorkspaceTaskDescriptionChunkField, string[] | null>
|
|
210
|
+
> = {};
|
|
211
|
+
|
|
212
|
+
if ('description' in payload) {
|
|
213
|
+
chunks.description =
|
|
214
|
+
payload.description === null
|
|
215
|
+
? null
|
|
216
|
+
: splitStringIntoChunks(payload.description ?? '');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if ('description_yjs_state' in payload) {
|
|
220
|
+
chunks.description_yjs_state =
|
|
221
|
+
payload.description_yjs_state === null
|
|
222
|
+
? null
|
|
223
|
+
: splitStringIntoChunks(
|
|
224
|
+
bytesToBase64(payload.description_yjs_state ?? [])
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return chunks;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildTaskDescriptionChunkFields(
|
|
232
|
+
chunksByField: Partial<
|
|
233
|
+
Record<WorkspaceTaskDescriptionChunkField, string[] | null>
|
|
234
|
+
>
|
|
235
|
+
) {
|
|
236
|
+
const fields: WorkspaceTaskDescriptionChunkFields = {};
|
|
237
|
+
|
|
238
|
+
for (const field of ['description', 'description_yjs_state'] as const) {
|
|
239
|
+
const chunks = chunksByField[field];
|
|
240
|
+
if (chunks === undefined) continue;
|
|
241
|
+
|
|
242
|
+
if (chunks === null) {
|
|
243
|
+
fields[field] = {
|
|
244
|
+
chunk_count: 0,
|
|
245
|
+
is_null: true,
|
|
246
|
+
total_length: 0,
|
|
247
|
+
};
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fields[field] = {
|
|
252
|
+
chunk_count: chunks.length,
|
|
253
|
+
total_length: chunks.join('').length,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
92
256
|
|
|
93
|
-
return
|
|
257
|
+
return fields;
|
|
94
258
|
}
|
|
95
259
|
|
|
96
260
|
export async function createWorkspaceLabel(
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
updateTaskDescriptionCaches,
|
|
35
35
|
} from '../utils';
|
|
36
36
|
import {
|
|
37
|
+
shouldChunkTaskDescriptionPayload,
|
|
37
38
|
updateWorkspaceTask,
|
|
38
39
|
updateWorkspaceTaskDescription,
|
|
39
40
|
} from './task-api';
|
|
@@ -945,9 +946,17 @@ export async function handleCreateTask({
|
|
|
945
946
|
];
|
|
946
947
|
}
|
|
947
948
|
|
|
949
|
+
const createDescriptionPayload = {
|
|
950
|
+
description: descriptionString || '',
|
|
951
|
+
description_yjs_state: descriptionYjsState ?? undefined,
|
|
952
|
+
};
|
|
953
|
+
const shouldDeferDescription = shouldChunkTaskDescriptionPayload(
|
|
954
|
+
createDescriptionPayload
|
|
955
|
+
);
|
|
956
|
+
|
|
948
957
|
const taskData: Partial<Task> = {
|
|
949
958
|
name: name.trim(),
|
|
950
|
-
description: descriptionString || '',
|
|
959
|
+
description: shouldDeferDescription ? '' : descriptionString || '',
|
|
951
960
|
priority: priority,
|
|
952
961
|
start_date: startDate ? startDate.toISOString() : undefined,
|
|
953
962
|
end_date: endDate ? endDate.toISOString() : undefined,
|
|
@@ -957,7 +966,9 @@ export async function handleCreateTask({
|
|
|
957
966
|
};
|
|
958
967
|
const newTask = await createTask(wsId, selectedListId, {
|
|
959
968
|
...taskData,
|
|
960
|
-
description_yjs_state:
|
|
969
|
+
description_yjs_state: shouldDeferDescription
|
|
970
|
+
? undefined
|
|
971
|
+
: (descriptionYjsState ?? undefined),
|
|
961
972
|
label_ids: selectedLabels.map((label) => label.id),
|
|
962
973
|
assignee_ids: desiredAssignees
|
|
963
974
|
.map((assignee) => assignee.user_id || assignee.id)
|
|
@@ -965,6 +976,14 @@ export async function handleCreateTask({
|
|
|
965
976
|
project_ids: selectedProjects.map((project) => project.id),
|
|
966
977
|
});
|
|
967
978
|
|
|
979
|
+
if (shouldDeferDescription) {
|
|
980
|
+
await updateWorkspaceTaskDescription(wsId, newTask.id, {
|
|
981
|
+
description: descriptionString,
|
|
982
|
+
description_yjs_state: descriptionYjsState,
|
|
983
|
+
});
|
|
984
|
+
newTask.description = descriptionString ?? undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
968
987
|
// Save per-user scheduling settings for the creator (if any were provided)
|
|
969
988
|
const hasAnySchedulingValue =
|
|
970
989
|
totalDuration != null ||
|
|
@@ -47,6 +47,7 @@ import { createInitialSuggestionState } from './mention-system/types';
|
|
|
47
47
|
import { SyncWarningDialog } from './sync-warning-dialog';
|
|
48
48
|
import {
|
|
49
49
|
normalizeTaskDialogPresentation,
|
|
50
|
+
resolveTaskDialogOpeningPresentation,
|
|
50
51
|
type TaskDialogPresentation,
|
|
51
52
|
} from './task-dialog-presentation';
|
|
52
53
|
import { CompactTaskDialogPanel } from './task-edit-dialog/components/compact-task-create-popover';
|
|
@@ -107,6 +108,7 @@ import {
|
|
|
107
108
|
getDescriptionContent,
|
|
108
109
|
getDraftStorageKey,
|
|
109
110
|
getTaskDescriptionPercentLeft,
|
|
111
|
+
getTaskDescriptionPreviewText,
|
|
110
112
|
getTaskDescriptionStorageLength,
|
|
111
113
|
saveAndVerifyYjsDescriptionToDatabase,
|
|
112
114
|
saveYjsDescriptionToDatabase,
|
|
@@ -494,6 +496,23 @@ export function TaskEditDialog({
|
|
|
494
496
|
taskSearchQuery,
|
|
495
497
|
sharedContext,
|
|
496
498
|
});
|
|
499
|
+
const currentList = availableLists?.find(
|
|
500
|
+
(list) => list.id === formState.selectedListId
|
|
501
|
+
);
|
|
502
|
+
const normalizedDefaultPresentation = useMemo(
|
|
503
|
+
() => normalizeTaskDialogPresentation(defaultPresentation),
|
|
504
|
+
[defaultPresentation]
|
|
505
|
+
);
|
|
506
|
+
const openingPresentation = useMemo(
|
|
507
|
+
() =>
|
|
508
|
+
resolveTaskDialogOpeningPresentation({
|
|
509
|
+
defaultPresentation: normalizedDefaultPresentation,
|
|
510
|
+
draftId,
|
|
511
|
+
mode,
|
|
512
|
+
selectedListStatus: currentList?.status,
|
|
513
|
+
}),
|
|
514
|
+
[currentList?.status, draftId, mode, normalizedDefaultPresentation]
|
|
515
|
+
);
|
|
497
516
|
|
|
498
517
|
// Update browser tab title
|
|
499
518
|
useEffect(() => {
|
|
@@ -552,13 +571,8 @@ export function TaskEditDialog({
|
|
|
552
571
|
useState(false);
|
|
553
572
|
const [showShareDialog, setShowShareDialog] = useState(false);
|
|
554
573
|
const [saveAsDraft, setSaveAsDraft] = useState(draftModeEnabled);
|
|
555
|
-
const
|
|
556
|
-
()
|
|
557
|
-
[defaultPresentation]
|
|
558
|
-
);
|
|
559
|
-
const [presentation, setPresentation] = useState<TaskDialogPresentation>(
|
|
560
|
-
normalizedDefaultPresentation
|
|
561
|
-
);
|
|
574
|
+
const [presentation, setPresentation] =
|
|
575
|
+
useState<TaskDialogPresentation>(openingPresentation);
|
|
562
576
|
const [smartSuggestions, setSmartSuggestions] = useState<
|
|
563
577
|
WorkspaceTaskSuggestionTask[]
|
|
564
578
|
>([]);
|
|
@@ -849,9 +863,6 @@ export function TaskEditDialog({
|
|
|
849
863
|
onUpdate,
|
|
850
864
|
});
|
|
851
865
|
|
|
852
|
-
const currentList = availableLists?.find(
|
|
853
|
-
(list) => list.id === formState.selectedListId
|
|
854
|
-
);
|
|
855
866
|
const doneList = availableLists?.find(
|
|
856
867
|
(list) => list.status === 'done' && !list.deleted
|
|
857
868
|
);
|
|
@@ -1946,7 +1957,7 @@ export function TaskEditDialog({
|
|
|
1946
1957
|
previousOpenRef.current = isOpen;
|
|
1947
1958
|
|
|
1948
1959
|
if (!isOpen) {
|
|
1949
|
-
setPresentation(
|
|
1960
|
+
setPresentation(openingPresentation);
|
|
1950
1961
|
setSmartSuggestions([]);
|
|
1951
1962
|
setSelectedSmartSuggestionIds([]);
|
|
1952
1963
|
setSmartSuggestionError(null);
|
|
@@ -1957,9 +1968,14 @@ export function TaskEditDialog({
|
|
|
1957
1968
|
}
|
|
1958
1969
|
|
|
1959
1970
|
if (justOpened) {
|
|
1960
|
-
setPresentation(
|
|
1971
|
+
setPresentation(openingPresentation);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
if (!isCreateMode && currentList?.status === 'documents') {
|
|
1976
|
+
setPresentation('fullscreen');
|
|
1961
1977
|
}
|
|
1962
|
-
}, [
|
|
1978
|
+
}, [currentList?.status, isCreateMode, isOpen, openingPresentation]);
|
|
1963
1979
|
|
|
1964
1980
|
// Track whether the title input is scrolled out of view
|
|
1965
1981
|
useEffect(() => {
|
|
@@ -2023,6 +2039,15 @@ export function TaskEditDialog({
|
|
|
2023
2039
|
]);
|
|
2024
2040
|
|
|
2025
2041
|
const showCompactDialog = presentation === 'compact' && !draftId;
|
|
2042
|
+
const compactDescriptionPreview = useMemo(() => {
|
|
2043
|
+
if (isCreateMode) return null;
|
|
2044
|
+
|
|
2045
|
+
const previewText = getTaskDescriptionPreviewText(
|
|
2046
|
+
formState.description
|
|
2047
|
+
).trim();
|
|
2048
|
+
|
|
2049
|
+
return previewText || null;
|
|
2050
|
+
}, [formState.description, isCreateMode]);
|
|
2026
2051
|
const taskHydrationNotice = taskLoadError ? (
|
|
2027
2052
|
<div
|
|
2028
2053
|
className="mx-4 mb-2 flex items-center justify-between gap-3 rounded-md border border-dynamic-red/30 bg-dynamic-red/10 px-3 py-2 text-dynamic-red text-sm md:mx-8"
|
|
@@ -2235,7 +2260,7 @@ export function TaskEditDialog({
|
|
|
2235
2260
|
showCloseButton={false}
|
|
2236
2261
|
className={
|
|
2237
2262
|
showCompactDialog
|
|
2238
|
-
? 'w-[min(calc(100vw-2rem),30rem)] max-w-[30rem] gap-0 overflow-
|
|
2263
|
+
? 'w-[min(calc(100vw-2rem),30rem)] max-w-[30rem] gap-0 overflow-visible rounded-lg border p-0 shadow-xl'
|
|
2239
2264
|
: 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-bottom-2 data-[state=open]:slide-in-from-bottom-2 inset-0! top-0! left-0! flex h-screen max-h-screen w-screen max-w-none! translate-x-0! translate-y-0! gap-0 rounded-none! border-0 p-0'
|
|
2240
2265
|
}
|
|
2241
2266
|
onContextMenu={(e) => {
|
|
@@ -2271,6 +2296,8 @@ export function TaskEditDialog({
|
|
|
2271
2296
|
iconBgClass={compactHeaderInfo.iconBgClass}
|
|
2272
2297
|
iconRingClass={compactHeaderInfo.iconRingClass}
|
|
2273
2298
|
showHeaderTitle={isCreateMode}
|
|
2299
|
+
descriptionPreview={compactDescriptionPreview}
|
|
2300
|
+
descriptionPreviewLabel={dialogT('open_fullscreen')}
|
|
2274
2301
|
titleInput={
|
|
2275
2302
|
<TaskNameInput
|
|
2276
2303
|
name={formState.name}
|
|
@@ -2311,6 +2338,7 @@ export function TaskEditDialog({
|
|
|
2311
2338
|
}
|
|
2312
2339
|
onClose={handleAttemptClose}
|
|
2313
2340
|
onFullscreen={() => setPresentation('fullscreen')}
|
|
2341
|
+
onDescriptionPreviewClick={() => setPresentation('fullscreen')}
|
|
2314
2342
|
onSave={
|
|
2315
2343
|
isCreateMode && !taskControlsDisabled ? handleSave : undefined
|
|
2316
2344
|
}
|
|
@@ -248,7 +248,7 @@ describe('useBoardRealtime', () => {
|
|
|
248
248
|
expect(mockChannel.subscribe).toHaveBeenCalledTimes(1);
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it('should create channel with self: false config', () => {
|
|
251
|
+
it('should create a private channel with self: false config', () => {
|
|
252
252
|
renderHook(() => useBoardRealtime('board-1', { enabled: true }), {
|
|
253
253
|
wrapper,
|
|
254
254
|
});
|
|
@@ -258,7 +258,7 @@ describe('useBoardRealtime', () => {
|
|
|
258
258
|
)();
|
|
259
259
|
expect(supabaseInstance.channel).toHaveBeenCalledWith(
|
|
260
260
|
'board-realtime-board-1',
|
|
261
|
-
{ config: { broadcast: { self: false } } }
|
|
261
|
+
{ config: { broadcast: { self: false }, private: true } }
|
|
262
262
|
);
|
|
263
263
|
});
|
|
264
264
|
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
6
|
+
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
7
|
+
import type { RefObject } from 'react';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { PRIVATE_TASK_REALTIME_CHANNEL_CONFIG } from '../useBoardRealtime.types';
|
|
10
|
+
import { useCursorTracking } from '../useCursorTracking';
|
|
11
|
+
|
|
12
|
+
type BroadcastListener = (message: {
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
}) => void;
|
|
15
|
+
|
|
16
|
+
type MockSupabaseClient = {
|
|
17
|
+
channel: ReturnType<typeof vi.fn>;
|
|
18
|
+
removeChannel: ReturnType<typeof vi.fn>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MockCreateClientFn = {
|
|
22
|
+
(): MockSupabaseClient;
|
|
23
|
+
mockReturnValue: (value: MockSupabaseClient) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({
|
|
27
|
+
createClient: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@tuturuuu/utils/constants', () => ({
|
|
31
|
+
DEV_MODE: false,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
function createContainerRef(): RefObject<HTMLDivElement | null> {
|
|
35
|
+
const element = document.createElement('div');
|
|
36
|
+
element.getBoundingClientRect = () =>
|
|
37
|
+
({
|
|
38
|
+
bottom: 100,
|
|
39
|
+
height: 100,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 100,
|
|
42
|
+
top: 0,
|
|
43
|
+
width: 100,
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0,
|
|
46
|
+
}) as DOMRect;
|
|
47
|
+
document.body.append(element);
|
|
48
|
+
return { current: element };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('useCursorTracking', () => {
|
|
52
|
+
let broadcastListeners: Map<string, BroadcastListener>;
|
|
53
|
+
let mockChannel: {
|
|
54
|
+
on: ReturnType<typeof vi.fn>;
|
|
55
|
+
send: ReturnType<typeof vi.fn>;
|
|
56
|
+
subscribe: ReturnType<typeof vi.fn>;
|
|
57
|
+
};
|
|
58
|
+
let mockRemoveChannel: ReturnType<typeof vi.fn>;
|
|
59
|
+
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
|
|
60
|
+
let animationFrameCallbacks: FrameRequestCallback[];
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
broadcastListeners = new Map();
|
|
64
|
+
animationFrameCallbacks = [];
|
|
65
|
+
|
|
66
|
+
requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
|
|
67
|
+
animationFrameCallbacks.push(callback);
|
|
68
|
+
return animationFrameCallbacks.length;
|
|
69
|
+
});
|
|
70
|
+
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
|
|
71
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
72
|
+
|
|
73
|
+
mockChannel = {
|
|
74
|
+
on: vi.fn(
|
|
75
|
+
(
|
|
76
|
+
type: string,
|
|
77
|
+
config: { event?: string },
|
|
78
|
+
callback: BroadcastListener
|
|
79
|
+
) => {
|
|
80
|
+
if (type === 'broadcast' && config.event) {
|
|
81
|
+
broadcastListeners.set(config.event, callback);
|
|
82
|
+
}
|
|
83
|
+
return mockChannel;
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
send: vi.fn(),
|
|
87
|
+
subscribe: vi.fn(() => mockChannel),
|
|
88
|
+
};
|
|
89
|
+
mockRemoveChannel = vi.fn();
|
|
90
|
+
|
|
91
|
+
const mockCreateClient = createClient as unknown as MockCreateClientFn;
|
|
92
|
+
mockCreateClient.mockReturnValue({
|
|
93
|
+
channel: vi.fn(() => mockChannel),
|
|
94
|
+
removeChannel: mockRemoveChannel,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
document.body.replaceChildren();
|
|
100
|
+
vi.unstubAllGlobals();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('subscribes to cursor channels with private realtime authorization', () => {
|
|
104
|
+
const containerRef = createContainerRef();
|
|
105
|
+
|
|
106
|
+
renderHook(() =>
|
|
107
|
+
useCursorTracking('board-realtime-board-1', containerRef, {
|
|
108
|
+
display_name: 'Current User',
|
|
109
|
+
id: 'user-current',
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const supabaseInstance = (createClient as unknown as MockCreateClientFn)();
|
|
114
|
+
expect(supabaseInstance.channel).toHaveBeenCalledWith(
|
|
115
|
+
'board-realtime-board-1',
|
|
116
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('broadcasts cursor payloads without private profile fields', async () => {
|
|
121
|
+
const containerRef = createContainerRef();
|
|
122
|
+
|
|
123
|
+
renderHook(() =>
|
|
124
|
+
useCursorTracking(
|
|
125
|
+
'board-realtime-board-1',
|
|
126
|
+
containerRef,
|
|
127
|
+
{
|
|
128
|
+
avatar_url: 'https://example.com/avatar.png',
|
|
129
|
+
display_name: 'Current User',
|
|
130
|
+
email: 'current@example.com',
|
|
131
|
+
id: 'user-current',
|
|
132
|
+
},
|
|
133
|
+
{ cursorScope: { boardId: 'board-1', type: 'board' } }
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
containerRef.current?.dispatchEvent(
|
|
139
|
+
new MouseEvent('mousemove', { clientX: 80, clientY: 40 })
|
|
140
|
+
);
|
|
141
|
+
animationFrameCallbacks.shift()?.(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await waitFor(() => expect(mockChannel.send).toHaveBeenCalled());
|
|
145
|
+
expect(mockChannel.send).toHaveBeenCalledWith({
|
|
146
|
+
event: 'cursor-move',
|
|
147
|
+
payload: {
|
|
148
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
149
|
+
user: {
|
|
150
|
+
avatar_url: 'https://example.com/avatar.png',
|
|
151
|
+
display_name: 'Current User',
|
|
152
|
+
id: 'user-current',
|
|
153
|
+
},
|
|
154
|
+
x: expect.any(Number),
|
|
155
|
+
y: expect.any(Number),
|
|
156
|
+
},
|
|
157
|
+
type: 'broadcast',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('accepts only well-formed cursor payloads and strips private fields', () => {
|
|
162
|
+
const containerRef = createContainerRef();
|
|
163
|
+
const { result } = renderHook(() =>
|
|
164
|
+
useCursorTracking('board-realtime-board-1', containerRef, {
|
|
165
|
+
display_name: 'Current User',
|
|
166
|
+
id: 'user-current',
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const listener = broadcastListeners.get('cursor-move');
|
|
171
|
+
expect(listener).toBeDefined();
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
listener?.({
|
|
175
|
+
payload: {
|
|
176
|
+
user: { email: 'bad@example.com' },
|
|
177
|
+
x: 12,
|
|
178
|
+
y: 18,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
expect(result.current.cursors.size).toBe(0);
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
listener?.({
|
|
186
|
+
payload: {
|
|
187
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
188
|
+
user: {
|
|
189
|
+
avatar_url: 'https://example.com/other.png',
|
|
190
|
+
display_name: 'Other User',
|
|
191
|
+
email: 'other@example.com',
|
|
192
|
+
id: 'user-other',
|
|
193
|
+
},
|
|
194
|
+
x: 12,
|
|
195
|
+
y: 18,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.current.cursors.get('user-other')).toEqual({
|
|
201
|
+
lastUpdatedAt: expect.any(Number),
|
|
202
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
203
|
+
user: {
|
|
204
|
+
avatar_url: 'https://example.com/other.png',
|
|
205
|
+
display_name: 'Other User',
|
|
206
|
+
id: 'user-other',
|
|
207
|
+
},
|
|
208
|
+
x: 12,
|
|
209
|
+
y: 18,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|