@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/components/ui/currency-input.test.tsx +43 -0
  4. package/src/components/ui/currency-input.tsx +1 -1
  5. package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
  6. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
  7. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
  8. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
  9. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
  10. package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
  11. package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
  12. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
  13. package/src/components/ui/finance/transactions/form-types.ts +2 -0
  14. package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
  15. package/src/components/ui/money-input.test.tsx +64 -0
  16. package/src/components/ui/money-input.tsx +63 -0
  17. package/src/components/ui/storefront/cart-summary.tsx +114 -29
  18. package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
  19. package/src/components/ui/storefront/hero-panel.tsx +2 -8
  20. package/src/components/ui/storefront/image-panel.tsx +6 -0
  21. package/src/components/ui/storefront/index.ts +11 -0
  22. package/src/components/ui/storefront/listing-card.tsx +84 -22
  23. package/src/components/ui/storefront/product-detail.tsx +289 -0
  24. package/src/components/ui/storefront/product-dialog.tsx +72 -0
  25. package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
  26. package/src/components/ui/storefront/storefront-surface.tsx +333 -133
  27. package/src/components/ui/storefront/types.ts +23 -1
  28. package/src/components/ui/storefront/utils.ts +111 -27
  29. package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
  30. package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
  31. package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
  32. package/src/components/ui/text-editor/content-migration.ts +41 -18
  33. package/src/components/ui/text-editor/extensions.ts +1 -1
  34. package/src/components/ui/text-editor/image-extension.ts +40 -18
  35. package/src/components/ui/text-editor/video-extension.ts +11 -2
  36. package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
  37. package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
  38. package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
  39. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
  40. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
  41. package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
  42. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
  43. package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
  44. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
  45. package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
  46. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
  47. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
  48. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
  49. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
  50. package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
  51. package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
  52. package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
  53. package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
  54. package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
  55. package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
  56. package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
  57. package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
  58. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
  59. package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
  60. package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
  61. package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
  62. package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
  63. package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
  64. package/src/hooks/useBoardRealtime.ts +6 -3
  65. package/src/hooks/useBoardRealtime.types.ts +11 -0
  66. package/src/hooks/useCursorTracking.ts +91 -27
  67. package/src/hooks/useTaskUserRealtime.ts +5 -3
@@ -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
+ });
@@ -10,8 +10,10 @@ import { toast } from './use-toast';
10
10
  import {
11
11
  type BoardRealtimePayload,
12
12
  createRealtimeClientId,
13
+ getBoardRealtimeChannelName,
13
14
  isBoardRealtimeEnvelope,
14
15
  LOCAL_BROADCAST_CHANNEL_PREFIX,
16
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG,
15
17
  type RealtimeChannel,
16
18
  SEEN_REALTIME_EVENT_LIMIT,
17
19
  } from './useBoardRealtime.types';
@@ -205,9 +207,10 @@ export function useBoardRealtime(
205
207
  channelRef.current = null;
206
208
  }
207
209
 
208
- const channel = supabase.channel(`board-realtime-${boardId}`, {
209
- config: { broadcast: { self: false } },
210
- });
210
+ const channel = supabase.channel(
211
+ getBoardRealtimeChannelName(boardId),
212
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
213
+ );
211
214
  channelRef.current = channel;
212
215
 
213
216
  channel
@@ -14,8 +14,19 @@ type BoardRealtimeEnvelope = {
14
14
  payload: BoardRealtimePayload;
15
15
  };
16
16
 
17
+ export const BOARD_REALTIME_CHANNEL_PREFIX = 'board-realtime';
17
18
  export const LOCAL_BROADCAST_CHANNEL_PREFIX = 'tuturuuu:board-realtime';
18
19
  export const SEEN_REALTIME_EVENT_LIMIT = 500;
20
+ export const PRIVATE_TASK_REALTIME_CHANNEL_CONFIG = {
21
+ config: {
22
+ broadcast: { self: false },
23
+ private: true,
24
+ },
25
+ } as const;
26
+
27
+ export function getBoardRealtimeChannelName(boardId: string) {
28
+ return `${BOARD_REALTIME_CHANNEL_PREFIX}-${boardId}`;
29
+ }
19
30
 
