@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/biome.json +1 -1
  3. package/package.json +53 -51
  4. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +58 -24
  5. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +57 -0
  6. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +28 -14
  7. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.test.tsx +321 -0
  8. package/src/components/ui/calendar-app/components/refresh-calendar-sync-status.ts +182 -0
  9. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +12 -14
  10. package/src/components/ui/chat/ai-message-parts.test.tsx +47 -0
  11. package/src/components/ui/chat/ai-message-parts.tsx +23 -16
  12. package/src/components/ui/chat/ai-message-render-utils.ts +19 -1
  13. package/src/components/ui/commercial-comparison-style.ts +104 -0
  14. package/src/components/ui/commercial-comparison-table.tsx +159 -0
  15. package/src/components/ui/commercial-comparison-toolbar.tsx +205 -0
  16. package/src/components/ui/commercial-comparison.test.tsx +86 -0
  17. package/src/components/ui/commercial-comparison.tsx +224 -0
  18. package/src/components/ui/comparison-value.test.tsx +32 -0
  19. package/src/components/ui/comparison-value.tsx +72 -0
  20. package/src/components/ui/custom/__tests__/report-preview-pagination-progress.test.ts +129 -0
  21. package/src/components/ui/custom/__tests__/report-preview-viewport.test.tsx +56 -0
  22. package/src/components/ui/custom/account-assurance-refresh.test.tsx +42 -0
  23. package/src/components/ui/custom/account-assurance-refresh.tsx +30 -0
  24. package/src/components/ui/custom/education/modules/resources/pdf-viewer.tsx +13 -2
  25. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  26. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  27. package/src/components/ui/custom/report-preview.tsx +2 -3
  28. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  29. package/src/components/ui/finance/invoices/charts/subscription-payment-chart.tsx +137 -0
  30. package/src/components/ui/finance/invoices/charts/subscription-payment-details.tsx +111 -0
  31. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  32. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  33. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  34. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  35. package/src/components/ui/finance/invoices/components/subscription-group-selector.tsx +29 -6
  36. package/src/components/ui/finance/invoices/export-data.test.ts +121 -0
  37. package/src/components/ui/finance/invoices/export-data.ts +229 -0
  38. package/src/components/ui/finance/invoices/export-dialog-content.test.tsx +245 -0
  39. package/src/components/ui/finance/invoices/export-dialog-content.tsx +108 -292
  40. package/src/components/ui/finance/invoices/export-pagination.test.ts +131 -0
  41. package/src/components/ui/finance/invoices/export-pagination.ts +66 -0
  42. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  43. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  44. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +255 -0
  45. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +6 -92
  46. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.test.tsx +93 -0
  47. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-content.ts +11 -15
  48. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  49. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  50. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  51. package/src/components/ui/finance/invoices/invoice-analytics.tsx +24 -1
  52. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  53. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  54. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  55. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  56. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  57. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  58. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  59. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  60. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  61. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  62. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  63. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  64. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  65. package/src/components/ui/finance/invoices/query-invalidation.test.ts +28 -0
  66. package/src/components/ui/finance/invoices/query-invalidation.ts +3 -0
  67. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  68. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  69. package/src/components/ui/finance/invoices/subscription-coverage.test.ts +74 -0
  70. package/src/components/ui/finance/invoices/subscription-coverage.ts +109 -0
  71. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  72. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  73. package/src/components/ui/finance/invoices/subscription-invoice.tsx +47 -51
  74. package/src/components/ui/finance/invoices/subscription-payment-analytics.test.tsx +168 -0
  75. package/src/components/ui/finance/invoices/subscription-payment-analytics.tsx +253 -0
  76. package/src/components/ui/finance/invoices/utils.ts +48 -106
  77. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  78. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  79. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  80. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  81. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  82. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  83. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  84. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  85. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  86. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +5 -5
  87. package/src/components/ui/legacy/calendar/location-timeline.tsx +2 -14
  88. package/src/components/ui/legacy/calendar/working-location.test.ts +47 -0
  89. package/src/components/ui/legacy/calendar/working-location.ts +35 -0
  90. package/src/components/ui/public-workspace-prices.tsx +13 -0
  91. package/src/components/ui/pwa-status.test.tsx +13 -2
  92. package/src/components/ui/pwa-status.tsx +15 -3
  93. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  94. package/src/components/ui/text-editor/editor.tsx +11 -3
  95. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  96. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  97. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  98. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  99. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  100. package/src/hooks/calendar-provider-sync.ts +33 -0
  101. package/src/hooks/notification-subscription.ts +74 -0
  102. package/src/hooks/use-calendar-sync.tsx +11 -14
  103. package/src/hooks/use-calendar.tsx +2 -2
  104. 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 = getLocationType(event.title ?? '');
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="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"
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="fixed right-3 bottom-3 z-30 gap-2 rounded-full bg-background shadow-sm"
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
- // Update editor's editable state when props change
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) editor.setEditable(!readOnly);
449
- }, [editor, readOnly]);
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 type { NodeViewProps } from '@tiptap/react';
3
- import { describe, expect, it, vi } from 'vitest';
4
- import { TaskItemCheckboxContent } from './task-item-checkbox-view';
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('@tuturuuu/supabase/next/client', () => ({
7
- createClient: vi.fn(),
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
- vi.mock('./task-item-checkbox', async (importOriginal) => {
11
- const actual = await importOriginal<typeof import('./task-item-checkbox')>();
12
-
13
- return {
14
- ...actual,
15
- useMentionedTaskStatuses: () => ({ data: [] }),
16
- };
17
- });
18
-
19
- function createNodeViewProps() {
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('TaskItemCheckboxContent', () => {
45
- it('cycles pointer interactions before the editor can replace the node view', () => {
46
- const { props, setNodeMarkup } = createNodeViewProps();
47
- render(<TaskItemCheckboxContent {...props} />);
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
- expect(checkbox).toHaveAttribute('aria-checked', 'false');
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
- fireEvent.pointerDown(checkbox, { button: 0, isPrimary: true });
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
- expect(setNodeMarkup).toHaveBeenLastCalledWith(4, undefined, {
57
- checked: 'indeterminate',
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('cycles all three states from the keyboard', () => {
63
- const { props, setNodeMarkup } = createNodeViewProps();
64
- render(<TaskItemCheckboxContent {...props} />);
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
- const checkbox = screen.getByRole('checkbox', {
67
- name: 'Task item status',
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
- fireEvent.keyDown(checkbox, { key: ' ' });
71
- fireEvent.keyDown(checkbox, { key: 'Enter' });
72
- fireEvent.keyDown(checkbox, { key: ' ' });
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
- expect(setNodeMarkup).toHaveBeenNthCalledWith(1, 4, undefined, {
75
- checked: 'indeterminate',
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
- expect(setNodeMarkup).toHaveBeenNthCalledWith(2, 4, undefined, {
78
- checked: true,
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
- expect(setNodeMarkup).toHaveBeenNthCalledWith(3, 4, undefined, {
81
- checked: false,
169
+ await act(async () => {
170
+ fireEvent.click(checkbox());
82
171
  });
83
- expect(checkbox).toHaveAttribute('aria-checked', 'false');
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
  });