@tuturuuu/ui 0.38.0 → 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.
- package/CHANGELOG.md +27 -0
- package/package.json +6 -6
- package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1554
- package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +1 -1
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
- package/src/components/ui/text-editor/editor.tsx +3 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- package/src/components/ui/text-editor/tool-bar.tsx +18 -74
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +101 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +27 -0
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +39 -0
- package/src/hooks/calendar-connection-types.ts +49 -0
- package/src/hooks/calendar-event-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +33 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/supabase-provider.ts +39 -41
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +24 -38
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-yjs-collaboration.ts +23 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
|
+
import { Dialog, DialogContent } from '@tuturuuu/ui/dialog';
|
|
4
|
+
import { useFormatter } from 'next-intl';
|
|
5
|
+
import { EventModalHeader } from './event-modal-header';
|
|
6
|
+
|
|
7
|
+
/** Unsupported provider rules never mount the ordinary editable event form. */
|
|
8
|
+
export function ProviderReadonlyDialog({
|
|
9
|
+
event,
|
|
10
|
+
open,
|
|
11
|
+
onOpenChange,
|
|
12
|
+
}: {
|
|
13
|
+
event: CalendarEvent;
|
|
14
|
+
open: boolean;
|
|
15
|
+
onOpenChange: (open: boolean) => void;
|
|
16
|
+
}) {
|
|
17
|
+
const format = useFormatter();
|
|
18
|
+
return (
|
|
19
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
20
|
+
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
|
|
21
|
+
<EventModalHeader event={event} isEditing />
|
|
22
|
+
<div className="space-y-4 px-6 py-5">
|
|
23
|
+
<p className="text-muted-foreground text-sm">
|
|
24
|
+
<time dateTime={event.start_at}>
|
|
25
|
+
{format.dateTime(new Date(event.start_at), {
|
|
26
|
+
dateStyle: 'medium',
|
|
27
|
+
timeStyle: 'short',
|
|
28
|
+
})}
|
|
29
|
+
</time>
|
|
30
|
+
{' – '}
|
|
31
|
+
<time dateTime={event.end_at}>
|
|
32
|
+
{format.dateTime(new Date(event.end_at), {
|
|
33
|
+
dateStyle: 'medium',
|
|
34
|
+
timeStyle: 'short',
|
|
35
|
+
})}
|
|
36
|
+
</time>
|
|
37
|
+
</p>
|
|
38
|
+
{event.location && <p className="text-sm">{event.location}</p>}
|
|
39
|
+
{event.description && (
|
|
40
|
+
<p className="max-h-64 overflow-auto whitespace-pre-wrap text-sm">
|
|
41
|
+
{event.description}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</DialogContent>
|
|
46
|
+
</Dialog>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { act, renderHook } from '@testing-library/react';
|
|
3
|
-
import { expect, it, vi } from 'vitest';
|
|
4
3
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
4
|
+
import { expect, it, vi } from 'vitest';
|
|
5
5
|
import { useCalendarDraftSave } from './use-calendar-draft-save';
|
|
6
6
|
|
|
7
7
|
const query = vi.hoisted(() => ({
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuery } from '@tanstack/react-query';
|
|
2
2
|
import {
|
|
3
|
-
getGoogleCalendarColorOptions,
|
|
4
3
|
type CalendarSourceOption,
|
|
4
|
+
getGoogleCalendarColorOptions,
|
|
5
5
|
} from '@tuturuuu/internal-api';
|
|
6
6
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
7
7
|
import { Button } from '@tuturuuu/ui/button';
|
|
@@ -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
|
+
}
|
|
@@ -132,6 +132,7 @@ export interface RichTextEditorProps {
|
|
|
132
132
|
};
|
|
133
133
|
renderTaskMention?: TaskMentionNodeViewRenderer;
|
|
134
134
|
toolbarLeadingContent?: React.ReactNode;
|
|
135
|
+
toolbarToolsLabel?: string;
|
|
135
136
|
toggleBlockLabel?: string;
|
|
136
137
|
copyLabels?: EditorCopyLabels;
|
|
137
138
|
}
|
|
@@ -164,6 +165,7 @@ export function RichTextEditor({
|
|
|
164
165
|
mentionTranslations,
|
|
165
166
|
renderTaskMention,
|
|
166
167
|
toolbarLeadingContent,
|
|
168
|
+
toolbarToolsLabel,
|
|
167
169
|
toggleBlockLabel,
|
|
168
170
|
copyLabels,
|
|
169
171
|
}: RichTextEditorProps) {
|
|
@@ -651,6 +653,7 @@ export function RichTextEditor({
|
|
|
651
653
|
onImageUpload={onImageUpload}
|
|
652
654
|
onConvertToTask={onConvertToTask}
|
|
653
655
|
leadingContent={toolbarLeadingContent}
|
|
656
|
+
toolsLabel={toolbarToolsLabel}
|
|
654
657
|
toggleBlockLabel={toggleBlockLabel}
|
|
655
658
|
copyLabels={copyLabels}
|
|
656
659
|
/>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { Editor } from '@tiptap/react';
|
|
3
|
+
import { TooltipProvider } from '@tuturuuu/ui/tooltip';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { FixedToolbarContents } from './fixed-toolbar-contents';
|
|
6
|
+
|
|
7
|
+
vi.mock('./color-controls', () => ({
|
|
8
|
+
TextEditorColorControls: () => <button type="button">Colors</button>,
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('./copy-menu', () => ({
|
|
11
|
+
EditorCopyMenu: () => <button type="button">Copy tools</button>,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
function mount(toolsLabel?: string) {
|
|
15
|
+
const heading = vi.fn();
|
|
16
|
+
const bold = vi.fn();
|
|
17
|
+
const options = new Map([
|
|
18
|
+
['bold', { icon: <span>B</span>, pressed: false, onClick: bold }],
|
|
19
|
+
['heading-1', { icon: <span>H1</span>, pressed: false, onClick: heading }],
|
|
20
|
+
]);
|
|
21
|
+
render(
|
|
22
|
+
<TooltipProvider>
|
|
23
|
+
<FixedToolbarContents
|
|
24
|
+
editor={{} as Editor}
|
|
25
|
+
options={options}
|
|
26
|
+
toolsLabel={toolsLabel}
|
|
27
|
+
imageUpload={false}
|
|
28
|
+
videoUpload={false}
|
|
29
|
+
uploadingImage={false}
|
|
30
|
+
uploadingVideo={false}
|
|
31
|
+
onImage={vi.fn()}
|
|
32
|
+
onVideo={vi.fn()}
|
|
33
|
+
/>
|
|
34
|
+
</TooltipProvider>
|
|
35
|
+
);
|
|
36
|
+
return { heading, bold };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('optional compact document toolbar', () => {
|
|
40
|
+
it('retains the existing full toolbar by default', () => {
|
|
41
|
+
const { heading } = mount();
|
|
42
|
+
expect(screen.queryByRole('button', { name: 'Tools' })).toBeNull();
|
|
43
|
+
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
|
|
44
|
+
expect(heading).toHaveBeenCalledOnce();
|
|
45
|
+
expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
it('keeps primary formatting reachable and places secondary controls in Tools', () => {
|
|
48
|
+
const { heading, bold } = mount('Tools');
|
|
49
|
+
expect(screen.queryByRole('button', { name: 'Heading 1' })).toBeNull();
|
|
50
|
+
expect(screen.queryByRole('button', { name: 'Colors' })).toBeNull();
|
|
51
|
+
fireEvent.click(screen.getByRole('button', { name: 'Bold' }));
|
|
52
|
+
expect(bold).toHaveBeenCalledOnce();
|
|
53
|
+
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
|
54
|
+
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }));
|
|
55
|
+
expect(heading).toHaveBeenCalledOnce();
|
|
56
|
+
expect(screen.getByRole('button', { name: 'Colors' })).toBeTruthy();
|
|
57
|
+
expect(screen.getByRole('button', { name: 'Copy tools' })).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Editor } from '@tiptap/react';
|
|
2
|
+
import {
|
|
3
|
+
CirclePlus,
|
|
4
|
+
FileVideo,
|
|
5
|
+
ImageIcon,
|
|
6
|
+
Loader2,
|
|
7
|
+
Settings2,
|
|
8
|
+
} from '@tuturuuu/icons';
|
|
9
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
|
|
11
|
+
import type { ReactNode } from 'react';
|
|
12
|
+
import { TextEditorColorControls } from './color-controls';
|
|
13
|
+
import { type EditorCopyLabels, EditorCopyMenu } from './copy-menu';
|
|
14
|
+
import { TOOLBAR_GROUPS } from './toolbar-config';
|
|
15
|
+
import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
editor: Editor;
|
|
19
|
+
options: Map<
|
|
20
|
+
string,
|
|
21
|
+
{ onClick: () => void; pressed: boolean; icon: ReactNode }
|
|
22
|
+
>;
|
|
23
|
+
toolsLabel?: string;
|
|
24
|
+
toggleBlockLabel?: string;
|
|
25
|
+
copyLabels?: EditorCopyLabels;
|
|
26
|
+
imageUpload: boolean;
|
|
27
|
+
videoUpload: boolean;
|
|
28
|
+
uploadingImage: boolean;
|
|
29
|
+
uploadingVideo: boolean;
|
|
30
|
+
onImage: () => void;
|
|
31
|
+
onVideo: () => void;
|
|
32
|
+
onConvertToTask?: () => void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Optional compact presentation; existing editors keep the full toolbar. */
|
|
36
|
+
export function FixedToolbarContents({
|
|
37
|
+
editor,
|
|
38
|
+
options,
|
|
39
|
+
toolsLabel,
|
|
40
|
+
toggleBlockLabel,
|
|
41
|
+
copyLabels,
|
|
42
|
+
imageUpload,
|
|
43
|
+
videoUpload,
|
|
44
|
+
uploadingImage,
|
|
45
|
+
uploadingVideo,
|
|
46
|
+
onImage,
|
|
47
|
+
onVideo,
|
|
48
|
+
onConvertToTask,
|
|
49
|
+
}: Props) {
|
|
50
|
+
function control(key: string) {
|
|
51
|
+
const option = options.get(key);
|
|
52
|
+
if (!option) return null;
|
|
53
|
+
return (
|
|
54
|
+
<ToolbarButton
|
|
55
|
+
key={key}
|
|
56
|
+
id={key}
|
|
57
|
+
label={key === 'toggle-block' ? toggleBlockLabel : undefined}
|
|
58
|
+
icon={option.icon}
|
|
59
|
+
pressed={option.pressed}
|
|
60
|
+
onClick={option.onClick}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const primary = new Set(['bold', 'italic', 'strike']);
|
|
65
|
+
const groups = TOOLBAR_GROUPS.map((group, index) => {
|
|
66
|
+
const keys = toolsLabel ? group.filter((key) => !primary.has(key)) : group;
|
|
67
|
+
if (!keys.length) return null;
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
key={index}
|
|
71
|
+
className={toolsLabel ? 'flex items-center gap-1' : 'contents'}
|
|
72
|
+
>
|
|
73
|
+
{!toolsLabel && index > 0 && <ToolbarSeparator />}
|
|
74
|
+
{keys.map(control)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
const secondary = (
|
|
79
|
+
<>
|
|
80
|
+
{groups}
|
|
81
|
+
<ToolbarSeparator />
|
|
82
|
+
<TextEditorColorControls editor={editor} />
|
|
83
|
+
{imageUpload && (
|
|
84
|
+
<>
|
|
85
|
+
<ToolbarSeparator />
|
|
86
|
+
<ToolbarButton
|
|
87
|
+
id="image"
|
|
88
|
+
icon={
|
|
89
|
+
uploadingImage ? (
|
|
90
|
+
<Loader2 className="size-4 animate-spin" />
|
|
91
|
+
) : (
|
|
92
|
+
<ImageIcon className="size-4" />
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
pressed={false}
|
|
96
|
+
onClick={onImage}
|
|
97
|
+
disabled={uploadingImage}
|
|
98
|
+
/>
|
|
99
|
+
{videoUpload && (
|
|
100
|
+
<ToolbarButton
|
|
101
|
+
id="video"
|
|
102
|
+
icon={
|
|
103
|
+
uploadingVideo ? (
|
|
104
|
+
<Loader2 className="size-4 animate-spin" />
|
|
105
|
+
) : (
|
|
106
|
+
<FileVideo className="size-4" />
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
pressed={false}
|
|
110
|
+
onClick={onVideo}
|
|
111
|
+
disabled={uploadingVideo}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)}
|
|
116
|
+
{onConvertToTask && (
|
|
117
|
+
<>
|
|
118
|
+
<ToolbarSeparator />
|
|
119
|
+
<ToolbarButton
|
|
120
|
+
id="convert-to-task"
|
|
121
|
+
icon={<CirclePlus className="size-4" />}
|
|
122
|
+
pressed={false}
|
|
123
|
+
onClick={onConvertToTask}
|
|
124
|
+
/>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
<ToolbarSeparator />
|
|
128
|
+
<EditorCopyMenu editor={editor} labels={copyLabels} />
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
if (!toolsLabel) return secondary;
|
|
132
|
+
return (
|
|
133
|
+
<>
|
|
134
|
+
{[...primary].map(control)}
|
|
135
|
+
<Popover>
|
|
136
|
+
<PopoverTrigger asChild>
|
|
137
|
+
<Button
|
|
138
|
+
type="button"
|
|
139
|
+
variant="ghost"
|
|
140
|
+
size="sm"
|
|
141
|
+
className="h-8 shrink-0"
|
|
142
|
+
>
|
|
143
|
+
<Settings2 className="size-4" aria-hidden="true" />
|
|
144
|
+
{toolsLabel}
|
|
145
|
+
</Button>
|
|
146
|
+
</PopoverTrigger>
|
|
147
|
+
<PopoverContent
|
|
148
|
+
align="start"
|
|
149
|
+
className="flex w-80 max-w-[calc(100vw-2rem)] flex-wrap items-center gap-1"
|
|
150
|
+
>
|
|
151
|
+
{secondary}
|
|
152
|
+
</PopoverContent>
|
|
153
|
+
</Popover>
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
}
|