@tuturuuu/ui 0.32.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +49 -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/education/modules/resources/pdf-viewer.tsx +13 -2
  23. package/src/components/ui/custom/report-preview-pagination.ts +5 -3
  24. package/src/components/ui/custom/report-preview-viewport.tsx +40 -0
  25. package/src/components/ui/custom/report-preview.tsx +2 -3
  26. package/src/components/ui/finance/categories-tags-tabs.tsx +3 -0
  27. package/src/components/ui/finance/invoices/columns.tsx +2 -0
  28. package/src/components/ui/finance/invoices/components/invoice-checkout-summary.tsx +6 -3
  29. package/src/components/ui/finance/invoices/components/invoice-data-state.test.tsx +27 -0
  30. package/src/components/ui/finance/invoices/components/invoice-data-state.tsx +34 -0
  31. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +89 -0
  32. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +117 -0
  33. package/src/components/ui/finance/invoices/hooks.ts +5 -110
  34. package/src/components/ui/finance/invoices/internal-api.test.ts +122 -0
  35. package/src/components/ui/finance/invoices/internal-api.ts +19 -1
  36. package/src/components/ui/finance/invoices/invoice-discount.test.tsx +107 -0
  37. package/src/components/ui/finance/invoices/invoice-discount.ts +12 -0
  38. package/src/components/ui/finance/invoices/invoice-history-filters.tsx +121 -0
  39. package/src/components/ui/finance/invoices/invoice-history-pagination.tsx +81 -0
  40. package/src/components/ui/finance/invoices/invoice-history-row.tsx +159 -0
  41. package/src/components/ui/finance/invoices/invoice-history.test.tsx +170 -0
  42. package/src/components/ui/finance/invoices/invoice-history.tsx +300 -0
  43. package/src/components/ui/finance/invoices/invoice-page.tsx +18 -2
  44. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +3 -7
  45. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.test.tsx +16 -0
  46. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +9 -0
  47. package/src/components/ui/finance/invoices/invoices-table.tsx +3 -0
  48. package/src/components/ui/finance/invoices/promotion-form.tsx +5 -4
  49. package/src/components/ui/finance/invoices/query-invalidation.ts +1 -0
  50. package/src/components/ui/finance/invoices/row-actions.tsx +35 -4
  51. package/src/components/ui/finance/invoices/standard-invoice.tsx +27 -16
  52. package/src/components/ui/finance/invoices/subscription-invoice-props.ts +20 -0
  53. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +71 -4
  54. package/src/components/ui/finance/invoices/subscription-invoice.tsx +44 -45
  55. package/src/components/ui/finance/shared/charts/category-breakdown-chart-body.tsx +73 -80
  56. package/src/components/ui/finance/shared/charts/category-breakdown-chart-controls.tsx +3 -1
  57. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.test.tsx +30 -0
  58. package/src/components/ui/finance/shared/charts/category-breakdown-chart-legend.tsx +31 -24
  59. package/src/components/ui/finance/shared/charts/category-breakdown-chart-tooltip.tsx +9 -6
  60. package/src/components/ui/finance/shared/charts/category-breakdown-chart-types.ts +2 -0
  61. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.test.ts +59 -0
  62. package/src/components/ui/finance/shared/charts/category-breakdown-chart-utils.ts +51 -37
  63. package/src/components/ui/finance/shared/charts/category-breakdown-chart.tsx +37 -7
  64. package/src/components/ui/public-workspace-prices.tsx +13 -0
  65. package/src/components/ui/pwa-status.test.tsx +13 -2
  66. package/src/components/ui/pwa-status.tsx +15 -3
  67. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +86 -0
  68. package/src/components/ui/text-editor/editor.tsx +11 -3
  69. package/src/components/ui/text-editor/task-item-checkbox-view.test.tsx +188 -65
  70. package/src/components/ui/text-editor/task-item-checkbox-view.tsx +178 -164
  71. package/src/components/ui/text-editor/task-item-status-picker.tsx +130 -0
  72. package/src/hooks/__tests__/use-calendar-sync.test.tsx +20 -1
  73. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +99 -180
  74. package/src/hooks/calendar-provider-sync.ts +33 -0
  75. package/src/hooks/notification-subscription.ts +74 -0
  76. package/src/hooks/use-calendar-sync.tsx +11 -14
  77. package/src/hooks/use-calendar.tsx +2 -2
  78. package/src/hooks/use-notifications.ts +5 -141
