@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -0,0 +1,141 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import {
3
+ type CalendarSourceOption,
4
+ getGoogleCalendarColorOptions,
5
+ } from '@tuturuuu/internal-api';
6
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
7
+ import { Button } from '@tuturuuu/ui/button';
8
+ import { useTranslations } from 'next-intl';
9
+ import { useRef, useState } from 'react';
10
+ import { createCalendarDraftRecovery } from './calendar-draft-recovery';
11
+ import { eventSavePayload } from './event-save-payload';
12
+
13
+ type Draft = Partial<CalendarEvent>;
14
+ type SaveArgs = {
15
+ draft: Draft;
16
+ original?: CalendarEvent;
17
+ buildPayload: () => Draft;
18
+ addEvent: (
19
+ event: Omit<CalendarEvent, 'id'>,
20
+ options?: { requestId?: string }
21
+ ) => Promise<CalendarEvent | undefined>;
22
+ updateEvent: (id: string, payload: Draft) => Promise<unknown>;
23
+ closeModal: () => void;
24
+ setIsSaving: (saving: boolean) => void;
25
+ onError: () => void;
26
+ isSaving: boolean;
27
+ source?: CalendarSourceOption;
28
+ wsId?: string;
29
+ };
30
+
31
+ export function useCalendarDraftSave(args: SaveArgs) {
32
+ const t = useTranslations('calendar');
33
+ const connectionId =
34
+ args.source?.provider === 'google' ? args.source.connectionId : null;
35
+ const { data: capabilities } = useQuery({
36
+ queryKey: ['google-calendar-color-options', args.wsId, connectionId],
37
+ enabled: !!args.wsId && !!connectionId,
38
+ queryFn: () => getGoogleCalendarColorOptions(args.wsId!, connectionId!),
39
+ });
40
+ const scope = `${args.draft.ws_id}:${args.draft.requestId ?? args.original?.id}`;
41
+ const state = useRef({ scope, recovery: createCalendarDraftRecovery() });
42
+ if (state.current.scope !== scope)
43
+ state.current = { scope, recovery: createCalendarDraftRecovery() };
44
+ const [uncertainScope, setUncertainScope] = useState<string>();
45
+ const current = state.current;
46
+ const recovery = current.recovery;
47
+ const uncertain = uncertainScope === scope && !recovery.original();
48
+
49
+ async function retryOriginal() {
50
+ const submission = recovery.submitted();
51
+ if (!submission?.recoverable) return;
52
+ args.setIsSaving(true);
53
+ try {
54
+ const saved = await args.addEvent(
55
+ submission.payload as Omit<CalendarEvent, 'id'>,
56
+ {
57
+ requestId: submission.requestId,
58
+ }
59
+ );
60
+ if (!saved) throw new Error('Unconfirmed creation');
61
+ if (state.current !== current) return;
62
+ recovery.complete(saved);
63
+ // The caller may have edited the draft during the uncertain response.
64
+ // Keep those edits open; their next save updates the confirmed event ID.
65
+ setUncertainScope(undefined);
66
+ } catch {
67
+ if (state.current === current) args.onError();
68
+ } finally {
69
+ if (state.current === current) args.setIsSaving(false);
70
+ }
71
+ }
72
+
73
+ async function save() {
74
+ if (uncertain) return;
75
+ args.setIsSaving(true);
76
+ try {
77
+ const payload = args.buildPayload();
78
+ const confirmed = recovery.original();
79
+ if (confirmed) {
80
+ const sourceChanged =
81
+ JSON.stringify(payload.source) !==
82
+ JSON.stringify(recovery.submitted()?.payload.source);
83
+ const updates = eventSavePayload(payload, confirmed, sourceChanged);
84
+ if (Object.keys(updates).length)
85
+ await args.updateEvent(confirmed.id, updates);
86
+ } else if (args.original?.id === 'new') {
87
+ if (!args.draft.requestId)
88
+ throw new Error('Missing creation request ID');
89
+ const submission = recovery.capture(
90
+ payload,
91
+ args.draft.requestId,
92
+ !!connectionId &&
93
+ capabilities?.connectionId === connectionId &&
94
+ capabilities.providerColorWrites === true
95
+ );
96
+ const saved = await args.addEvent(
97
+ submission.payload as Omit<CalendarEvent, 'id'>,
98
+ {
99
+ requestId: submission.requestId,
100
+ }
101
+ );
102
+ if (!saved) throw new Error('Unconfirmed creation');
103
+ recovery.complete(saved);
104
+ } else if (args.original?.id) {
105
+ if (Object.keys(payload).length)
106
+ await args.updateEvent(args.original.id, payload);
107
+ } else throw new Error('No event to save');
108
+ if (state.current === current) args.closeModal();
109
+ } catch {
110
+ if (recovery.submitted() && !recovery.original())
111
+ setUncertainScope(scope);
112
+ if (state.current === current) args.onError();
113
+ } finally {
114
+ if (state.current === current) args.setIsSaving(false);
115
+ }
116
+ }
117
+
118
+ return {
119
+ save,
120
+ recoveryNotice: uncertain ? (
121
+ <div role="status" className="mb-2 space-y-2">
122
+ <p className="text-muted-foreground text-sm">
123
+ {t(
124
+ recovery.submitted()?.recoverable
125
+ ? 'creation_recovery_notice'
126
+ : 'creation_recovery_unavailable'
127
+ )}
128
+ </p>
129
+ {recovery.submitted()?.recoverable && (
130
+ <Button
131
+ variant="outline"
132
+ onClick={retryOriginal}
133
+ disabled={args.isSaving}
134
+ >
135
+ {t('creation_recovery_retry')}
136
+ </Button>
137
+ )}
138
+ </div>
139
+ ) : null,
140
+ };
141
+ }
@@ -0,0 +1,185 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
4
+ import type { PropsWithChildren } from 'react';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ reserve: vi.fn(),
9
+ read: vi.fn(),
10
+ retry: vi.fn(),
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/calendar-provider-series', () => ({
13
+ reserveCalendarProviderSeriesOperation: mocks.reserve,
14
+ getCalendarProviderSeriesOperation: mocks.read,
15
+ retryCalendarProviderSeriesOperation: mocks.retry,
16
+ }));
17
+
18
+ import { useProviderRecurrenceOperation } from './use-provider-recurrence-operation';
19
+
20
+ const clients: QueryClient[] = [];
21
+ const payload = {
22
+ requestId: '11111111-1111-4111-8111-111111111111',
23
+ action: 'create' as const,
24
+ source: { provider: 'google' as const, connectionId: 'connection' },
25
+ rule: {
26
+ version: 1 as const,
27
+ timeZone: 'UTC',
28
+ frequency: 'daily' as const,
29
+ interval: 1,
30
+ end: { type: 'never' as const },
31
+ },
32
+ anchor: {
33
+ startLocal: '2026-10-06T09:00:00',
34
+ endLocal: '2026-10-06T10:00:00',
35
+ allDay: false,
36
+ },
37
+ event: { title: 'Test series' },
38
+ };
39
+ function setup(
40
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } }),
41
+ actorId = 'actor'
42
+ ) {
43
+ clients.push(client);
44
+ const applied = vi.fn(),
45
+ assertActive = vi.fn();
46
+ const actor = {
47
+ actorId,
48
+ lifetime: { active: true },
49
+ assertActive,
50
+ } as Parameters<typeof useProviderRecurrenceOperation>[0]['actor'];
51
+ const wrapper = ({ children }: PropsWithChildren) => (
52
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
53
+ );
54
+ const hook = renderHook(
55
+ () =>
56
+ useProviderRecurrenceOperation({
57
+ wsId: 'workspace',
58
+ actor,
59
+ identity: 'new',
60
+ onApplied: applied,
61
+ }),
62
+ { wrapper }
63
+ );
64
+ return { ...hook, client, applied, assertActive };
65
+ }
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ sessionStorage.clear();
69
+ mocks.read.mockResolvedValue({
70
+ operationId: '11111111-1111-4111-8111-111111111111',
71
+ status: 'pending',
72
+ });
73
+ mocks.reserve.mockResolvedValue({
74
+ operationId: '11111111-1111-4111-8111-111111111111',
75
+ status: 'pending',
76
+ });
77
+ mocks.retry.mockResolvedValue({
78
+ operationId: '11111111-1111-4111-8111-111111111111',
79
+ status: 'pending',
80
+ });
81
+ });
82
+ afterEach(() => {
83
+ for (const client of clients.splice(0)) client.clear();
84
+ });
85
+ describe('recoverable provider operation UI state', () => {
86
+ it('does not finish a pending reservation and retries the exact confirmed operation', async () => {
87
+ const h = setup();
88
+ act(() => h.result.current.submission.mutate(payload));
89
+ await waitFor(() => expect(h.result.current.pending).toBe(true));
90
+ await waitFor(() =>
91
+ expect(h.result.current.submission.isSuccess).toBe(true)
92
+ );
93
+ expect(h.applied).not.toHaveBeenCalled();
94
+ act(() => h.result.current.retry());
95
+ await waitFor(() =>
96
+ expect(mocks.retry).toHaveBeenCalledWith(
97
+ 'workspace',
98
+ '11111111-1111-4111-8111-111111111111'
99
+ )
100
+ );
101
+ expect(mocks.reserve).toHaveBeenCalledTimes(1);
102
+ h.unmount();
103
+ });
104
+ it('retains an ambiguous admission across dialog dismissal and never creates a new intent', async () => {
105
+ mocks.reserve.mockRejectedValue(new Error('Response lost'));
106
+ const h = setup();
107
+ act(() => h.result.current.submission.mutate(payload));
108
+ await waitFor(() => expect(h.result.current.submission.isError).toBe(true));
109
+ h.unmount();
110
+ const reopened = setup(h.client);
111
+ expect(reopened.result.current.pending).toBe(true);
112
+ mocks.reserve.mockResolvedValue({
113
+ operationId: '11111111-1111-4111-8111-111111111111',
114
+ status: 'pending',
115
+ });
116
+ act(() => reopened.result.current.retry());
117
+ await waitFor(() => expect(mocks.reserve).toHaveBeenCalledTimes(2));
118
+ expect(mocks.reserve.mock.calls[1]).toEqual(['workspace', payload]);
119
+ reopened.unmount();
120
+ });
121
+ it('finishes only a matching applied receipt and clears the retained intent', async () => {
122
+ mocks.reserve.mockResolvedValue({
123
+ operationId: '11111111-1111-4111-8111-111111111111',
124
+ status: 'applied',
125
+ result: {},
126
+ });
127
+ const h = setup();
128
+ act(() => h.result.current.submission.mutate(payload));
129
+ await waitFor(() => expect(h.applied).toHaveBeenCalledTimes(1));
130
+ expect(h.result.current.pending).toBe(false);
131
+ h.unmount();
132
+ });
133
+
134
+ it('recovers only the operation UUID after reload without persisting event contents', async () => {
135
+ const h = setup();
136
+ act(() => h.result.current.submission.mutate(payload));
137
+ await waitFor(() =>
138
+ expect(h.result.current.submission.isSuccess).toBe(true)
139
+ );
140
+ expect(JSON.stringify(sessionStorage)).not.toContain('Test series');
141
+ h.unmount();
142
+ const restored = setup();
143
+ expect(restored.result.current.pending).toBe(true);
144
+ act(() => restored.result.current.retry());
145
+ await waitFor(() =>
146
+ expect(mocks.retry).toHaveBeenCalledWith('workspace', payload.requestId)
147
+ );
148
+ expect(mocks.reserve).toHaveBeenCalledTimes(1);
149
+ restored.unmount();
150
+ });
151
+
152
+ it('releases a completed preflight rejection only after an authoritative absent operation', async () => {
153
+ mocks.reserve.mockRejectedValue(new InternalApiError('Unsupported', 422));
154
+ mocks.read.mockRejectedValue(new InternalApiError('Absent', 404));
155
+ const h = setup();
156
+ act(() => h.result.current.submission.mutate(payload));
157
+ await waitFor(() => expect(h.result.current.submission.isError).toBe(true));
158
+ expect(h.result.current.pending).toBe(false);
159
+ expect(sessionStorage.length).toBe(0);
160
+ h.unmount();
161
+ });
162
+ it('blocks dispatch when UUID recovery storage is unavailable', async () => {
163
+ const storage = vi
164
+ .spyOn(Storage.prototype, 'setItem')
165
+ .mockImplementation(() => {
166
+ throw new Error('Storage denied');
167
+ });
168
+ const h = setup();
169
+ act(() => h.result.current.submission.mutate(payload));
170
+ await waitFor(() => expect(h.result.current.submission.isError).toBe(true));
171
+ expect(mocks.reserve).not.toHaveBeenCalled();
172
+ expect(h.result.current.pending).toBe(false);
173
+ h.unmount();
174
+ storage.mockRestore();
175
+ });
176
+ it('does not carry another actor’s pending draft into the new session', async () => {
177
+ const h = setup();
178
+ act(() => h.result.current.submission.mutate(payload));
179
+ await waitFor(() => expect(h.result.current.pending).toBe(true));
180
+ h.unmount();
181
+ const other = setup(h.client, 'another-actor');
182
+ expect(other.result.current.pending).toBe(false);
183
+ other.unmount();
184
+ });
185
+ });
@@ -0,0 +1,186 @@
1
+ 'use client';
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import {
4
+ type CalendarProviderSeriesOperationPayload,
5
+ type CalendarProviderSeriesOperationResult,
6
+ getCalendarProviderSeriesOperation,
7
+ reserveCalendarProviderSeriesOperation,
8
+ retryCalendarProviderSeriesOperation,
9
+ } from '@tuturuuu/internal-api/calendar-provider-series';
10
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
11
+ import { useEffect, useMemo } from 'react';
12
+ import type { useWorkspaceActor } from '../../../../hooks/use-workspace-visibility';
13
+
14
+ type Pending = {
15
+ operationId: string;
16
+ payload?: CalendarProviderSeriesOperationPayload;
17
+ confirmed: boolean;
18
+ };
19
+ /** Retained within the actor-scoped query cache so dismissing a pending dialog cannot create another intent. */
20
+ export function useProviderRecurrenceOperation({
21
+ wsId,
22
+ actor,
23
+ identity,
24
+ onApplied,
25
+ }: {
26
+ wsId: string;
27
+ actor: NonNullable<ReturnType<typeof useWorkspaceActor>>;
28
+ identity: string;
29
+ onApplied: () => void;
30
+ }) {
31
+ const client = useQueryClient();
32
+ const key = useMemo(
33
+ () => ['calendar-provider-pending', wsId, actor.actorId, identity],
34
+ [wsId, actor.actorId, identity]
35
+ );
36
+ const storageKey = `calendar-provider-operation:${wsId}:${actor.actorId}:${identity}`;
37
+ const retained = useQuery<Pending | null>({
38
+ queryKey: key,
39
+ queryFn: () => null,
40
+ enabled: false,
41
+ initialData: () => {
42
+ try {
43
+ const id = sessionStorage.getItem(storageKey);
44
+ return id && /^[0-9a-f]{8}-[0-9a-f-]{27,40}$/i.test(id)
45
+ ? { operationId: id, confirmed: true }
46
+ : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ },
51
+ gcTime: Infinity,
52
+ });
53
+ const pending = retained.data;
54
+ const status = useQuery({
55
+ queryKey: [
56
+ 'calendar-provider-operation',
57
+ wsId,
58
+ actor.actorId,
59
+ pending?.operationId,
60
+ ],
61
+ enabled: !!pending,
62
+ queryFn: async ({ signal }) => {
63
+ actor.assertActive();
64
+ const value = await getCalendarProviderSeriesOperation(
65
+ wsId,
66
+ pending!.operationId,
67
+ { signal }
68
+ );
69
+ actor.assertActive();
70
+ return value;
71
+ },
72
+ retry: false,
73
+ refetchInterval: (query) => {
74
+ if (query.state.data?.status === 'applied') return false;
75
+ const error = query.state.error;
76
+ if (
77
+ error instanceof InternalApiError &&
78
+ [401, 403, 404, 409, 422, 429].includes(error.status)
79
+ )
80
+ return false;
81
+ // Network/server failures need an explicit retry, not a repeated request storm.
82
+ return error ? false : 2000;
83
+ },
84
+ });
85
+ const submission = useMutation({
86
+ mutationFn: async (payload?: CalendarProviderSeriesOperationPayload) => {
87
+ actor.assertActive();
88
+ const current = client.getQueryData<Pending | null>(key);
89
+ const intent = current?.payload ?? payload;
90
+ const operationId = current?.operationId ?? intent?.requestId;
91
+ if (!operationId) throw new Error('No provider operation');
92
+ // UUID only: preserve recovery across reloads without storing event content or credentials.
93
+ // If storage is unavailable, fail before admitting a request that cannot be recovered.
94
+ sessionStorage.setItem(storageKey, operationId);
95
+ // Retain before sending: a failed response may follow a successful remote reservation.
96
+ client.setQueryData<Pending>(key, {
97
+ operationId,
98
+ payload: intent,
99
+ confirmed: current?.confirmed ?? false,
100
+ });
101
+ let value: CalendarProviderSeriesOperationResult;
102
+ try {
103
+ value =
104
+ current?.confirmed || !intent
105
+ ? await retryCalendarProviderSeriesOperation(wsId, operationId)
106
+ : await reserveCalendarProviderSeriesOperation(wsId, intent);
107
+ } catch (error) {
108
+ // Only a completed rejection plus authoritative absence can release an intent.
109
+ // Transport errors retain it because the server may already have reserved it.
110
+ if (
111
+ !current?.confirmed &&
112
+ error instanceof InternalApiError &&
113
+ [400, 403, 404, 409, 422].includes(error.status)
114
+ ) {
115
+ try {
116
+ await getCalendarProviderSeriesOperation(wsId, operationId);
117
+ } catch (lookupError) {
118
+ actor.assertActive();
119
+ if (
120
+ lookupError instanceof InternalApiError &&
121
+ lookupError.status === 404
122
+ ) {
123
+ sessionStorage.removeItem(storageKey);
124
+ client.setQueryData(key, null);
125
+ }
126
+ }
127
+ }
128
+ throw error;
129
+ }
130
+ actor.assertActive();
131
+ const operationKey = [
132
+ 'calendar-provider-operation',
133
+ wsId,
134
+ actor.actorId,
135
+ operationId,
136
+ ];
137
+ const observed =
138
+ client.getQueryData<CalendarProviderSeriesOperationResult>(
139
+ operationKey
140
+ );
141
+ if (
142
+ observed?.status === 'applied' &&
143
+ observed.operationId === operationId
144
+ )
145
+ return observed;
146
+ client.setQueryData<Pending>(key, {
147
+ operationId,
148
+ payload: intent,
149
+ confirmed: true,
150
+ });
151
+ client.setQueryData<CalendarProviderSeriesOperationResult>(
152
+ ['calendar-provider-operation', wsId, actor.actorId, operationId],
153
+ value
154
+ );
155
+ return value;
156
+ },
157
+ });
158
+ const receipt =
159
+ status.data?.status === 'applied' ? status.data : submission.data;
160
+ const applied =
161
+ !!pending &&
162
+ receipt?.status === 'applied' &&
163
+ receipt.operationId === pending.operationId;
164
+ useEffect(() => {
165
+ if (!applied) return;
166
+ try {
167
+ actor.assertActive();
168
+ } catch {
169
+ return;
170
+ }
171
+ if (!client.getQueryData<Pending | null>(key)) return;
172
+ try {
173
+ sessionStorage.removeItem(storageKey);
174
+ } catch {
175
+ /* An applied UUID is safe to recover again. */
176
+ }
177
+ client.setQueryData(key, null);
178
+ onApplied();
179
+ }, [applied, actor, client, key, storageKey, onApplied]);
180
+ return {
181
+ pending: !!pending,
182
+ submission,
183
+ status,
184
+ retry: () => submission.mutate(undefined),
185
+ };
186
+ }
@@ -44,4 +44,6 @@ function PopoverAnchor({
44
44
  return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
45
45
  }
46
46
 
47
- export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
47
+ const PopoverClose = PopoverPrimitive.Close;
48
+
49
+ export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger };
@@ -0,0 +1,142 @@
1
+ import { waitFor } from '@testing-library/react';
2
+ import { Editor } from '@tiptap/core';
3
+ import Collaboration from '@tiptap/extension-collaboration';
4
+ import CollaborationCaret from '@tiptap/extension-collaboration-caret';
5
+ import StarterKit from '@tiptap/starter-kit';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ Awareness,
9
+ applyAwarenessUpdate,
10
+ encodeAwarenessUpdate,
11
+ } from 'y-protocols/awareness';
12
+ import * as Y from 'yjs';
13
+ import {
14
+ renderCollaborationCaret,
15
+ renderCollaborationSelection,
16
+ scrollToCollaborationCaret,
17
+ } from '../collaboration-carets';
18
+
19
+ describe('remote cursors and selections', () => {
20
+ it('identifies duplicate names by account ID and safely renders untrusted names', () => {
21
+ const root = document.createElement('div');
22
+ const first = renderCollaborationCaret({
23
+ id: 'first',
24
+ name: 'Alex',
25
+ color: '#123456',
26
+ });
27
+ const second = renderCollaborationCaret({
28
+ id: 'second',
29
+ name: 'Alex',
30
+ color: '#654321',
31
+ });
32
+ const scrollFirst = vi.fn();
33
+ const scrollSecond = vi.fn();
34
+ first.scrollIntoView = scrollFirst;
35
+ second.scrollIntoView = scrollSecond;
36
+ root.append(first, second);
37
+ expect(scrollToCollaborationCaret(root, 'second')).toBe(true);
38
+ expect(scrollFirst).not.toHaveBeenCalled();
39
+ expect(scrollSecond).toHaveBeenCalledOnce();
40
+ const unsafe = renderCollaborationCaret({
41
+ id: 'third',
42
+ name: '<img src=x onerror=alert(1)>',
43
+ color: 'url(evil)',
44
+ });
45
+ expect(unsafe.querySelector('img')).toBeNull();
46
+ expect(unsafe.textContent).toBe('<img src=x onerror=alert(1)>');
47
+ expect(
48
+ renderCollaborationSelection({ color: 'url(evil)' }).style
49
+ ).not.toContain('evil');
50
+ });
51
+ it('renders a real Tiptap peer caret and highlights a remote range after Yjs sync', async () => {
52
+ const a = new Y.Doc();
53
+ const b = new Y.Doc();
54
+ const awarenessA = new Awareness(a);
55
+ const awarenessB = new Awareness(b);
56
+ const sync = 'wire';
57
+ a.on('update', (update, origin) => {
58
+ if (origin !== sync) Y.applyUpdate(b, update, sync);
59
+ });
60
+ b.on('update', (update, origin) => {
61
+ if (origin !== sync) Y.applyUpdate(a, update, sync);
62
+ });
63
+ for (const [source, target] of [
64
+ [awarenessA, awarenessB],
65
+ [awarenessB, awarenessA],
66
+ ] as const) {
67
+ source.on(
68
+ 'update',
69
+ (
70
+ {
71
+ added,
72
+ updated,
73
+ removed,
74
+ }: { added: number[]; updated: number[]; removed: number[] },
75
+ origin: unknown
76
+ ) => {
77
+ if (origin !== sync)
78
+ applyAwarenessUpdate(
79
+ target,
80
+ encodeAwarenessUpdate(source, [...added, ...updated, ...removed]),
81
+ sync
82
+ );
83
+ }
84
+ );
85
+ }
86
+ const elements = [
87
+ document.createElement('div'),
88
+ document.createElement('div'),
89
+ ];
90
+ for (const element of elements) document.body.append(element);
91
+ const editors = [
92
+ [a, awarenessA, 'first'],
93
+ [b, awarenessB, 'second'],
94
+ ].map(
95
+ ([doc, awareness, id], index) =>
96
+ new Editor({
97
+ element: elements[index],
98
+ extensions: [
99
+ StarterKit.configure({ undoRedo: false }),
100
+ Collaboration.configure({
101
+ document: doc as Y.Doc,
102
+ field: 'prosemirror',
103
+ }),
104
+ CollaborationCaret.configure({
105
+ provider: { awareness },
106
+ user: { id, name: 'Alex', color: '#123456' },
107
+ render: renderCollaborationCaret,
108
+ selectionRender: renderCollaborationSelection,
109
+ }),
110
+ ],
111
+ })
112
+ );
113
+ try {
114
+ editors[0]!.commands.setContent('<p>Hello shared document</p>');
115
+ await waitFor(() =>
116
+ expect(editors[1]!.getText()).toBe('Hello shared document')
117
+ );
118
+ editors[0]!.view.focus();
119
+ editors[0]!.commands.setTextSelection({ from: 2, to: 6 });
120
+ await waitFor(() =>
121
+ expect(
122
+ elements[1]!.querySelector('.collaboration-carets__selection')
123
+ ).not.toBeNull()
124
+ );
125
+ expect(
126
+ elements[1]!.querySelector<HTMLElement>('.collaboration-carets__caret')
127
+ ?.dataset.userId
128
+ ).toBe('first');
129
+ expect(
130
+ elements[1]!.querySelector('.collaboration-carets__selection')
131
+ ?.textContent
132
+ ).toBe('ello');
133
+ } finally {
134
+ for (const editor of editors) editor.destroy();
135
+ awarenessA.destroy();
136
+ awarenessB.destroy();
137
+ a.destroy();
138
+ b.destroy();
139
+ for (const element of elements) element.remove();
140
+ }
141
+ });
142
+ });