@tuturuuu/ui 0.31.0 → 0.33.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 +57 -0
- package/biome.json +1 -1
- package/package.json +53 -51
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
- package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
- package/src/components/ui/chat/ai-message-markdown.tsx +17 -2
- package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
- package/src/components/ui/chat/ai-message-parts.tsx +23 -16
- package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
- package/src/components/ui/commercial-comparison-style.ts +104 -0
- package/src/components/ui/commercial-comparison-table.tsx +159 -0
- package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
- package/src/components/ui/commercial-comparison.test.tsx +86 -0
- package/src/components/ui/commercial-comparison.tsx +224 -0
- package/src/components/ui/comparison-value.test.tsx +32 -0
- package/src/components/ui/comparison-value.tsx +72 -0
- package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
- package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
- package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
- package/src/components/ui/custom/report-preview-pagination.ts +5 -3
- package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
- package/src/components/ui/custom/report-preview.tsx +2 -3
- package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
- package/src/components/ui/finance/invoices/columns.tsx +2 -0
- package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
- package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
- package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
- package/src/components/ui/finance/invoices/hooks.ts +5 -110
- package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
- package/src/components/ui/finance/invoices/internal-api.ts +19 -1
- package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
- package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
- package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
- package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
- package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
- package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
- package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
- package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
- package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
- package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
- package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
- package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
- package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
- package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
- package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
- package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
- package/src/components/ui/public-workspace-prices.tsx +13 -0
- package/src/components/ui/pwa-status.test.tsx +13 -2
- package/src/components/ui/pwa-status.tsx +15 -3
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
- package/src/components/ui/text-editor/editor.tsx +11 -3
- package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
- package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
- package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
- package/src/hooks/calendar-provider-sync.ts +33 -0
- package/src/hooks/notification-subscription.ts +74 -0
- package/src/hooks/use-calendar-sync.tsx +11 -14
- package/src/hooks/use-calendar.tsx +2 -2
- package/src/hooks/use-notifications.ts +5 -141
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { InvoiceDataState } from './invoice-data-state';
|
|
4
|
+
|
|
5
|
+
vi.mock('next-intl', () => ({
|
|
6
|
+
useTranslations: () => (key: string) => key,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('InvoiceDataState', () => {
|
|
10
|
+
it('announces loading without offering duplicate requests', () => {
|
|
11
|
+
render(<InvoiceDataState loading onRetry={vi.fn()} />);
|
|
12
|
+
expect(screen.getByRole('status')).toHaveTextContent('ws-invoices.loading');
|
|
13
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('replaces loading with an actionable retry after failure', () => {
|
|
17
|
+
const onRetry = vi.fn();
|
|
18
|
+
const { rerender } = render(<InvoiceDataState loading onRetry={onRetry} />);
|
|
19
|
+
rerender(<InvoiceDataState loading={false} onRetry={onRetry} />);
|
|
20
|
+
expect(screen.queryByRole('status')).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole('alert')).toHaveTextContent(
|
|
22
|
+
'ws-invoices.load_failed'
|
|
23
|
+
);
|
|
24
|
+
fireEvent.click(screen.getByRole('button', { name: 'common.retry' }));
|
|
25
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2 } from '@tuturuuu/icons';
|
|
4
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
|
|
7
|
+
export function InvoiceDataState({
|
|
8
|
+
loading,
|
|
9
|
+
onRetry,
|
|
10
|
+
}: {
|
|
11
|
+
loading: boolean;
|
|
12
|
+
onRetry: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
const t = useTranslations();
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
|
18
|
+
<div
|
|
19
|
+
role={loading ? 'status' : 'alert'}
|
|
20
|
+
className="flex items-center gap-2"
|
|
21
|
+
>
|
|
22
|
+
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
23
|
+
<p className="text-muted-foreground text-sm">
|
|
24
|
+
{t(loading ? 'ws-invoices.loading' : 'ws-invoices.load_failed')}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
{!loading && (
|
|
28
|
+
<Button type="button" variant="outline" onClick={onRetry}>
|
|
29
|
+
{t('common.retry')}
|
|
30
|
+
</Button>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { PropsWithChildren } from 'react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { useInvoiceCustomerSearch } from './use-invoice-customer-search';
|
|
6
|
+
|
|
7
|
+
const api = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn() }));
|
|
8
|
+
vi.mock('../internal-api', () => ({
|
|
9
|
+
listWorkspaceUsersWithInternalApi: api.list,
|
|
10
|
+
getWorkspaceUserWithInternalApi: api.get,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
api.list.mockReset();
|
|
15
|
+
api.get.mockReset();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function renderSearch(selectedUserId = '') {
|
|
19
|
+
const client = new QueryClient();
|
|
20
|
+
function Wrapper({ children }: PropsWithChildren) {
|
|
21
|
+
return (
|
|
22
|
+
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return renderHook(
|
|
26
|
+
() => useInvoiceCustomerSearch('ws-1', '', selectedUserId),
|
|
27
|
+
{
|
|
28
|
+
wrapper: Wrapper,
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('invoice customer loading recovery', () => {
|
|
34
|
+
it('surfaces a failed read immediately and allows a manual retry', async () => {
|
|
35
|
+
api.list.mockRejectedValueOnce(new Error('Timed out')).mockResolvedValue({
|
|
36
|
+
data: [{ id: 'customer-1' }],
|
|
37
|
+
count: 1,
|
|
38
|
+
});
|
|
39
|
+
const { result } = renderSearch();
|
|
40
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
41
|
+
expect(result.current.isLoading).toBe(false);
|
|
42
|
+
expect(api.list).toHaveBeenCalledOnce();
|
|
43
|
+
|
|
44
|
+
await act(async () => {
|
|
45
|
+
await result.current.refetch();
|
|
46
|
+
});
|
|
47
|
+
await waitFor(() => expect(result.current.error).toBeNull());
|
|
48
|
+
expect(result.current.customers).toEqual([{ id: 'customer-1' }]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('retries the selected customer as well as the customer list', async () => {
|
|
52
|
+
api.list.mockResolvedValue({ data: [], count: 0 });
|
|
53
|
+
api.get
|
|
54
|
+
.mockRejectedValueOnce(new Error('Timed out'))
|
|
55
|
+
.mockResolvedValue({ id: 'selected' });
|
|
56
|
+
const { result } = renderSearch('selected');
|
|
57
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
58
|
+
await act(async () => {
|
|
59
|
+
await result.current.refetch();
|
|
60
|
+
});
|
|
61
|
+
await waitFor(() =>
|
|
62
|
+
expect(result.current.selectedUser?.id).toBe('selected')
|
|
63
|
+
);
|
|
64
|
+
expect(result.current.error).toBeNull();
|
|
65
|
+
expect(api.get).toHaveBeenCalledTimes(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('ignores an old detail error once the list contains the selected customer', async () => {
|
|
69
|
+
api.list.mockResolvedValueOnce({ data: [], count: 0 }).mockResolvedValue({
|
|
70
|
+
data: [{ id: 'selected' }],
|
|
71
|
+
count: 1,
|
|
72
|
+
});
|
|
73
|
+
api.get.mockRejectedValue(new Error('Detail unavailable'));
|
|
74
|
+
const { result } = renderSearch('selected');
|
|
75
|
+
await waitFor(() => expect(result.current.error).toBeTruthy());
|
|
76
|
+
await act(async () => {
|
|
77
|
+
await result.current.refetch();
|
|
78
|
+
});
|
|
79
|
+
await waitFor(() => expect(result.current.error).toBeNull());
|
|
80
|
+
expect(result.current.selectedUser?.id).toBe('selected');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('stops offering more customers when an empty page has a stale count', async () => {
|
|
84
|
+
api.list.mockResolvedValue({ data: [], count: 10 });
|
|
85
|
+
const { result } = renderSearch();
|
|
86
|
+
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
|
87
|
+
expect(result.current.hasNextPage).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
|
2
|
+
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
3
|
+
import {
|
|
4
|
+
getWorkspaceUserWithInternalApi,
|
|
5
|
+
listWorkspaceUsersWithInternalApi,
|
|
6
|
+
} from '../internal-api';
|
|
7
|
+
|
|
8
|
+
const INVOICE_CUSTOMER_PAGE_SIZE = 25;
|
|
9
|
+
|
|
10
|
+
type WorkspaceUsersPage = {
|
|
11
|
+
data: WorkspaceUser[];
|
|
12
|
+
count: number;
|
|
13
|
+
offset: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function fetchInvoiceCustomerPage(
|
|
17
|
+
wsId: string,
|
|
18
|
+
searchQuery: string,
|
|
19
|
+
offset: number
|
|
20
|
+
): Promise<WorkspaceUsersPage> {
|
|
21
|
+
const payload = await listWorkspaceUsersWithInternalApi(wsId, {
|
|
22
|
+
from: offset,
|
|
23
|
+
limit: INVOICE_CUSTOMER_PAGE_SIZE,
|
|
24
|
+
q: searchQuery.trim() || undefined,
|
|
25
|
+
to: offset + INVOICE_CUSTOMER_PAGE_SIZE - 1,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
data: payload.data,
|
|
30
|
+
count: payload.count,
|
|
31
|
+
offset,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchWorkspaceUserById(
|
|
36
|
+
wsId: string,
|
|
37
|
+
userId: string
|
|
38
|
+
): Promise<WorkspaceUser | null> {
|
|
39
|
+
return getWorkspaceUserWithInternalApi(wsId, userId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useInvoiceCustomerSearch(
|
|
43
|
+
wsId: string,
|
|
44
|
+
searchQuery: string,
|
|
45
|
+
selectedUserId: string
|
|
46
|
+
) {
|
|
47
|
+
const normalizedSearchQuery = searchQuery.trim();
|
|
48
|
+
|
|
49
|
+
const usersQuery = useInfiniteQuery({
|
|
50
|
+
queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
|
|
51
|
+
queryFn: async ({ pageParam = 0 }) =>
|
|
52
|
+
fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
|
|
53
|
+
initialPageParam: 0,
|
|
54
|
+
placeholderData: (previousData) => previousData,
|
|
55
|
+
getNextPageParam: (lastPage, allPages) => {
|
|
56
|
+
const loadedCount = allPages.reduce(
|
|
57
|
+
(total, page) => total + page.data.length,
|
|
58
|
+
0
|
|
59
|
+
);
|
|
60
|
+
return lastPage.data.length > 0 && loadedCount < lastPage.count
|
|
61
|
+
? loadedCount
|
|
62
|
+
: undefined;
|
|
63
|
+
},
|
|
64
|
+
enabled: !!wsId,
|
|
65
|
+
retry: false,
|
|
66
|
+
staleTime: 30 * 1000,
|
|
67
|
+
gcTime: 5 * 60 * 1000,
|
|
68
|
+
refetchOnWindowFocus: false,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const loadedCustomers =
|
|
72
|
+
usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
|
|
73
|
+
const needsSelectedUser =
|
|
74
|
+
!!wsId &&
|
|
75
|
+
!!selectedUserId &&
|
|
76
|
+
!loadedCustomers.some((user) => user.id === selectedUserId);
|
|
77
|
+
const selectedUserQuery = useQuery({
|
|
78
|
+
queryKey: ['invoice-customer', wsId, selectedUserId],
|
|
79
|
+
queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
|
|
80
|
+
enabled: needsSelectedUser,
|
|
81
|
+
retry: false,
|
|
82
|
+
staleTime: 30 * 1000,
|
|
83
|
+
gcTime: 5 * 60 * 1000,
|
|
84
|
+
refetchOnWindowFocus: false,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const customersById = new Map<string, WorkspaceUser>();
|
|
88
|
+
|
|
89
|
+
if (selectedUserQuery.data) {
|
|
90
|
+
customersById.set(selectedUserQuery.data.id, selectedUserQuery.data);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (const customer of loadedCustomers) {
|
|
94
|
+
customersById.set(customer.id, customer);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const customers = Array.from(customersById.values());
|
|
98
|
+
const selectedUser =
|
|
99
|
+
(selectedUserId
|
|
100
|
+
? customers.find((user) => user.id === selectedUserId)
|
|
101
|
+
: undefined) ?? undefined;
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...usersQuery,
|
|
105
|
+
customers,
|
|
106
|
+
selectedUser,
|
|
107
|
+
error:
|
|
108
|
+
usersQuery.error ?? (needsSelectedUser ? selectedUserQuery.error : null),
|
|
109
|
+
isLoading: usersQuery.isLoading || selectedUserQuery.isLoading,
|
|
110
|
+
isFetchingSelectedUser: selectedUserQuery.isFetching,
|
|
111
|
+
refetch: () =>
|
|
112
|
+
Promise.all([
|
|
113
|
+
usersQuery.refetch(),
|
|
114
|
+
...(needsSelectedUser ? [selectedUserQuery.refetch()] : []),
|
|
115
|
+
]),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -20,12 +20,10 @@ import {
|
|
|
20
20
|
import type { Invoice } from '@tuturuuu/types/primitives/Invoice';
|
|
21
21
|
import type { PendingInvoice } from '@tuturuuu/types/primitives/PendingInvoice';
|
|
22
22
|
import { parseMonthsOwed } from '@tuturuuu/types/primitives/PendingInvoice';
|
|
23
|
-
import type { WorkspaceUser } from '@tuturuuu/types/primitives/WorkspaceUser';
|
|
24
23
|
import { z } from 'zod';
|
|
25
24
|
import type { WorkspaceUserLinkedPromotion } from './internal-api';
|
|
26
25
|
import {
|
|
27
26
|
createPromotionWithInternalApi,
|
|
28
|
-
getWorkspaceUserWithInternalApi,
|
|
29
27
|
listInvoiceProductsWithInternalApi,
|
|
30
28
|
listMultiGroupProductsWithInternalApi,
|
|
31
29
|
listPromotionsWithInternalApi,
|
|
@@ -37,7 +35,7 @@ import {
|
|
|
37
35
|
} from './internal-api';
|
|
38
36
|
import type { UserGroup } from './utils';
|
|
39
37
|
|
|
40
|
-
|
|
38
|
+
export { useInvoiceCustomerSearch } from './hooks/use-invoice-customer-search';
|
|
41
39
|
|
|
42
40
|
const invoiceSchema = z.object({
|
|
43
41
|
id: z.string(),
|
|
@@ -184,8 +182,6 @@ function isInvoiceQueryEnabled(options?: InvoiceQueryOptions) {
|
|
|
184
182
|
return options?.enabled !== false;
|
|
185
183
|
}
|
|
186
184
|
|
|
187
|
-
// ==================== INVOICES DATA FETCHING ====================
|
|
188
|
-
|
|
189
185
|
export interface InvoicesParams {
|
|
190
186
|
q?: string;
|
|
191
187
|
page?: number;
|
|
@@ -258,8 +254,6 @@ export function useWorkspaceInvoices(
|
|
|
258
254
|
});
|
|
259
255
|
}
|
|
260
256
|
|
|
261
|
-
// ==================== LEGACY HOOKS ====================
|
|
262
|
-
|
|
263
257
|
// React Query hooks for data fetching
|
|
264
258
|
export const useUsers = (wsId: string) => {
|
|
265
259
|
return useQuery({
|
|
@@ -273,106 +267,6 @@ export const useUsers = (wsId: string) => {
|
|
|
273
267
|
});
|
|
274
268
|
};
|
|
275
269
|
|
|
276
|
-
const INVOICE_CUSTOMER_PAGE_SIZE = 25;
|
|
277
|
-
|
|
278
|
-
type WorkspaceUsersPage = {
|
|
279
|
-
data: WorkspaceUser[];
|
|
280
|
-
count: number;
|
|
281
|
-
offset: number;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
async function fetchInvoiceCustomerPage(
|
|
285
|
-
wsId: string,
|
|
286
|
-
searchQuery: string,
|
|
287
|
-
offset: number
|
|
288
|
-
): Promise<WorkspaceUsersPage> {
|
|
289
|
-
const payload = await listWorkspaceUsersWithInternalApi(wsId, {
|
|
290
|
-
from: offset,
|
|
291
|
-
limit: INVOICE_CUSTOMER_PAGE_SIZE,
|
|
292
|
-
q: searchQuery.trim() || undefined,
|
|
293
|
-
to: offset + INVOICE_CUSTOMER_PAGE_SIZE - 1,
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
return {
|
|
297
|
-
data: payload.data,
|
|
298
|
-
count: payload.count,
|
|
299
|
-
offset,
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
async function fetchWorkspaceUserById(
|
|
304
|
-
wsId: string,
|
|
305
|
-
userId: string
|
|
306
|
-
): Promise<WorkspaceUser | null> {
|
|
307
|
-
return getWorkspaceUserWithInternalApi(wsId, userId);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export function useInvoiceCustomerSearch(
|
|
311
|
-
wsId: string,
|
|
312
|
-
searchQuery: string,
|
|
313
|
-
selectedUserId: string
|
|
314
|
-
) {
|
|
315
|
-
const normalizedSearchQuery = searchQuery.trim();
|
|
316
|
-
|
|
317
|
-
const usersQuery = useInfiniteQuery({
|
|
318
|
-
queryKey: ['invoice-customer-search', wsId, normalizedSearchQuery],
|
|
319
|
-
queryFn: async ({ pageParam = 0 }) =>
|
|
320
|
-
fetchInvoiceCustomerPage(wsId, normalizedSearchQuery, pageParam),
|
|
321
|
-
initialPageParam: 0,
|
|
322
|
-
placeholderData: (previousData) => previousData,
|
|
323
|
-
getNextPageParam: (lastPage, allPages) => {
|
|
324
|
-
const loadedCount = allPages.reduce(
|
|
325
|
-
(total, page) => total + page.data.length,
|
|
326
|
-
0
|
|
327
|
-
);
|
|
328
|
-
return loadedCount < lastPage.count ? loadedCount : undefined;
|
|
329
|
-
},
|
|
330
|
-
enabled: !!wsId,
|
|
331
|
-
staleTime: 30 * 1000,
|
|
332
|
-
gcTime: 5 * 60 * 1000,
|
|
333
|
-
refetchOnWindowFocus: false,
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
const loadedCustomers =
|
|
337
|
-
usersQuery.data?.pages.flatMap((page) => page.data) ?? [];
|
|
338
|
-
const selectedUserQuery = useQuery({
|
|
339
|
-
queryKey: ['invoice-customer', wsId, selectedUserId],
|
|
340
|
-
queryFn: async () => fetchWorkspaceUserById(wsId, selectedUserId),
|
|
341
|
-
enabled:
|
|
342
|
-
!!wsId &&
|
|
343
|
-
!!selectedUserId &&
|
|
344
|
-
!loadedCustomers.some((user) => user.id === selectedUserId),
|
|
345
|
-
staleTime: 30 * 1000,
|
|
346
|
-
gcTime: 5 * 60 * 1000,
|
|
347
|
-
refetchOnWindowFocus: false,
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
const customersById = new Map<string, WorkspaceUser>();
|
|
351
|
-
|
|
352
|
-
if (selectedUserQuery.data) {
|
|
353
|
-
customersById.set(selectedUserQuery.data.id, selectedUserQuery.data);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
for (const customer of loadedCustomers) {
|
|
357
|
-
customersById.set(customer.id, customer);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const customers = Array.from(customersById.values());
|
|
361
|
-
const selectedUser =
|
|
362
|
-
(selectedUserId
|
|
363
|
-
? customers.find((user) => user.id === selectedUserId)
|
|
364
|
-
: undefined) ?? undefined;
|
|
365
|
-
|
|
366
|
-
return {
|
|
367
|
-
...usersQuery,
|
|
368
|
-
customers,
|
|
369
|
-
selectedUser,
|
|
370
|
-
error: usersQuery.error ?? selectedUserQuery.error,
|
|
371
|
-
isLoading: usersQuery.isLoading || selectedUserQuery.isLoading,
|
|
372
|
-
isFetchingSelectedUser: selectedUserQuery.isFetching,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
|
-
|
|
376
270
|
// Users with selectable groups (groups where they have STUDENT role)
|
|
377
271
|
export const useUsersWithSelectableGroups = (wsId: string) => {
|
|
378
272
|
return useQuery({
|
|
@@ -391,6 +285,7 @@ export const useProducts = (wsId: string, options?: InvoiceQueryOptions) => {
|
|
|
391
285
|
queryKey: ['products', wsId],
|
|
392
286
|
queryFn: () => listInvoiceProductsWithInternalApi(wsId),
|
|
393
287
|
enabled: !!wsId && isInvoiceQueryEnabled(options),
|
|
288
|
+
retry: false,
|
|
394
289
|
staleTime: INVOICE_STATIC_QUERY_STALE_TIME,
|
|
395
290
|
gcTime: INVOICE_STATIC_QUERY_GC_TIME,
|
|
396
291
|
refetchOnWindowFocus: false,
|
|
@@ -493,7 +388,7 @@ export const useUserGroups = (wsId: string, userId: string) => {
|
|
|
493
388
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
494
389
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
495
390
|
refetchOnWindowFocus: false,
|
|
496
|
-
retry:
|
|
391
|
+
retry: false,
|
|
497
392
|
});
|
|
498
393
|
};
|
|
499
394
|
|
|
@@ -540,7 +435,7 @@ export const useSubscriptionInvoiceContext = (
|
|
|
540
435
|
staleTime: 5 * 60 * 1000,
|
|
541
436
|
gcTime: 10 * 60 * 1000,
|
|
542
437
|
refetchOnWindowFocus: false,
|
|
543
|
-
retry:
|
|
438
|
+
retry: false,
|
|
544
439
|
});
|
|
545
440
|
};
|
|
546
441
|
|
|
@@ -677,7 +572,7 @@ export const useMultiGroupProducts = (
|
|
|
677
572
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
678
573
|
gcTime: 10 * 60 * 1000, // 10 minutes
|
|
679
574
|
refetchOnWindowFocus: false,
|
|
680
|
-
retry:
|
|
575
|
+
retry: false,
|
|
681
576
|
});
|
|
682
577
|
};
|
|
683
578
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getWorkspaceUserWithInternalApi,
|
|
4
|
+
listInvoiceProductsWithInternalApi,
|
|
5
|
+
listMultiGroupProductsWithInternalApi,
|
|
6
|
+
listUserGroupsWithInternalApi,
|
|
7
|
+
listWorkspaceUsersWithInternalApi,
|
|
8
|
+
} from './internal-api';
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ json: vi.fn() }));
|
|
11
|
+
vi.mock('@tuturuuu/internal-api/client', () => ({
|
|
12
|
+
encodePathSegment: encodeURIComponent,
|
|
13
|
+
getInternalApiClient: () => ({ json: mocks.json }),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mocks.json.mockReset();
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => vi.restoreAllMocks());
|
|
20
|
+
|
|
21
|
+
describe('invoice product loading', () => {
|
|
22
|
+
it('loads every product page and shares one deadline across pages', async () => {
|
|
23
|
+
const firstPage = Array.from({ length: 500 }, (_, id) => ({
|
|
24
|
+
id: String(id),
|
|
25
|
+
}));
|
|
26
|
+
mocks.json
|
|
27
|
+
.mockResolvedValueOnce({ data: firstPage, count: 501 })
|
|
28
|
+
.mockResolvedValueOnce({ data: [{ id: 'last' }], count: 501 });
|
|
29
|
+
|
|
30
|
+
const result = await listInvoiceProductsWithInternalApi('ws-1');
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual([...firstPage, { id: 'last' }]);
|
|
33
|
+
expect(mocks.json).toHaveBeenCalledTimes(2);
|
|
34
|
+
expect(mocks.json.mock.calls.map((call) => call[1].query.page)).toEqual([
|
|
35
|
+
1, 2,
|
|
36
|
+
]);
|
|
37
|
+
expect(mocks.json.mock.calls[0]?.[1].signal).toBe(
|
|
38
|
+
mocks.json.mock.calls[1]?.[1].signal
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts an empty catalog', async () => {
|
|
43
|
+
mocks.json.mockResolvedValue({ data: [], count: 0 });
|
|
44
|
+
await expect(listInvoiceProductsWithInternalApi('ws-1')).resolves.toEqual(
|
|
45
|
+
[]
|
|
46
|
+
);
|
|
47
|
+
expect(mocks.json).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('fails instead of looping forever when an empty page retains a total', async () => {
|
|
51
|
+
mocks.json
|
|
52
|
+
.mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
|
|
53
|
+
.mockResolvedValueOnce({ data: [], count: 2 })
|
|
54
|
+
.mockRejectedValue(new Error('Unexpected extra page'));
|
|
55
|
+
|
|
56
|
+
await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
|
|
57
|
+
'stopped making progress'
|
|
58
|
+
);
|
|
59
|
+
expect(mocks.json).toHaveBeenCalledTimes(2);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('fails when the server repeats a page without adding products', async () => {
|
|
63
|
+
mocks.json.mockResolvedValue({ data: [{ id: 'same' }], count: 3 });
|
|
64
|
+
await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
|
|
65
|
+
'stopped making progress'
|
|
66
|
+
);
|
|
67
|
+
expect(mocks.json).toHaveBeenCalledTimes(2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not return a partial catalog when a later page fails', async () => {
|
|
71
|
+
mocks.json
|
|
72
|
+
.mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
|
|
73
|
+
.mockRejectedValueOnce(new Error('Unavailable'));
|
|
74
|
+
await expect(listInvoiceProductsWithInternalApi('ws-1')).rejects.toThrow(
|
|
75
|
+
'Unavailable'
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('stops when the catalog shrinks between pages', async () => {
|
|
80
|
+
mocks.json
|
|
81
|
+
.mockResolvedValueOnce({ data: [{ id: 'first' }], count: 2 })
|
|
82
|
+
.mockResolvedValueOnce({ data: [], count: 1 });
|
|
83
|
+
await expect(listInvoiceProductsWithInternalApi('ws-1')).resolves.toEqual([
|
|
84
|
+
{ id: 'first' },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('invoice startup request deadlines', () => {
|
|
90
|
+
it.each([
|
|
91
|
+
['products', () => listInvoiceProductsWithInternalApi('ws-1')],
|
|
92
|
+
['customers', () => listWorkspaceUsersWithInternalApi('ws-1')],
|
|
93
|
+
[
|
|
94
|
+
'selected customer',
|
|
95
|
+
() => getWorkspaceUserWithInternalApi('ws-1', 'user-1'),
|
|
96
|
+
],
|
|
97
|
+
['customer groups', () => listUserGroupsWithInternalApi('ws-1', 'user-1')],
|
|
98
|
+
[
|
|
99
|
+
'group products',
|
|
100
|
+
() => listMultiGroupProductsWithInternalApi('ws-1', ['group-1']),
|
|
101
|
+
],
|
|
102
|
+
])('aborts a stalled %s read', async (_name, load) => {
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
const timeout = vi
|
|
105
|
+
.spyOn(AbortSignal, 'timeout')
|
|
106
|
+
.mockReturnValue(controller.signal);
|
|
107
|
+
mocks.json.mockImplementation(
|
|
108
|
+
(_path, options) =>
|
|
109
|
+
new Promise((_resolve, reject) => {
|
|
110
|
+
options.signal.addEventListener('abort', () =>
|
|
111
|
+
reject(options.signal.reason)
|
|
112
|
+
);
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const pending = load();
|
|
117
|
+
const assertion = expect(pending).rejects.toThrow('Timed out');
|
|
118
|
+
controller.abort(new Error('Timed out'));
|
|
119
|
+
await assertion;
|
|
120
|
+
expect(timeout).toHaveBeenCalledWith(15_000);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -203,6 +203,7 @@ export async function listWorkspaceUsersWithInternalApi(
|
|
|
203
203
|
WorkspaceUser[] | { data?: WorkspaceUser[]; count?: number }
|
|
204
204
|
>(`/api/v1/workspaces/${encodePathSegment(workspaceId)}/users`, {
|
|
205
205
|
cache: 'no-store',
|
|
206
|
+
signal: AbortSignal.timeout(15_000),
|
|
206
207
|
query: {
|
|
207
208
|
from: query.from,
|
|
208
209
|
limit: query.limit,
|
|
@@ -233,6 +234,7 @@ export async function getWorkspaceUserWithInternalApi(
|
|
|
233
234
|
`/api/v1/workspaces/${encodePathSegment(workspaceId)}/users/${encodePathSegment(userId)}`,
|
|
234
235
|
{
|
|
235
236
|
cache: 'no-store',
|
|
237
|
+
signal: AbortSignal.timeout(15_000),
|
|
236
238
|
}
|
|
237
239
|
);
|
|
238
240
|
|
|
@@ -246,6 +248,8 @@ export async function getWorkspaceUserWithInternalApi(
|
|
|
246
248
|
export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
|
|
247
249
|
const client = getInternalApiClient();
|
|
248
250
|
const products: Product[] = [];
|
|
251
|
+
const productIds = new Set<string>();
|
|
252
|
+
const signal = AbortSignal.timeout(15_000);
|
|
249
253
|
const pageSize = 500;
|
|
250
254
|
let page = 1;
|
|
251
255
|
let count = 0;
|
|
@@ -255,6 +259,7 @@ export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
|
|
|
255
259
|
`/api/v1/workspaces/${encodePathSegment(workspaceId)}/inventory/products`,
|
|
256
260
|
{
|
|
257
261
|
cache: 'no-store',
|
|
262
|
+
signal,
|
|
258
263
|
query: {
|
|
259
264
|
page,
|
|
260
265
|
pageSize,
|
|
@@ -262,7 +267,18 @@ export async function listInvoiceProductsWithInternalApi(workspaceId: string) {
|
|
|
262
267
|
}
|
|
263
268
|
);
|
|
264
269
|
|
|
265
|
-
|
|
270
|
+
const pageProducts = payload.data ?? [];
|
|
271
|
+
const newProducts = pageProducts.filter(
|
|
272
|
+
(product) => !productIds.has(product.id)
|
|
273
|
+
);
|
|
274
|
+
if (pageProducts.length === 0 || newProducts.length === 0) {
|
|
275
|
+
if (products.length < (payload.count ?? 0)) {
|
|
276
|
+
throw new Error('Invoice product pagination stopped making progress');
|
|
277
|
+
}
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
for (const product of newProducts) productIds.add(product.id);
|
|
281
|
+
products.push(...newProducts);
|
|
266
282
|
count = payload.count ?? products.length;
|
|
267
283
|
page += 1;
|
|
268
284
|
} while (products.length < count);
|
|
@@ -326,6 +342,7 @@ export function listUserGroupsWithInternalApi(
|
|
|
326
342
|
`/api/v1/workspaces/${encodePathSegment(workspaceId)}/users/${encodePathSegment(userId)}/user-groups`,
|
|
327
343
|
{
|
|
328
344
|
cache: 'no-store',
|
|
345
|
+
signal: AbortSignal.timeout(15_000),
|
|
329
346
|
}
|
|
330
347
|
);
|
|
331
348
|
}
|
|
@@ -384,6 +401,7 @@ export function listMultiGroupProductsWithInternalApi(
|
|
|
384
401
|
`/api/v1/workspaces/${encodePathSegment(workspaceId)}/user-groups/linked-products?${searchParams.toString()}`,
|
|
385
402
|
{
|
|
386
403
|
cache: 'no-store',
|
|
404
|
+
signal: AbortSignal.timeout(15_000),
|
|
387
405
|
}
|
|
388
406
|
)
|
|
389
407
|
.then((payload) => payload.items ?? []);
|