@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.
- package/CHANGELOG.md +49 -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/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
|
@@ -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.
|
|
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">
|
|
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)}
|
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const chartData =
|
|
174
|
-
|
|
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
|
|
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
|
-
() =>
|
|
114
|
-
|
|
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.
|
|
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((
|
|
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-
|
|
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=
|
|
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(() => {
|