@tuturuuu/ui 0.32.0 → 0.34.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 +62 -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-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/account-assurance-refresh.test.tsx +42 -0
- package/src/components/ui/custom/account-assurance-refresh.tsx +30 -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/charts/subscription-payment-chart.tsx +137 -0
- package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -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/components/subscription-group-selector.tsx +29 -6
- package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
- package/src/components/ui/finance/invoices/export-data.ts +229 -0
- package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
- package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
- package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
- package/src/components/ui/finance/invoices/export-pagination.ts +66 -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/use-subscription-auto-selection.test.tsx +255 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
- 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-analytics.tsx +24 -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.test.ts +28 -0
- package/src/components/ui/finance/invoices/query-invalidation.ts +3 -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-coverage.test.ts +74 -0
- package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
- 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 +47 -51
- package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
- package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
- package/src/components/ui/finance/invoices/utils.ts +48 -106
- 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/legacy/calendar/all-day-event-bar.tsx +5 -5
- package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
- package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
- package/src/components/ui/legacy/calendar/working-location.ts +35 -0
- 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
|
@@ -22,6 +22,7 @@ import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
|
|
22
22
|
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
|
23
23
|
import timezone from 'dayjs/plugin/timezone';
|
|
24
24
|
import { useCallback, useEffect, useState } from 'react';
|
|
25
|
+
import { getEventLocationType } from './working-location';
|
|
25
26
|
|
|
26
27
|
dayjs.extend(timezone);
|
|
27
28
|
dayjs.extend(isBetween);
|
|
@@ -47,19 +48,6 @@ interface EventSpan {
|
|
|
47
48
|
mergedEventIds?: string[];
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
|
|
51
|
-
|
|
52
|
-
// Helper to determine location type
|
|
53
|
-
export const getLocationType = (title: string): LocationType => {
|
|
54
|
-
const normalizedTitle = title.toLowerCase().trim();
|
|
55
|
-
if (normalizedTitle === 'home') return 'home';
|
|
56
|
-
if (normalizedTitle === 'office' || normalizedTitle === 'work')
|
|
57
|
-
return 'office';
|
|
58
|
-
if (normalizedTitle === 'school') return 'school';
|
|
59
|
-
if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
|
|
60
|
-
return null;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
51
|
// Location colors using dynamic theme tokens
|
|
64
52
|
const locationStyles = {
|
|
65
53
|
home: {
|
|
@@ -156,7 +144,7 @@ const LocationPill = ({
|
|
|
156
144
|
);
|
|
157
145
|
|
|
158
146
|
const { event, startIndex, span } = eventSpan;
|
|
159
|
-
const locationType =
|
|
147
|
+
const locationType = getEventLocationType(event);
|
|
160
148
|
const isHome = locationType === 'home';
|
|
161
149
|
const isOffice = locationType === 'office';
|
|
162
150
|
const isSchool = locationType === 'school';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { getEventLocationType } from './working-location';
|
|
4
|
+
|
|
5
|
+
const event = (overrides: Partial<CalendarEvent>): CalendarEvent => ({
|
|
6
|
+
id: 'event',
|
|
7
|
+
start_at: '2026-09-25T00:00:00Z',
|
|
8
|
+
end_at: '2026-09-26T00:00:00Z',
|
|
9
|
+
...overrides,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('Google working locations', () => {
|
|
13
|
+
it('uses provider metadata even when the title differs', () => {
|
|
14
|
+
expect(
|
|
15
|
+
getEventLocationType(
|
|
16
|
+
event({
|
|
17
|
+
title: 'Remote today',
|
|
18
|
+
scheduling_metadata: {
|
|
19
|
+
google_event_type: 'workingLocation',
|
|
20
|
+
google_working_location_type: 'homeOffice',
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
).toBe('home');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('keeps legacy Home and Office location events visible', () => {
|
|
28
|
+
expect(getEventLocationType(event({ title: 'Home' }))).toBe('home');
|
|
29
|
+
expect(getEventLocationType(event({ title: 'Office' }))).toBe('office');
|
|
30
|
+
expect(getEventLocationType(event({ title: 'School' }))).toBe('school');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('displays custom Google School locations distinctly', () => {
|
|
34
|
+
expect(
|
|
35
|
+
getEventLocationType(
|
|
36
|
+
event({
|
|
37
|
+
title: 'Campus',
|
|
38
|
+
scheduling_metadata: {
|
|
39
|
+
google_event_type: 'workingLocation',
|
|
40
|
+
google_working_location_type: 'customLocation',
|
|
41
|
+
google_working_location_label: 'School',
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
)
|
|
45
|
+
).toBe('school');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
|
|
3
|
+
export type LocationType = 'home' | 'office' | 'school' | 'custom' | null;
|
|
4
|
+
|
|
5
|
+
export function getLocationType(title: string): LocationType {
|
|
6
|
+
const normalizedTitle = title.toLowerCase().trim();
|
|
7
|
+
if (normalizedTitle === 'home') return 'home';
|
|
8
|
+
if (normalizedTitle === 'office' || normalizedTitle === 'work')
|
|
9
|
+
return 'office';
|
|
10
|
+
if (normalizedTitle === 'school') return 'school';
|
|
11
|
+
if (title.startsWith('📍') || title.startsWith('Location: ')) return 'custom';
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getEventLocationType(event: CalendarEvent): LocationType {
|
|
16
|
+
if (event.scheduling_metadata?.google_event_type === 'workingLocation') {
|
|
17
|
+
switch (event.scheduling_metadata.google_working_location_type) {
|
|
18
|
+
case 'homeOffice':
|
|
19
|
+
return 'home';
|
|
20
|
+
case 'officeLocation':
|
|
21
|
+
return 'office';
|
|
22
|
+
case 'customLocation': {
|
|
23
|
+
const label = event.scheduling_metadata.google_working_location_label;
|
|
24
|
+
return (typeof label === 'string' &&
|
|
25
|
+
label.trim().toLowerCase() === 'school') ||
|
|
26
|
+
event.title?.trim().toLowerCase() === 'school'
|
|
27
|
+
? 'school'
|
|
28
|
+
: 'custom';
|
|
29
|
+
}
|
|
30
|
+
default:
|
|
31
|
+
return 'custom';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return getLocationType(event.title ?? '');
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import { getPublicWorkspacePrices } from '@tuturuuu/internal-api';
|
|
4
|
+
|
|
5
|
+
export function usePublicWorkspacePrices(options?: { baseUrl?: string }) {
|
|
6
|
+
return useQuery({
|
|
7
|
+
queryKey: ['public-workspace-prices', options?.baseUrl ?? 'same-origin'],
|
|
8
|
+
queryFn: () => getPublicWorkspacePrices(options),
|
|
9
|
+
staleTime: 300_000,
|
|
10
|
+
refetchInterval: 300_000,
|
|
11
|
+
retry: 1,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -22,6 +22,10 @@ afterEach(() => {
|
|
|
22
22
|
vi.restoreAllMocks();
|
|
23
23
|
});
|
|
24
24
|
describe('PWA status and installation', () => {
|
|
25
|
+
it('does not show a floating install button in the app', () => {
|
|
26
|
+
render(<PwaStatus labels={labels} />);
|
|
27
|
+
expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
|
|
28
|
+
});
|
|
25
29
|
it('shows offline limitations without offering an unavailable install', () => {
|
|
26
30
|
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
|
27
31
|
render(<PwaStatus labels={labels} />);
|
|
@@ -29,10 +33,17 @@ describe('PWA status and installation', () => {
|
|
|
29
33
|
expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
|
|
30
34
|
});
|
|
31
35
|
it('provides Home Screen instructions when a browser has no install prompt', () => {
|
|
32
|
-
render(<PwaStatus labels={labels} />);
|
|
36
|
+
render(<PwaStatus placement="settings" labels={labels} />);
|
|
33
37
|
fireEvent.click(screen.getByRole('button', { name: 'install' }));
|
|
34
38
|
expect(screen.getByRole('dialog')).toHaveTextContent('install_help');
|
|
35
39
|
});
|
|
40
|
+
it('explains offline status inside the installation settings panel', () => {
|
|
41
|
+
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
|
|
42
|
+
render(<PwaStatus placement="settings" labels={labels} />);
|
|
43
|
+
expect(screen.getByRole('status')).toHaveTextContent('offline_status');
|
|
44
|
+
expect(screen.getByRole('status')).not.toHaveClass('fixed');
|
|
45
|
+
expect(screen.queryByRole('button', { name: 'install' })).toBeNull();
|
|
46
|
+
});
|
|
36
47
|
it('only invokes the native install prompt after an explicit click', async () => {
|
|
37
48
|
const prompt = vi.fn(async () => {});
|
|
38
49
|
const event = Object.assign(
|
|
@@ -42,7 +53,7 @@ describe('PWA status and installation', () => {
|
|
|
42
53
|
act(() => {
|
|
43
54
|
window.dispatchEvent(event);
|
|
44
55
|
});
|
|
45
|
-
render(<PwaStatus labels={labels} />);
|
|
56
|
+
render(<PwaStatus placement="settings" labels={labels} />);
|
|
46
57
|
expect(prompt).not.toHaveBeenCalled();
|
|
47
58
|
await act(async () => {
|
|
48
59
|
fireEvent.click(screen.getByRole('button', { name: 'install' }));
|
|
@@ -26,12 +26,15 @@ function subscribeOnline(callback: () => void) {
|
|
|
26
26
|
}
|
|
27
27
|
export function PwaStatus({
|
|
28
28
|
labels,
|
|
29
|
+
placement = 'status',
|
|
29
30
|
}: {
|
|
31
|
+
placement?: 'status' | 'settings';
|
|
30
32
|
labels: {
|
|
31
33
|
offline_status: string;
|
|
32
34
|
install: string;
|
|
33
35
|
install_help: string;
|
|
34
36
|
cache_help: string;
|
|
37
|
+
installed?: string;
|
|
35
38
|
};
|
|
36
39
|
}) {
|
|
37
40
|
const t = (key: keyof typeof labels) => labels[key];
|
|
@@ -82,23 +85,32 @@ export function PwaStatus({
|
|
|
82
85
|
{!online && (
|
|
83
86
|
<div
|
|
84
87
|
role="status"
|
|
85
|
-
className=
|
|
88
|
+
className={
|
|
89
|
+
placement === 'settings'
|
|
90
|
+
? 'flex items-start gap-2 rounded-lg border bg-muted/30 p-3 text-sm'
|
|
91
|
+
: 'fixed inset-x-3 bottom-3 z-50 mx-auto flex max-w-xl items-start gap-2 rounded-lg border bg-background p-3 text-sm shadow-lg'
|
|
92
|
+
}
|
|
86
93
|
>
|
|
87
94
|
<WifiOff className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
88
95
|
<p>{t('offline_status')}</p>
|
|
89
96
|
</div>
|
|
90
97
|
)}
|
|
91
|
-
{online && !installed && (
|
|
98
|
+
{placement === 'settings' && online && !installed && (
|
|
92
99
|
<Button
|
|
93
100
|
variant="outline"
|
|
94
101
|
size="sm"
|
|
95
|
-
className="
|
|
102
|
+
className="gap-2"
|
|
96
103
|
onClick={() => void install()}
|
|
97
104
|
>
|
|
98
105
|
<Download className="size-4" />
|
|
99
106
|
{t('install')}
|
|
100
107
|
</Button>
|
|
101
108
|
)}
|
|
109
|
+
{placement === 'settings' && installed && labels.installed && (
|
|
110
|
+
<p role="status" className="text-muted-foreground text-sm">
|
|
111
|
+
{labels.installed}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
102
114
|
<Dialog open={showHelp} onOpenChange={setShowHelp}>
|
|
103
115
|
<DialogContent className="max-w-md">
|
|
104
116
|
<DialogHeader>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
act,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from '@testing-library/react';
|
|
8
|
+
import type { Editor, JSONContent } from '@tiptap/react';
|
|
9
|
+
import { expect, it, vi } from 'vitest';
|
|
10
|
+
import { RichTextEditor } from './editor';
|
|
11
|
+
|
|
12
|
+
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
|
13
|
+
vi.mock('./tool-bar', () => ({
|
|
14
|
+
FixedToolbar: () => null,
|
|
15
|
+
ToolBar: () => null,
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./task-item-checkbox', async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import('./task-item-checkbox')>()),
|
|
19
|
+
useMentionedTaskStatuses: () => ({ data: [] }),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
it('removes click-blocking classes after sync without recreating the editor', async () => {
|
|
23
|
+
const editorRef: { current: Editor | null } = { current: null };
|
|
24
|
+
const content: JSONContent = {
|
|
25
|
+
type: 'doc',
|
|
26
|
+
content: [
|
|
27
|
+
{
|
|
28
|
+
type: 'taskList',
|
|
29
|
+
content: [
|
|
30
|
+
{
|
|
31
|
+
type: 'taskItem',
|
|
32
|
+
attrs: { checked: false },
|
|
33
|
+
content: [
|
|
34
|
+
{
|
|
35
|
+
type: 'paragraph',
|
|
36
|
+
content: [{ type: 'text', text: 'Checklist' }],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
const onImmediateChange = vi.fn();
|
|
45
|
+
const { rerender } = render(
|
|
46
|
+
<RichTextEditor
|
|
47
|
+
content={content}
|
|
48
|
+
editorRef={editorRef}
|
|
49
|
+
readOnly
|
|
50
|
+
onImmediateChange={onImmediateChange}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
await act(async () => {});
|
|
54
|
+
await waitFor(() => expect(editorRef.current).not.toBeNull());
|
|
55
|
+
const original = editorRef.current!;
|
|
56
|
+
expect(original.view.dom.className).toContain(
|
|
57
|
+
'[&_.task-list-checkbox]:!pointer-events-none'
|
|
58
|
+
);
|
|
59
|
+
rerender(
|
|
60
|
+
<RichTextEditor
|
|
61
|
+
content={content}
|
|
62
|
+
editorRef={editorRef}
|
|
63
|
+
readOnly={false}
|
|
64
|
+
onImmediateChange={onImmediateChange}
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
await act(async () => {});
|
|
68
|
+
expect(editorRef.current).toBe(original);
|
|
69
|
+
expect(original.isEditable).toBe(true);
|
|
70
|
+
expect(original.view.dom.className).not.toContain(
|
|
71
|
+
'[&_.task-list-checkbox]:!pointer-events-none'
|
|
72
|
+
);
|
|
73
|
+
for (const state of ['indeterminate', true, false]) {
|
|
74
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'status' }));
|
|
75
|
+
expect(original.state.doc.firstChild?.firstChild?.attrs.checked).toBe(
|
|
76
|
+
state
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
expect(onImmediateChange).toHaveBeenCalled();
|
|
80
|
+
rerender(<RichTextEditor content={content} editorRef={editorRef} readOnly />);
|
|
81
|
+
await act(async () => {});
|
|
82
|
+
expect(original.isEditable).toBe(false);
|
|
83
|
+
expect(original.view.dom.className).toContain(
|
|
84
|
+
'[&_.task-list-checkbox]:!pointer-events-none'
|
|
85
|
+
);
|
|
86
|
+
});
|
|
@@ -443,10 +443,18 @@ export function RichTextEditor({
|
|
|
443
443
|
).updateUser?.(collaborationUser);
|
|
444
444
|
}, [editor, allowCollaboration, yjsProvider, collaborationUser]);
|
|
445
445
|
|
|
446
|
-
//
|
|
446
|
+
// useEditor keeps options from creation when its dependency array is stable.
|
|
447
|
+
// Sync the DOM classes too: setEditable alone leaves sync-time pointer blockers.
|
|
447
448
|
useEffect(() => {
|
|
448
|
-
if (editor)
|
|
449
|
-
|
|
449
|
+
if (!editor) return;
|
|
450
|
+
editor.setOptions({
|
|
451
|
+
editorProps: {
|
|
452
|
+
...editor.options.editorProps,
|
|
453
|
+
attributes: { class: getEditorClasses },
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
editor.setEditable(!readOnly);
|
|
457
|
+
}, [editor, readOnly, getEditorClasses]);
|
|
450
458
|
|
|
451
459
|
// Update editor content when the content prop changes externally
|
|
452
460
|
useEffect(() => {
|
|
@@ -1,85 +1,208 @@
|
|
|
1
|
-
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { TaskList } from '@tiptap/extension-list';
|
|
3
|
+
import { type Editor, EditorContent, useEditor } from '@tiptap/react';
|
|
4
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { TaskItemCheckbox } from './task-item-checkbox-extension';
|
|
7
|
+
import { TaskItemStatusPicker } from './task-item-status-picker';
|
|
5
8
|
|
|
6
|
-
vi.mock('
|
|
7
|
-
|
|
9
|
+
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
|
10
|
+
vi.mock('./task-item-checkbox', async (importOriginal) => ({
|
|
11
|
+
...(await importOriginal<typeof import('./task-item-checkbox')>()),
|
|
12
|
+
useMentionedTaskStatuses: () => ({ data: [] }),
|
|
8
13
|
}));
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const setNodeMarkup = vi.fn();
|
|
21
|
-
const command = vi.fn(
|
|
22
|
-
(
|
|
23
|
-
callback: (props: {
|
|
24
|
-
tr: { setNodeMarkup: typeof setNodeMarkup };
|
|
25
|
-
}) => boolean
|
|
26
|
-
) => callback({ tr: { setNodeMarkup } })
|
|
27
|
-
);
|
|
28
|
-
|
|
29
|
-
const props = {
|
|
30
|
-
node: {
|
|
31
|
-
attrs: { checked: false },
|
|
32
|
-
descendants: vi.fn(),
|
|
33
|
-
},
|
|
34
|
-
getPos: () => 4,
|
|
35
|
-
editor: {
|
|
36
|
-
isEditable: true,
|
|
37
|
-
commands: { command },
|
|
38
|
-
},
|
|
39
|
-
} as unknown as NodeViewProps;
|
|
40
|
-
|
|
41
|
-
return { command, props, setNodeMarkup };
|
|
15
|
+
let editor: Editor;
|
|
16
|
+
function Harness({ editable = true }: { editable?: boolean }) {
|
|
17
|
+
const instance = useEditor({
|
|
18
|
+
extensions: [StarterKit, TaskList, TaskItemCheckbox],
|
|
19
|
+
content:
|
|
20
|
+
'<ul data-type="taskList"><li data-type="taskItem" data-checked="false"><p>Checklist text</p></li></ul>',
|
|
21
|
+
editable,
|
|
22
|
+
});
|
|
23
|
+
if (instance) editor = instance;
|
|
24
|
+
return <EditorContent editor={instance} />;
|
|
42
25
|
}
|
|
26
|
+
const checkbox = () => screen.getByRole('checkbox', { name: 'status' });
|
|
27
|
+
const checked = () => editor.state.doc.firstChild?.firstChild?.attrs.checked;
|
|
28
|
+
afterEach(() => vi.useRealTimers());
|
|
43
29
|
|
|
44
|
-
describe('
|
|
45
|
-
it('cycles
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const checkbox = screen.getByRole('checkbox', {
|
|
50
|
-
name: 'Task item status',
|
|
30
|
+
describe('checklist editor interactions', () => {
|
|
31
|
+
it('cycles through all three persisted states with normal clicks', async () => {
|
|
32
|
+
await act(async () => {
|
|
33
|
+
render(<Harness />);
|
|
51
34
|
});
|
|
52
|
-
|
|
35
|
+
for (const state of ['indeterminate', true, false]) {
|
|
36
|
+
await act(async () => {
|
|
37
|
+
fireEvent.click(checkbox());
|
|
38
|
+
});
|
|
39
|
+
expect(checked()).toBe(state);
|
|
40
|
+
expect(checkbox()).toHaveAttribute(
|
|
41
|
+
'aria-checked',
|
|
42
|
+
state === 'indeterminate' ? 'mixed' : String(state)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
53
46
|
|
|
54
|
-
|
|
47
|
+
it('enables the status picker after task collaboration finishes syncing', async () => {
|
|
48
|
+
await act(async () => {
|
|
49
|
+
render(<Harness editable={false} />);
|
|
50
|
+
});
|
|
51
|
+
await act(async () => {
|
|
52
|
+
editor.setEditable(true);
|
|
53
|
+
});
|
|
54
|
+
expect(checkbox()).toHaveAttribute('aria-disabled', 'false');
|
|
55
|
+
fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
|
|
56
|
+
fireEvent.click(screen.getByRole('button', { name: 'in_progress' }));
|
|
57
|
+
expect(checked()).toBe('indeterminate');
|
|
58
|
+
for (const state of [true, false, 'indeterminate']) {
|
|
59
|
+
await act(async () => {
|
|
60
|
+
fireEvent.click(checkbox());
|
|
61
|
+
});
|
|
62
|
+
expect(checked()).toBe(state);
|
|
63
|
+
}
|
|
64
|
+
await act(async () => {
|
|
65
|
+
editor.setEditable(false);
|
|
66
|
+
});
|
|
67
|
+
expect(checkbox()).toHaveAttribute('aria-disabled', 'true');
|
|
68
|
+
});
|
|
55
69
|
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
it('follows undo, redo, and external document updates', async () => {
|
|
71
|
+
await act(async () => {
|
|
72
|
+
render(<Harness />);
|
|
73
|
+
});
|
|
74
|
+
await act(async () => {
|
|
75
|
+
fireEvent.click(checkbox());
|
|
76
|
+
});
|
|
77
|
+
await act(async () => {
|
|
78
|
+
editor.commands.undo();
|
|
79
|
+
});
|
|
80
|
+
expect(checkbox()).toHaveAttribute('aria-checked', 'false');
|
|
81
|
+
await act(async () => {
|
|
82
|
+
editor.commands.redo();
|
|
58
83
|
});
|
|
59
|
-
expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
|
|
84
|
+
expect(checkbox()).toHaveAttribute('aria-checked', 'mixed');
|
|
85
|
+
await act(async () => {
|
|
86
|
+
editor.view.dispatch(
|
|
87
|
+
editor.state.tr.setNodeMarkup(1, undefined, { checked: true })
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
expect(checkbox()).toHaveAttribute('aria-checked', 'true');
|
|
60
91
|
});
|
|
61
92
|
|
|
62
|
-
it('
|
|
63
|
-
|
|
64
|
-
|
|
93
|
+
it('opens a delayed picker and selects a state without moving text selection', async () => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
await act(async () => {
|
|
96
|
+
render(<Harness />);
|
|
97
|
+
});
|
|
98
|
+
await act(async () => {
|
|
99
|
+
editor.commands.setTextSelection(4);
|
|
100
|
+
});
|
|
101
|
+
fireEvent.pointerEnter(checkbox().parentElement!);
|
|
102
|
+
await act(async () => {
|
|
103
|
+
vi.advanceTimersByTime(499);
|
|
104
|
+
});
|
|
105
|
+
expect(
|
|
106
|
+
screen.queryByRole('button', { name: 'completed' })
|
|
107
|
+
).not.toBeInTheDocument();
|
|
108
|
+
await act(async () => {
|
|
109
|
+
vi.advanceTimersByTime(1);
|
|
110
|
+
});
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: 'completed' }));
|
|
112
|
+
expect(checked()).toBe(true);
|
|
113
|
+
expect(editor.state.selection.from).toBe(4);
|
|
114
|
+
});
|
|
65
115
|
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
it('cancels a brief hover and closes the picker with Escape', async () => {
|
|
117
|
+
vi.useFakeTimers();
|
|
118
|
+
await act(async () => {
|
|
119
|
+
render(<Harness />);
|
|
120
|
+
});
|
|
121
|
+
fireEvent.pointerEnter(checkbox().parentElement!);
|
|
122
|
+
await act(async () => {
|
|
123
|
+
vi.advanceTimersByTime(200);
|
|
124
|
+
});
|
|
125
|
+
fireEvent.pointerLeave(checkbox().parentElement!);
|
|
126
|
+
await act(async () => {
|
|
127
|
+
vi.advanceTimersByTime(500);
|
|
68
128
|
});
|
|
129
|
+
expect(
|
|
130
|
+
screen.queryByRole('button', { name: 'completed' })
|
|
131
|
+
).not.toBeInTheDocument();
|
|
132
|
+
fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
|
|
133
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'completed' }), {
|
|
134
|
+
key: 'Escape',
|
|
135
|
+
});
|
|
136
|
+
expect(
|
|
137
|
+
screen.queryByRole('button', { name: 'completed' })
|
|
138
|
+
).not.toBeInTheDocument();
|
|
139
|
+
});
|
|
69
140
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
141
|
+
it('keeps indeterminate state when serializing and reloading the checklist', async () => {
|
|
142
|
+
await act(async () => {
|
|
143
|
+
render(<Harness />);
|
|
144
|
+
});
|
|
145
|
+
await act(async () => {
|
|
146
|
+
fireEvent.click(checkbox());
|
|
147
|
+
});
|
|
148
|
+
const html = editor.getHTML();
|
|
149
|
+
await act(async () => {
|
|
150
|
+
editor.commands.setContent(html);
|
|
151
|
+
});
|
|
152
|
+
expect(checked()).toBe('indeterminate');
|
|
153
|
+
expect(checkbox()).toHaveAttribute('aria-checked', 'mixed');
|
|
154
|
+
});
|
|
73
155
|
|
|
74
|
-
|
|
75
|
-
|
|
156
|
+
it('opens the picker from the keyboard and prevents read-only changes', async () => {
|
|
157
|
+
let unmount = () => {};
|
|
158
|
+
await act(async () => {
|
|
159
|
+
({ unmount } = render(<Harness />));
|
|
76
160
|
});
|
|
77
|
-
|
|
78
|
-
|
|
161
|
+
fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
|
|
162
|
+
expect(
|
|
163
|
+
screen.getByRole('button', { name: 'in_progress' })
|
|
164
|
+
).toBeInTheDocument();
|
|
165
|
+
unmount();
|
|
166
|
+
await act(async () => {
|
|
167
|
+
render(<Harness editable={false} />);
|
|
79
168
|
});
|
|
80
|
-
|
|
81
|
-
|
|
169
|
+
await act(async () => {
|
|
170
|
+
fireEvent.click(checkbox());
|
|
82
171
|
});
|
|
83
|
-
|
|
172
|
+
fireEvent.keyDown(checkbox(), { key: 'ArrowDown' });
|
|
173
|
+
expect(checked()).toBe(false);
|
|
174
|
+
expect(
|
|
175
|
+
screen.queryByRole('button', { name: 'in_progress' })
|
|
176
|
+
).not.toBeInTheDocument();
|
|
84
177
|
});
|
|
178
|
+
it.each([200, 500])(
|
|
179
|
+
'clears a pending or open picker when disabled after %i ms',
|
|
180
|
+
async (hoverMs) => {
|
|
181
|
+
vi.useFakeTimers();
|
|
182
|
+
const picker = (disabled: boolean) => (
|
|
183
|
+
<TaskItemStatusPicker
|
|
184
|
+
state={false}
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
onSelect={vi.fn()}
|
|
187
|
+
>
|
|
188
|
+
<button type="button">Toggle</button>
|
|
189
|
+
</TaskItemStatusPicker>
|
|
190
|
+
);
|
|
191
|
+
const { rerender } = render(picker(false));
|
|
192
|
+
fireEvent.pointerEnter(
|
|
193
|
+
screen.getByRole('button', { name: 'Toggle' }).parentElement!
|
|
194
|
+
);
|
|
195
|
+
await act(async () => {
|
|
196
|
+
vi.advanceTimersByTime(hoverMs);
|
|
197
|
+
});
|
|
198
|
+
rerender(picker(true));
|
|
199
|
+
rerender(picker(false));
|
|
200
|
+
await act(async () => {
|
|
201
|
+
vi.advanceTimersByTime(1000);
|
|
202
|
+
});
|
|
203
|
+
expect(
|
|
204
|
+
screen.queryByRole('button', { name: 'completed' })
|
|
205
|
+
).not.toBeInTheDocument();
|
|
206
|
+
}
|
|
207
|
+
);
|
|
85
208
|
});
|