20
31
  const isRecord = (value: unknown): value is Record<string, unknown> =>
21
32
  typeof value === 'object' && value !== null;
@@ -5,15 +5,79 @@ import { DEV_MODE } from '@tuturuuu/utils/constants';
5
5
  import type { RefObject } from 'react';
6
6
  import { useCallback, useEffect, useRef, useState } from 'react';
7
7
  import { usePageVisibility } from './use-page-visibility';
8
+ import { PRIVATE_TASK_REALTIME_CHANNEL_CONFIG } from './useBoardRealtime.types';
9
+
10
+ type CursorUser = Pick<User, 'avatar_url' | 'display_name' | 'id'> & {
11
+ id: string;
12
+ };
8
13
 
9
14
  export interface CursorPosition {
10
15
  x: number;
11
16
  y: number;
12
- user?: User;
17
+ user?: CursorUser;
13
18
  metadata?: { [key: string]: any };
14
19
  lastUpdatedAt: number;
15
20
  }
16
21
 
22
+ type ParsedCursorPosition = CursorPosition & { user: CursorUser };
23
+
24
+ function isRecord(value: unknown): value is Record<string, unknown> {
25
+ return typeof value === 'object' && value !== null;
26
+ }
27
+
28
+ function sanitizeCursorUser(user: User | undefined): CursorUser | null {
29
+ if (!user?.id) return null;
30
+
31
+ return {
32
+ avatar_url:
33
+ typeof user.avatar_url === 'string' && user.avatar_url.length > 0
34
+ ? user.avatar_url
35
+ : null,
36
+ display_name:
37
+ typeof user.display_name === 'string' && user.display_name.length > 0
38
+ ? user.display_name
39
+ : null,
40
+ id: user.id,
41
+ };
42
+ }
43
+
44
+ function sanitizeCursorMetadata(metadata: { [key: string]: any } | undefined) {
45
+ if (metadata == null || !isRecord(metadata) || Array.isArray(metadata)) {
46
+ return undefined;
47
+ }
48
+
49
+ return metadata;
50
+ }
51
+
52
+ function parseCursorMovePayload(payload: unknown): ParsedCursorPosition | null {
53
+ if (!isRecord(payload)) return null;
54
+
55
+ const { metadata, user, x, y } = payload;
56
+ if (typeof x !== 'number' || !Number.isFinite(x)) return null;
57
+ if (typeof y !== 'number' || !Number.isFinite(y)) return null;
58
+ if (!isRecord(user) || typeof user.id !== 'string' || user.id.length === 0) {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ lastUpdatedAt: Date.now(),
64
+ metadata: sanitizeCursorMetadata(metadata as { [key: string]: any }),
65
+ user: {
66
+ avatar_url:
67
+ typeof user.avatar_url === 'string' && user.avatar_url.length > 0
68
+ ? user.avatar_url
69
+ : null,
70
+ display_name:
71
+ typeof user.display_name === 'string' && user.display_name.length > 0
72
+ ? user.display_name
73
+ : null,
74
+ id: user.id,
75
+ },
76
+ x,
77
+ y,
78
+ };
79
+ }
80
+
17
81
  export function useCursorTracking(
18
82
  channelName: string,
19
83
  containerRef?: RefObject<HTMLElement | null>,
@@ -59,6 +123,8 @@ export function useCursorTracking(
59
123
  metadata?: { [key: string]: any }
60
124
  ) => {
61
125
  if (!channelRef.current) return;
126
+ const cursorUser = sanitizeCursorUser(user);
127
+ if (cursorUser == null) return;
62
128
  // Check error count instead of state to avoid dependency
63
129
  if (errorCountRef.current >= MAX_ERROR_COUNT) return;
64
130
 
@@ -73,7 +139,12 @@ export function useCursorTracking(
73
139
  await channelRef.current?.send({
74
140
  type: 'broadcast',
75
141
  event: 'cursor-move',
76
- payload: { x, y, user, metadata },
142
+ payload: {
143
+ metadata: sanitizeCursorMetadata(metadata),
144
+ user: cursorUser,
145
+ x,
146
+ y,
147
+ },
77
148
  });
78
149
 
79
150
  lastBroadcastTimeRef.current = Date.now();
@@ -90,7 +161,12 @@ export function useCursorTracking(
90
161
  await channelRef.current.send({
91
162
  type: 'broadcast',
92
163
  event: 'cursor-move',
93
- payload: { x, y, user, metadata },
164
+ payload: {
165
+ metadata: sanitizeCursorMetadata(metadata),
166
+ user: cursorUser,
167
+ x,
168
+ y,
169
+ },
94
170
  });
95
171
 
96
172
  lastBroadcastTimeRef.current = now;
@@ -207,38 +283,25 @@ export function useCursorTracking(
207
283
  channelRef.current = null;
208
284
  }
209
285
 
210
- const channel = supabase.channel(channelName, {
211
- config: {
212
- broadcast: {
213
- self: false, // Don't receive own broadcasts
214
- },
215
- },
216
- });
286
+ const channel = supabase.channel(
287
+ channelName,
288
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
289
+ );
217
290
 
218
291
  channelRef.current = channel;
219
292
  // Listen for cursor movements from other users
220
293
  channel
221
294
  .on('broadcast', { event: 'cursor-move' }, (payload) => {
222
295
  try {
223
- const {
224
- x,
225
- y,
226
- user: broadcastUser,
227
- metadata: broadcastMetadata,
228
- } = payload.payload;
296
+ const cursor = parseCursorMovePayload(payload.payload);
297
+ if (cursor == null) return;
229
298
 
230
299
  // Ignore own broadcasts (extra safety)
231
- if (broadcastUser.id === user?.id) return;
300
+ if (cursor.user?.id === user?.id) return;
232
301
 
233
302
  setCursors((prev) => {
234
303
  const updated = new Map(prev);
235
- updated.set(broadcastUser.id || '', {
236
- x,
237
- y,
238
- user: broadcastUser,
239
- metadata: broadcastMetadata,
240
- lastUpdatedAt: Date.now(),
241
- });
304
+ updated.set(cursor.user.id, cursor);
242
305
  return updated;
243
306
  });
244
307
  } catch (err) {
@@ -289,16 +352,17 @@ export function useCursorTracking(
289
352
 
290
353
  // Broadcast cursor removal before unsubscribing (best effort)
291
354
  // This helps other users see the cursor disappear immediately
292
- if (channelRef.current && user?.id) {
355
+ const cursorUser = sanitizeCursorUser(user);
356
+ if (channelRef.current && cursorUser != null) {
293
357
  try {
294
358
  channelRef.current.send({
295
359
  type: 'broadcast',
296
360
  event: 'cursor-move',
297
361
  payload: {
362
+ metadata: sanitizeCursorMetadata(metadata),
363
+ user: cursorUser,
298
364
  x: -1000,
299
365
  y: -1000,
300
- user,
301
- metadata,
302
366
  },
303
367
  });
304
368
  } catch (err) {
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef } from 'react';
8
8
  import {
9
9
  type BoardRealtimePayload,
10
10
  createRealtimeClientId,
11
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG,
11
12
  type RealtimeChannel,
12
13
  SEEN_REALTIME_EVENT_LIMIT,
13
14
  } from './useBoardRealtime.types';
@@ -255,9 +256,10 @@ export function useTaskUserRealtime(userId: string | null | undefined) {
255
256
  return;
256
257
  }
257
258
 
258
- const channel = supabase.channel(getTaskUserRealtimeChannelName(userId), {
259
- config: { broadcast: { self: false } },
260
- });
259
+ const channel = supabase.channel(
260
+ getTaskUserRealtimeChannelName(userId),
261
+ PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
262
+ );
261
263
  channelRef.current = channel;
262
264
 
263
265
  channel