@tuturuuu/ui 0.38.0 → 0.39.1
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 +34 -0
- package/package.json +8 -8
- package/src/components/ui/custom/public-profile-menu-item.tsx +25 -0
- package/src/components/ui/custom/satellite-user-menu-items.test.tsx +61 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +8 -0
- 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 +489 -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/public-link-button.test.tsx +116 -0
- package/src/components/ui/public-link-button.tsx +81 -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,489 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react';
|
|
10
|
+
import type { ComponentProps } from 'react';
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
useWorkspaceActor,
|
|
14
|
+
WorkspaceVisibilityProvider,
|
|
15
|
+
} from '../../../../../hooks/use-workspace-visibility';
|
|
16
|
+
import InvoiceCard from './invoice-card';
|
|
17
|
+
|
|
18
|
+
const notices = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
|
19
|
+
const translate = vi.hoisted(() => (key: string) => key);
|
|
20
|
+
vi.mock('next-intl', () => ({ useTranslations: () => translate }));
|
|
21
|
+
vi.mock('@tuturuuu/ui/sonner', () => ({ toast: notices }));
|
|
22
|
+
const renderer = vi.fn();
|
|
23
|
+
const importStarted = vi.fn();
|
|
24
|
+
const downloads: string[] = [];
|
|
25
|
+
let lease: ReturnType<typeof useWorkspaceActor>;
|
|
26
|
+
function Witness() {
|
|
27
|
+
lease = useWorkspaceActor();
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function held<T>() {
|
|
31
|
+
let resolve!: (value: T) => void;
|
|
32
|
+
let reject!: (error: Error) => void;
|
|
33
|
+
const promise = new Promise<T>((yes, no) => {
|
|
34
|
+
resolve = yes;
|
|
35
|
+
reject = no;
|
|
36
|
+
});
|
|
37
|
+
return { promise, resolve, reject };
|
|
38
|
+
}
|
|
39
|
+
function canvas() {
|
|
40
|
+
return {
|
|
41
|
+
toBlob: (callback: BlobCallback) =>
|
|
42
|
+
callback(new Blob(['synthetic PNG'], { type: 'image/png' })),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function invoice(id: string): ComponentProps<typeof InvoiceCard>['invoice'] {
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
category_id: 'synthetic-category',
|
|
49
|
+
completed_at: null,
|
|
50
|
+
creator_id: null,
|
|
51
|
+
customer_id: null,
|
|
52
|
+
note: null,
|
|
53
|
+
notice: null,
|
|
54
|
+
paid_amount: 0,
|
|
55
|
+
platform_creator_id: null,
|
|
56
|
+
subscription_months: null,
|
|
57
|
+
transaction_id: null,
|
|
58
|
+
valid_until: null,
|
|
59
|
+
wallet_id: 'synthetic-wallet',
|
|
60
|
+
ws_id: 'synthetic-workspace',
|
|
61
|
+
created_at: '2026-10-01T10:00:00Z',
|
|
62
|
+
price: 100,
|
|
63
|
+
total_diff: 0,
|
|
64
|
+
customer_display_name: `Customer ${id}`,
|
|
65
|
+
customer_full_name: null,
|
|
66
|
+
wallet: null,
|
|
67
|
+
creator: null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function mount(
|
|
71
|
+
options: {
|
|
72
|
+
actor?: string | null;
|
|
73
|
+
ws?: string;
|
|
74
|
+
invoiceWs?: string;
|
|
75
|
+
auto?: boolean;
|
|
76
|
+
} = {}
|
|
77
|
+
) {
|
|
78
|
+
const client = new QueryClient({
|
|
79
|
+
defaultOptions: { queries: { retry: false } },
|
|
80
|
+
});
|
|
81
|
+
const content = (actor: string | null, id: string, customerName?: string) => {
|
|
82
|
+
const body = (
|
|
83
|
+
<>
|
|
84
|
+
<Witness />
|
|
85
|
+
<InvoiceCard
|
|
86
|
+
wsId={options.ws ?? 'synthetic-workspace'}
|
|
87
|
+
lang="en"
|
|
88
|
+
configs={[]}
|
|
89
|
+
invoice={{
|
|
90
|
+
...invoice(id),
|
|
91
|
+
ws_id: options.invoiceWs ?? 'synthetic-workspace',
|
|
92
|
+
customer_display_name: customerName ?? `Customer ${id}`,
|
|
93
|
+
}}
|
|
94
|
+
products={[]}
|
|
95
|
+
promotions={[]}
|
|
96
|
+
/>
|
|
97
|
+
</>
|
|
98
|
+
);
|
|
99
|
+
return (
|
|
100
|
+
<QueryClientProvider client={client}>
|
|
101
|
+
{actor !== null ? (
|
|
102
|
+
<WorkspaceVisibilityProvider actorId={actor}>
|
|
103
|
+
{body}
|
|
104
|
+
</WorkspaceVisibilityProvider>
|
|
105
|
+
) : (
|
|
106
|
+
body
|
|
107
|
+
)}
|
|
108
|
+
</QueryClientProvider>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
if (options.auto) window.history.replaceState({}, '', '/?image=true');
|
|
112
|
+
const view = render(
|
|
113
|
+
content(
|
|
114
|
+
options.actor === undefined ? 'account-A' : options.actor,
|
|
115
|
+
'synthetic-A'
|
|
116
|
+
)
|
|
117
|
+
);
|
|
118
|
+
return {
|
|
119
|
+
...view,
|
|
120
|
+
redraw: (
|
|
121
|
+
actor: string | null = 'account-A',
|
|
122
|
+
id = 'synthetic-A',
|
|
123
|
+
customerName?: string
|
|
124
|
+
) => view.rerender(content(actor, id, customerName)),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function exportPng() {
|
|
128
|
+
const openAction = screen.queryByRole('menuitem', {
|
|
129
|
+
name: 'invoices.download_image',
|
|
130
|
+
});
|
|
131
|
+
if (openAction) {
|
|
132
|
+
fireEvent.click(openAction);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
await screen.findByRole('tab', { name: 'invoices.full' });
|
|
136
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'common.export' }), {
|
|
137
|
+
button: 0,
|
|
138
|
+
ctrlKey: false,
|
|
139
|
+
});
|
|
140
|
+
fireEvent.click(
|
|
141
|
+
await screen.findByRole('menuitem', { name: 'invoices.download_image' })
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
beforeEach(() => {
|
|
145
|
+
vi.resetModules();
|
|
146
|
+
renderer.mockReset().mockResolvedValue(canvas());
|
|
147
|
+
importStarted.mockReset();
|
|
148
|
+
notices.success.mockReset();
|
|
149
|
+
notices.error.mockReset();
|
|
150
|
+
downloads.length = 0;
|
|
151
|
+
vi.stubGlobal(
|
|
152
|
+
'fetch',
|
|
153
|
+
vi.fn(() => {
|
|
154
|
+
throw new Error('Unexpected synthetic export network request');
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
// biome-ignore lint/suspicious/noDocumentCookie: Synthetic finance visibility fixture.
|
|
158
|
+
document.cookie = 'finance-confidential-mode=false;path=/';
|
|
159
|
+
window.localStorage.clear();
|
|
160
|
+
window.history.replaceState({}, '', '/');
|
|
161
|
+
vi.doMock('html2canvas-pro', () => {
|
|
162
|
+
importStarted();
|
|
163
|
+
return { default: renderer };
|
|
164
|
+
});
|
|
165
|
+
const NativeURL = URL;
|
|
166
|
+
vi.stubGlobal(
|
|
167
|
+
'URL',
|
|
168
|
+
class extends NativeURL {
|
|
169
|
+
static createObjectURL = vi.fn(() => 'blob:synthetic-png');
|
|
170
|
+
static revokeObjectURL = vi.fn();
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
174
|
+
this: HTMLAnchorElement
|
|
175
|
+
) {
|
|
176
|
+
downloads.push(this.download);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
cleanup();
|
|
181
|
+
vi.unstubAllGlobals();
|
|
182
|
+
vi.restoreAllMocks();
|
|
183
|
+
vi.doUnmock('html2canvas-pro');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('actual invoice card PNG export account and invoice lifetime', () => {
|
|
187
|
+
it('invalidates a held capture when actual confidential amount visibility changes', async () => {
|
|
188
|
+
const result = held<ReturnType<typeof canvas>>();
|
|
189
|
+
renderer.mockReturnValue(result.promise);
|
|
190
|
+
mount();
|
|
191
|
+
await exportPng();
|
|
192
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
193
|
+
act(() => {
|
|
194
|
+
// biome-ignore lint/suspicious/noDocumentCookie: Real visibility hook reads this synthetic fixture.
|
|
195
|
+
document.cookie = 'finance-confidential-mode=true;path=/';
|
|
196
|
+
window.dispatchEvent(new Event('finance-confidential-mode-change'));
|
|
197
|
+
});
|
|
198
|
+
await waitFor(() =>
|
|
199
|
+
expect(screen.getAllByText('•••••').length).toBeGreaterThan(0)
|
|
200
|
+
);
|
|
201
|
+
await act(async () => {
|
|
202
|
+
result.resolve(canvas());
|
|
203
|
+
await result.promise;
|
|
204
|
+
});
|
|
205
|
+
expect(downloads).toEqual([]);
|
|
206
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
207
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('does not capture replacement invoice DOM after a held renderer import', async () => {
|
|
211
|
+
const module = held<{ default: typeof renderer }>();
|
|
212
|
+
vi.doMock('html2canvas-pro', () => {
|
|
213
|
+
importStarted();
|
|
214
|
+
return module.promise;
|
|
215
|
+
});
|
|
216
|
+
const view = mount();
|
|
217
|
+
await exportPng();
|
|
218
|
+
await waitFor(() => expect(importStarted).toHaveBeenCalledOnce());
|
|
219
|
+
view.redraw('account-A', 'synthetic-B');
|
|
220
|
+
expect(screen.getByText('Customer synthetic-B')).toBeVisible();
|
|
221
|
+
await act(async () => {
|
|
222
|
+
module.resolve({ default: renderer });
|
|
223
|
+
await module.promise;
|
|
224
|
+
});
|
|
225
|
+
expect(renderer).not.toHaveBeenCalled();
|
|
226
|
+
expect(downloads).toEqual([]);
|
|
227
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
228
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
it('does not finish a held render for a replaced invoice', async () => {
|
|
231
|
+
const renderResult = held<ReturnType<typeof canvas>>();
|
|
232
|
+
renderer.mockReturnValueOnce(renderResult.promise);
|
|
233
|
+
const view = mount();
|
|
234
|
+
await exportPng();
|
|
235
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
236
|
+
view.redraw('account-A', 'synthetic-B');
|
|
237
|
+
await act(async () => {
|
|
238
|
+
renderResult.resolve(canvas());
|
|
239
|
+
await renderResult.promise;
|
|
240
|
+
});
|
|
241
|
+
expect(downloads).toEqual([]);
|
|
242
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
243
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
245
|
+
for (const boundary of ['replacement', 'ABA', 'unmount'] as const) {
|
|
246
|
+
it(`does not finish a held render after actor ${boundary}`, async () => {
|
|
247
|
+
const renderResult = held<ReturnType<typeof canvas>>();
|
|
248
|
+
renderer.mockReturnValueOnce(renderResult.promise);
|
|
249
|
+
const view = mount();
|
|
250
|
+
await exportPng();
|
|
251
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
252
|
+
const expired = lease;
|
|
253
|
+
if (boundary === 'unmount') view.unmount();
|
|
254
|
+
else {
|
|
255
|
+
view.redraw('account-B');
|
|
256
|
+
if (boundary === 'ABA') {
|
|
257
|
+
await act(async () => {
|
|
258
|
+
await Promise.resolve();
|
|
259
|
+
});
|
|
260
|
+
view.redraw('account-A');
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
expect(() => expired?.assertActive()).toThrow(
|
|
264
|
+
'Workspace account changed'
|
|
265
|
+
);
|
|
266
|
+
await act(async () => {
|
|
267
|
+
renderResult.resolve(canvas());
|
|
268
|
+
await renderResult.promise;
|
|
269
|
+
});
|
|
270
|
+
expect(downloads).toEqual([]);
|
|
271
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
272
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
it('does not finish a held blob conversion after invoice replacement', async () => {
|
|
276
|
+
let deliver!: BlobCallback;
|
|
277
|
+
renderer.mockResolvedValueOnce({
|
|
278
|
+
toBlob: (callback: BlobCallback) => {
|
|
279
|
+
deliver = callback;
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
const view = mount();
|
|
283
|
+
await exportPng();
|
|
284
|
+
await waitFor(() => expect(deliver).toBeTypeOf('function'));
|
|
285
|
+
view.redraw('account-A', 'synthetic-B');
|
|
286
|
+
await act(async () => {
|
|
287
|
+
deliver(new Blob(['synthetic PNG'], { type: 'image/png' }));
|
|
288
|
+
await Promise.resolve();
|
|
289
|
+
});
|
|
290
|
+
expect(downloads).toEqual([]);
|
|
291
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
292
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
it('does not notify a replacement actor about an old renderer failure', async () => {
|
|
295
|
+
const renderResult = held<ReturnType<typeof canvas>>();
|
|
296
|
+
renderer.mockReturnValueOnce(renderResult.promise);
|
|
297
|
+
const view = mount();
|
|
298
|
+
await exportPng();
|
|
299
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
300
|
+
view.redraw('account-B');
|
|
301
|
+
await act(async () => {
|
|
302
|
+
renderResult.reject(new Error('Synthetic renderer failure'));
|
|
303
|
+
});
|
|
304
|
+
expect(downloads).toEqual([]);
|
|
305
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
it('exports the actual current invoice with the correct filename and element', async () => {
|
|
308
|
+
mount();
|
|
309
|
+
await exportPng();
|
|
310
|
+
await waitFor(() => expect(downloads).toEqual(['invoice-synthetic_a.png']));
|
|
311
|
+
expect(renderer).toHaveBeenCalledOnce();
|
|
312
|
+
expect(renderer.mock.calls[0]?.[0]).toBe(
|
|
313
|
+
document.getElementById('printable-area')
|
|
314
|
+
);
|
|
315
|
+
expect(notices.success).toHaveBeenCalledWith('common.export-success');
|
|
316
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
317
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic-png');
|
|
318
|
+
});
|
|
319
|
+
it('keeps the same invoice export valid across an unchanged rerender', async () => {
|
|
320
|
+
const renderResult = held<ReturnType<typeof canvas>>();
|
|
321
|
+
renderer.mockReturnValueOnce(renderResult.promise);
|
|
322
|
+
const view = mount();
|
|
323
|
+
await exportPng();
|
|
324
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
325
|
+
view.redraw();
|
|
326
|
+
await act(async () => {
|
|
327
|
+
renderResult.resolve(canvas());
|
|
328
|
+
await renderResult.promise;
|
|
329
|
+
});
|
|
330
|
+
expect(downloads).toEqual(['invoice-synthetic_a.png']);
|
|
331
|
+
expect(notices.success).toHaveBeenCalledOnce();
|
|
332
|
+
});
|
|
333
|
+
it('keeps current renderer failure truthful without a download', async () => {
|
|
334
|
+
renderer.mockRejectedValueOnce(
|
|
335
|
+
new Error('Synthetic current renderer failure')
|
|
336
|
+
);
|
|
337
|
+
mount();
|
|
338
|
+
await exportPng();
|
|
339
|
+
await waitFor(() =>
|
|
340
|
+
expect(notices.error).toHaveBeenCalledWith('common.export-error')
|
|
341
|
+
);
|
|
342
|
+
expect(downloads).toEqual([]);
|
|
343
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
for (const failure of ['renderer', 'blob'] as const) {
|
|
346
|
+
it(`allows one same-invoice retry after a ${failure} failure`, async () => {
|
|
347
|
+
if (failure === 'renderer') {
|
|
348
|
+
renderer.mockRejectedValueOnce(new Error('Synthetic renderer failure'));
|
|
349
|
+
} else {
|
|
350
|
+
renderer.mockResolvedValueOnce({
|
|
351
|
+
toBlob: (callback: BlobCallback) => callback(null),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const retry = held<ReturnType<typeof canvas>>();
|
|
355
|
+
renderer.mockReturnValueOnce(retry.promise);
|
|
356
|
+
mount();
|
|
357
|
+
await exportPng();
|
|
358
|
+
await waitFor(() =>
|
|
359
|
+
expect(notices.error).toHaveBeenCalledWith('common.export-error')
|
|
360
|
+
);
|
|
361
|
+
expect(downloads).toEqual([]);
|
|
362
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
363
|
+
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
|
364
|
+
await waitFor(() =>
|
|
365
|
+
expect(
|
|
366
|
+
screen.getByRole('menuitem', { name: 'invoices.download_image' })
|
|
367
|
+
).not.toHaveAttribute('aria-disabled', 'true')
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
await exportPng();
|
|
371
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
|
|
372
|
+
const exporting = screen.getByRole('menuitem', {
|
|
373
|
+
name: 'ws-reports.exporting_png',
|
|
374
|
+
});
|
|
375
|
+
expect(exporting).toHaveAttribute('aria-disabled', 'true');
|
|
376
|
+
fireEvent.click(exporting);
|
|
377
|
+
expect(renderer).toHaveBeenCalledTimes(2);
|
|
378
|
+
expect(downloads).toEqual([]);
|
|
379
|
+
|
|
380
|
+
await act(async () => {
|
|
381
|
+
retry.resolve(canvas());
|
|
382
|
+
await retry.promise;
|
|
383
|
+
});
|
|
384
|
+
await waitFor(() =>
|
|
385
|
+
expect(downloads).toEqual(['invoice-synthetic_a.png'])
|
|
386
|
+
);
|
|
387
|
+
expect(notices.error).toHaveBeenCalledOnce();
|
|
388
|
+
expect(notices.success).toHaveBeenCalledOnce();
|
|
389
|
+
expect(URL.createObjectURL).toHaveBeenCalledOnce();
|
|
390
|
+
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic-png');
|
|
391
|
+
expect(
|
|
392
|
+
screen.getByRole('menuitem', { name: 'invoices.download_image' })
|
|
393
|
+
).not.toHaveAttribute('aria-disabled', 'true');
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
for (const boundary of [
|
|
397
|
+
'missing-provider',
|
|
398
|
+
'missing-actor',
|
|
399
|
+
'missing-workspace',
|
|
400
|
+
'foreign-invoice-workspace',
|
|
401
|
+
] as const) {
|
|
402
|
+
it(`denies PNG admission for ${boundary}`, async () => {
|
|
403
|
+
mount({
|
|
404
|
+
actor:
|
|
405
|
+
boundary === 'missing-provider'
|
|
406
|
+
? null
|
|
407
|
+
: boundary === 'missing-actor'
|
|
408
|
+
? ''
|
|
409
|
+
: 'account-A',
|
|
410
|
+
ws: boundary === 'missing-workspace' ? '' : 'synthetic-workspace',
|
|
411
|
+
invoiceWs:
|
|
412
|
+
boundary === 'foreign-invoice-workspace'
|
|
413
|
+
? 'other-workspace'
|
|
414
|
+
: undefined,
|
|
415
|
+
});
|
|
416
|
+
await exportPng();
|
|
417
|
+
expect(renderer).not.toHaveBeenCalled();
|
|
418
|
+
expect(importStarted).not.toHaveBeenCalled();
|
|
419
|
+
expect(downloads).toEqual([]);
|
|
420
|
+
expect(notices.error).toHaveBeenCalledOnce();
|
|
421
|
+
expect(notices.error).toHaveBeenCalledWith('common.export-error');
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
it('invalidates held rendering when same-invoice content changes', async () => {
|
|
425
|
+
const result = held<ReturnType<typeof canvas>>();
|
|
426
|
+
renderer.mockReturnValueOnce(result.promise);
|
|
427
|
+
const view = mount();
|
|
428
|
+
await exportPng();
|
|
429
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
430
|
+
view.redraw('account-A', 'synthetic-A', 'Updated synthetic customer');
|
|
431
|
+
await act(async () => {
|
|
432
|
+
result.resolve(canvas());
|
|
433
|
+
await result.promise;
|
|
434
|
+
});
|
|
435
|
+
expect(downloads).toEqual([]);
|
|
436
|
+
expect(notices.success).not.toHaveBeenCalled();
|
|
437
|
+
expect(notices.error).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
it('does not let an old finally clear a new invoice export owner', async () => {
|
|
440
|
+
const old = held<ReturnType<typeof canvas>>();
|
|
441
|
+
const next = held<ReturnType<typeof canvas>>();
|
|
442
|
+
renderer.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
|
|
443
|
+
const view = mount();
|
|
444
|
+
await exportPng();
|
|
445
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
446
|
+
view.redraw('account-A', 'synthetic-B');
|
|
447
|
+
await exportPng();
|
|
448
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
|
|
449
|
+
await act(async () => {
|
|
450
|
+
old.resolve(canvas());
|
|
451
|
+
await old.promise;
|
|
452
|
+
});
|
|
453
|
+
const exporting = screen.getByRole('menuitem', {
|
|
454
|
+
name: 'ws-reports.exporting_png',
|
|
455
|
+
});
|
|
456
|
+
expect(exporting).toHaveAttribute('aria-disabled', 'true');
|
|
457
|
+
fireEvent.click(exporting);
|
|
458
|
+
expect(renderer).toHaveBeenCalledTimes(2);
|
|
459
|
+
expect(downloads).toEqual([]);
|
|
460
|
+
await act(async () => {
|
|
461
|
+
next.resolve(canvas());
|
|
462
|
+
await next.promise;
|
|
463
|
+
});
|
|
464
|
+
expect(downloads).toEqual(['invoice-synthetic_b.png']);
|
|
465
|
+
expect(notices.success).toHaveBeenCalledOnce();
|
|
466
|
+
});
|
|
467
|
+
it('preserves a new auto-image intent until its own export completes', async () => {
|
|
468
|
+
const old = held<ReturnType<typeof canvas>>();
|
|
469
|
+
const next = held<ReturnType<typeof canvas>>();
|
|
470
|
+
renderer.mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise);
|
|
471
|
+
const view = mount({ auto: true });
|
|
472
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledOnce());
|
|
473
|
+
view.redraw('account-B', 'synthetic-B');
|
|
474
|
+
await waitFor(() => expect(renderer).toHaveBeenCalledTimes(2));
|
|
475
|
+
await act(async () => {
|
|
476
|
+
old.resolve(canvas());
|
|
477
|
+
await old.promise;
|
|
478
|
+
});
|
|
479
|
+
expect(window.location.search).toBe('?image=true');
|
|
480
|
+
expect(downloads).toEqual([]);
|
|
481
|
+
await act(async () => {
|
|
482
|
+
next.resolve(canvas());
|
|
483
|
+
await next.promise;
|
|
484
|
+
});
|
|
485
|
+
await waitFor(() => expect(window.location.search).toBe(''));
|
|
486
|
+
expect(downloads).toEqual(['invoice-synthetic_b.png']);
|
|
487
|
+
expect(notices.success).toHaveBeenCalledOnce();
|
|
488
|
+
});
|
|
489
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { toast } from '@tuturuuu/ui/sonner';
|
|
4
|
+
import { useTranslations } from 'next-intl';
|
|
5
|
+
import {
|
|
6
|
+
type RefObject,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { useWorkspaceActor } from '../../../../../hooks/use-workspace-visibility';
|
|
13
|
+
|
|
14
|
+
type Scope = {
|
|
15
|
+
active: boolean;
|
|
16
|
+
signature: string;
|
|
17
|
+
actor: ReturnType<typeof useWorkspaceActor>;
|
|
18
|
+
owner: Attempt | null;
|
|
19
|
+
};
|
|
20
|
+
type Attempt = { scope: Scope; element: HTMLDivElement; invoiceId: string };
|
|
21
|
+
|
|
22
|
+
function semanticSnapshot(value: unknown) {
|
|
23
|
+
return JSON.stringify(value, (_, item) =>
|
|
24
|
+
item && typeof item === 'object' && !Array.isArray(item)
|
|
25
|
+
? Object.fromEntries(
|
|
26
|
+
Object.keys(item)
|
|
27
|
+
.sort()
|
|
28
|
+
.map((key) => [key, item[key]])
|
|
29
|
+
)
|
|
30
|
+
: item
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useInvoicePngExport({
|
|
35
|
+
wsId,
|
|
36
|
+
invoice,
|
|
37
|
+
renderInputs,
|
|
38
|
+
isDarkPreview,
|
|
39
|
+
ready,
|
|
40
|
+
printableRef,
|
|
41
|
+
}: {
|
|
42
|
+
wsId: string;
|
|
43
|
+
invoice: { id: string; ws_id?: string };
|
|
44
|
+
renderInputs: unknown;
|
|
45
|
+
isDarkPreview: boolean;
|
|
46
|
+
ready: boolean;
|
|
47
|
+
printableRef: RefObject<HTMLDivElement | null>;
|
|
48
|
+
}) {
|
|
49
|
+
const t = useTranslations();
|
|
50
|
+
const actor = useWorkspaceActor();
|
|
51
|
+
const signature = semanticSnapshot([wsId, renderInputs]);
|
|
52
|
+
const currentScope = useRef<Scope | null>(null);
|
|
53
|
+
if (
|
|
54
|
+
currentScope.current?.signature !== signature ||
|
|
55
|
+
currentScope.current?.actor !== actor
|
|
56
|
+
) {
|
|
57
|
+
if (currentScope.current) currentScope.current.active = false;
|
|
58
|
+
currentScope.current = { active: true, signature, actor, owner: null };
|
|
59
|
+
}
|
|
60
|
+
const scope = currentScope.current;
|
|
61
|
+
const [pending, setPending] = useState<Attempt | null>(null);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
scope.active = true;
|
|
64
|
+
return () => {
|
|
65
|
+
scope.active = false;
|
|
66
|
+
};
|
|
67
|
+
}, [scope]);
|
|
68
|
+
|
|
69
|
+
const handlePngExport = useCallback(async () => {
|
|
70
|
+
const activeScope = () => {
|
|
71
|
+
if (!scope.active || currentScope.current !== scope) return false;
|
|
72
|
+
try {
|
|
73
|
+
actor?.assertActive();
|
|
74
|
+
return !!actor;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
// A retained callback may never report a failure into a replacement scope.
|
|
80
|
+
if (!scope.active || currentScope.current !== scope) return false;
|
|
81
|
+
if (
|
|
82
|
+
!actor?.actorId ||
|
|
83
|
+
!wsId ||
|
|
84
|
+
invoice.ws_id !== wsId ||
|
|
85
|
+
!ready ||
|
|
86
|
+
!printableRef.current
|
|
87
|
+
) {
|
|
88
|
+
toast.error(t('common.export-error'));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
if (!activeScope() || scope.owner) return false;
|
|
92
|
+
const attempt: Attempt = {
|
|
93
|
+
scope,
|
|
94
|
+
element: printableRef.current,
|
|
95
|
+
invoiceId: invoice.id,
|
|
96
|
+
};
|
|
97
|
+
scope.owner = attempt;
|
|
98
|
+
setPending(attempt);
|
|
99
|
+
const current = () =>
|
|
100
|
+
activeScope() &&
|
|
101
|
+
scope.owner === attempt &&
|
|
102
|
+
printableRef.current === attempt.element &&
|
|
103
|
+
attempt.element.isConnected;
|
|
104
|
+
try {
|
|
105
|
+
const html2canvas = (await import('html2canvas-pro')).default;
|
|
106
|
+
if (!current()) return false;
|
|
107
|
+
const canvas = await html2canvas(attempt.element, {
|
|
108
|
+
scale: 2,
|
|
109
|
+
useCORS: true,
|
|
110
|
+
backgroundColor: isDarkPreview ? '#1a1a1a' : '#ffffff',
|
|
111
|
+
width: attempt.element.scrollWidth,
|
|
112
|
+
height: attempt.element.scrollHeight,
|
|
113
|
+
});
|
|
114
|
+
if (!current()) return false;
|
|
115
|
+
await new Promise<void>((resolve, reject) => {
|
|
116
|
+
canvas.toBlob(
|
|
117
|
+
(blob) => {
|
|
118
|
+
if (!current()) {
|
|
119
|
+
resolve();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!blob) {
|
|
123
|
+
reject(new Error('Failed to create image'));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const url = URL.createObjectURL(blob);
|
|
128
|
+
const link = document.createElement('a');
|
|
129
|
+
try {
|
|
130
|
+
link.href = url;
|
|
131
|
+
const sanitizedId = attempt.invoiceId
|
|
132
|
+
.normalize('NFD')
|
|
133
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
134
|
+
.replace(/đ/g, 'd')
|
|
135
|
+
.replace(/Đ/g, 'D')
|
|
136
|
+
.replace(/[^a-z0-9]/gi, '_')
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/_+/g, '_')
|
|
139
|
+
.replace(/^_|_$/g, '');
|
|
140
|
+
link.download = `invoice-${sanitizedId}.png`;
|
|
141
|
+
document.body.appendChild(link);
|
|
142
|
+
link.click();
|
|
143
|
+
} finally {
|
|
144
|
+
link.remove();
|
|
145
|
+
URL.revokeObjectURL(url);
|
|
146
|
+
}
|
|
147
|
+
resolve();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
reject(error);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
'image/png',
|
|
153
|
+
1.0
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
if (!current()) return false;
|
|
157
|
+
toast.success(t('common.export-success'));
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
if (!current()) return false;
|
|
161
|
+
toast.error(t('common.export-error'));
|
|
162
|
+
return true;
|
|
163
|
+
} finally {
|
|
164
|
+
if (current()) {
|
|
165
|
+
scope.owner = null;
|
|
166
|
+
setPending((value) => (value === attempt ? null : value));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, [
|
|
170
|
+
actor,
|
|
171
|
+
scope,
|
|
172
|
+
wsId,
|
|
173
|
+
invoice.id,
|
|
174
|
+
invoice.ws_id,
|
|
175
|
+
ready,
|
|
176
|
+
printableRef,
|
|
177
|
+
isDarkPreview,
|
|
178
|
+
t,
|
|
179
|
+
]);
|
|
180
|
+
return {
|
|
181
|
+
handlePngExport,
|
|
182
|
+
isExporting: pending?.scope === scope && scope.owner === pending,
|
|
183
|
+
};
|
|
184
|
+
}
|