@@ -6,6 +6,7 @@ import type {
6
6
 
7
7
  interface TooltipPayloadItem {
8
8
  name: string;
9
+ dataKey?: string;
9
10
  value: number;
10
11
  color?: string;
11
12
  }
@@ -36,7 +37,7 @@ export function CategoryBreakdownTooltipContent({
36
37
  if (!active || !payload || payload.length === 0) return null;
37
38
 
38
39
  const sortedPayload = [...payload]
39
- .filter((item) => !hiddenCategories.has(item.name))
40
+ .filter((item) => !hiddenCategories.has(item.dataKey ?? item.name))
40
41
  .sort((a, b) => (b.value || 0) - (a.value || 0));
41
42
 
42
43
  if (sortedPayload.length === 0) return null;
@@ -78,16 +79,16 @@ export function CategoryBreakdownTooltipContent({
78
79
  }
79
80
 
80
81
  return (
81
- <div className="rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
82
+ <div className="max-w-sm rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-xl">
82
83
  <p className="mb-2 font-semibold">{formattedLabel}</p>
83
- <div className="space-y-1">
84
+ <div className="max-h-64 space-y-1 overflow-y-auto">
84
85
  {sortedPayload.map((item) => {
85
- const category = categories.find((c) => c.name === item.name);
86
+ const category = categories.find((c) => c.key === item.dataKey);
86
87
  const color = category?.color || item.color;
87
88
 
88
89
  return (
89
90
  <div
90
- key={item.name}
91
+ key={item.dataKey ?? item.name}
91
92
  className="flex items-center justify-between gap-4"
92
93
  >
93
94
  <div className="flex items-center gap-2">
@@ -95,7 +96,9 @@ export function CategoryBreakdownTooltipContent({
95
96
  className="h-3 w-3 rounded-sm"
96
97
  style={{ backgroundColor: color }}
97
98
  />
98
- <span className="text-sm">{item.name}</span>
99
+ <span className="line-clamp-2 break-words text-sm">
100
+ {item.name}
101
+ </span>
99
102
  </div>
100
103
  <span className="font-semibold text-sm" style={{ color }}>
101
104
  {formatValue(item.value)}
@@ -3,6 +3,8 @@ export type TransactionType = 'expense' | 'income';
3
3
  export type ChartInterval = 'daily' | 'weekly' | 'monthly' | 'yearly';
4
4
 
5
5
  export interface CategoryBreakdownCategory {
6
+ key: string;
7
+ total: number;
6
8
  color: string;
7
9
  id: string | null;
8
10
  name: string;
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildCategoryBreakdownChartData } from './category-breakdown-chart-utils';
3
+
4
+ const point = (
5
+ id: string,
6
+ name: string,
7
+ total: number,
8
+ period = '2026-09-01'
9
+ ) => ({
10
+ category_id: id,
11
+ category_name: name,
12
+ category_color: null,
13
+ category_icon: null,
14
+ total,
15
+ period,
16
+ });
17
+
18
+ describe('category breakdown series', () => {
19
+ it('keeps categories with the same name separate and sums repeated points', () => {
20
+ const result = buildCategoryBreakdownChartData([
21
+ point('a', 'Office', 10),
22
+ point('b', 'Office', 20),
23
+ point('a', 'Office', 5),
24
+ ]);
25
+ expect(result.categories.map((category) => category.total)).toEqual([
26
+ 20, 15,
27
+ ]);
28
+ expect(result.chartData[0]).toMatchObject({
29
+ category_a: 15,
30
+ category_b: 20,
31
+ });
32
+ });
33
+ it('groups smaller categories without losing any period totals', () => {
34
+ const result = buildCategoryBreakdownChartData(
35
+ [
36
+ point('a', 'A', 100),
37
+ point('b', 'B', 20),
38
+ point('c', 'C', 5),
39
+ point('b', 'B', 3, '2026-08-01'),
40
+ ],
41
+ { limit: 1, otherLabel: 'Other' }
42
+ );
43
+ expect(
44
+ result.categories.map((category) => [category.name, category.total])
45
+ ).toEqual([
46
+ ['A', 100],
47
+ ['Other', 28],
48
+ ]);
49
+ expect(result.chartData).toEqual([
50
+ { period: '2026-08-01', other_categories: 3 },
51
+ { period: '2026-09-01', category_a: 100, other_categories: 25 },
52
+ ]);
53
+ });
54
+ it('keeps arbitrary names out of chart data keys', () => {
55
+ expect(
56
+ buildCategoryBreakdownChartData([point('a', 'period', 10)]).chartData[0]
57
+ ).toEqual({ period: '2026-09-01', category_a: 10 });
58
+ });
59
+ });
@@ -131,50 +131,64 @@ export function getActualCategoryBreakdownDisplayRange(
131
131
  }
132
132
 
133
133
  export function buildCategoryBreakdownChartData(
134
- rawData: FinanceCategoryBreakdownPoint[]
134
+ rawData: FinanceCategoryBreakdownPoint[],
135
+ options?: { limit: number; otherLabel: string }
135
136
  ): {
136
137
  categories: CategoryBreakdownCategory[];
137
138
  chartData: CategoryBreakdownChartDatum[];
138
139
  } {
139
- if (rawData.length === 0) {
140
- return { chartData: [], categories: [] };
141
- }
142
-
143
140
  const categoryMap = new Map<string, CategoryBreakdownCategory>();
144
- let colorIndex = 0;
145
-
146
- rawData.forEach((item) => {
147
- const key = item.category_id || 'uncategorized';
148
- if (categoryMap.has(key)) return;
149
-
150
- const color =
151
- item.category_color ||
152
- DEFAULT_CATEGORY_COLORS[colorIndex % DEFAULT_CATEGORY_COLORS.length] ||
153
- DEFAULT_CATEGORY_COLORS[0]!;
154
-
155
- categoryMap.set(key, {
156
- id: item.category_id,
157
- name: item.category_name,
158
- color,
159
- });
160
- colorIndex += 1;
161
- });
162
-
141
+ for (const item of rawData) {
142
+ const key = `category_${item.category_id ?? 'uncategorized'}`;
143
+ const existing = categoryMap.get(key);
144
+ if (existing) {
145
+ existing.total += Number(item.total) || 0;
146
+ } else {
147
+ categoryMap.set(key, {
148
+ key,
149
+ id: item.category_id,
150
+ name: item.category_name,
151
+ total: Number(item.total) || 0,
152
+ color:
153
+ item.category_color ||
154
+ DEFAULT_CATEGORY_COLORS[
155
+ categoryMap.size % DEFAULT_CATEGORY_COLORS.length
156
+ ]!,
157
+ });
158
+ }
159
+ }
160
+ const ranked = [...categoryMap.values()].sort(
161
+ (a, b) => b.total - a.total || a.key.localeCompare(b.key)
162
+ );
163
+ const visible = options ? ranked.slice(0, options.limit) : ranked;
164
+ const visibleKeys = new Set(visible.map((category) => category.key));
165
+ const remainder = ranked.slice(visible.length);
166
+ const categories =
167
+ remainder.length > 0
168
+ ? [
169
+ ...visible,
170
+ {
171
+ key: 'other_categories',
172
+ id: null,
173
+ name: options!.otherLabel,
174
+ total: remainder.reduce((sum, category) => sum + category.total, 0),
175
+ color: 'var(--muted-foreground)',
176
+ },
177
+ ]
178
+ : visible;
163
179
  const periodMap = new Map<string, CategoryBreakdownChartDatum>();
164
-
165
- rawData.forEach((item) => {
166
- if (!periodMap.has(item.period)) {
180
+ for (const item of rawData) {
181
+ if (!periodMap.has(item.period))
167
182
  periodMap.set(item.period, { period: item.period });
168
- }
169
- const periodData = periodMap.get(item.period)!;
170
- periodData[item.category_name] = Number(item.total) || 0;
171
- });
172
-
173
- const chartData = Array.from(periodMap.values()).sort((a, b) => {
174
- return new Date(a.period).getTime() - new Date(b.period).getTime();
175
- });
176
-
177
- return { chartData, categories: Array.from(categoryMap.values()) };
183
+ const period = periodMap.get(item.period)!;
184
+ const categoryKey = `category_${item.category_id ?? 'uncategorized'}`;
185
+ const key = visibleKeys.has(categoryKey) ? categoryKey : 'other_categories';
186
+ period[key] = Number(period[key] || 0) + (Number(item.total) || 0);
187
+ }
188
+ const chartData = [...periodMap.values()].sort(
189
+ (a, b) => new Date(a.period).getTime() - new Date(b.period).getTime()
190
+ );
191
+ return { categories, chartData };
178
192
  }
179
193
 
180
194
  export function formatCategoryBreakdownXAxisTick(
@@ -5,7 +5,7 @@ import { listFinanceCategoryBreakdown } from '@tuturuuu/internal-api/finance';
5
5
  import { cn, getCurrencyLocale } from '@tuturuuu/utils/format';
6
6
  import { useLocale, useTranslations } from 'next-intl';
7
7
  import { type ReactNode, useCallback, useMemo, useState } from 'react';
8
- import type { LegendPayload } from 'recharts';
8
+ import { Button } from '../../../button';
9
9
  import { Card, CardContent, CardHeader } from '../../../card';
10
10
  import type { ChartConfig } from '../../../chart';
11
11
  import { Skeleton } from '../../../skeleton';
@@ -47,6 +47,7 @@ export function CategoryBreakdownChart({
47
47
  const { isConfidential, toggleConfidential } =
48
48
  useFinanceConfidentialVisibility();
49
49
  const shouldHideAmounts = isConfidential || !includeConfidential;
50
+ const [showAll, setShowAll] = useState(false);
50
51
  const [dateOffset, setDateOffset] = useState(0);
51
52
  const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(
52
53
  new Set()
@@ -110,8 +111,14 @@ export function CategoryBreakdownChart({
110
111
  );
111
112
 
112
113
  const { chartData, categories } = useMemo(
113
- () => buildCategoryBreakdownChartData(rawData),
114
- [rawData]
114
+ () =>
115
+ buildCategoryBreakdownChartData(
116
+ rawData,
117
+ showAll
118
+ ? undefined
119
+ : { limit: 7, otherLabel: t('finance-analytics.other-categories') }
120
+ ),
121
+ [rawData, showAll, t]
115
122
  );
116
123
 
117
124
  const intervalLabels: Record<ChartInterval, string> = useMemo(
@@ -133,7 +140,7 @@ export function CategoryBreakdownChart({
133
140
 
134
141
  const chartConfig = useMemo<ChartConfig>(() => {
135
142
  return categories.reduce((config, category) => {
136
- config[category.name] = {
143
+ config[category.key] = {
137
144
  label: category.name,
138
145
  color: category.color,
139
146
  };
@@ -166,8 +173,7 @@ export function CategoryBreakdownChart({
166
173
  [shouldHideAmounts, locale]
167
174
  );
168
175
 
169
- const handleLegendClick = useCallback((entry: LegendPayload) => {
170
- const categoryName = entry.value as string;
176
+ const handleLegendClick = useCallback((categoryName: string) => {
171
177
  setHiddenCategories((prev) => {
172
178
  const next = new Set(prev);
173
179
  if (next.has(categoryName)) {
@@ -246,7 +252,31 @@ export function CategoryBreakdownChart({
246
252
  }
247
253
 
248
254
  return renderCard(
249
- <CardContent className="px-2 pb-4">
255
+ <CardContent className="min-w-0 space-y-3 px-4 pb-4 sm:px-6">
256
+ <div className="flex flex-wrap items-center justify-between gap-2 text-muted-foreground text-xs">
257
+ <p>
258
+ {t(
259
+ showAll
260
+ ? 'finance-analytics.all-categories-description'
261
+ : 'finance-analytics.top-categories-description'
262
+ )}
263
+ </p>
264
+ <Button
265
+ variant="ghost"
266
+ size="sm"
267
+ onClick={() => {
268
+ setShowAll(!showAll);
269
+ setHiddenCategories(new Set());
270
+ }}
271
+ aria-expanded={showAll}
272
+ >
273
+ {t(
274
+ showAll
275
+ ? 'finance-analytics.show-top-categories'
276
+ : 'finance-analytics.show-all-categories'
277
+ )}
278
+ </Button>
279
+ </div>
250
280
  <CategoryBreakdownChartBody
251
281
  categories={categories}
252
282
  chartConfig={chartConfig}
@@ -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(() => {