@tuturuuu/ui 0.37.2 → 0.39.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 (126) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/finance/invoices/attendance-calendar.test.tsx +294 -0
  11. package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
  12. package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
  13. package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
  14. package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
  15. package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
  16. package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
  17. package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
  18. package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
  19. package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
  20. package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
  21. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
  22. package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
  23. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
  24. package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
  25. package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
  26. package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
  27. package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
  28. package/src/components/ui/finance/invoices/hooks.ts +3 -100
  29. package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
  30. package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
  31. package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
  32. package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
  33. package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
  34. package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
  35. package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
  36. package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
  37. package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
  38. package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
  39. package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
  40. package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
  41. package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
  42. package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
  43. package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
  44. package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
  45. package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
  46. package/src/components/ui/finance/invoices/types.ts +3 -3
  47. package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
  48. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
  49. package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
  50. package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
  51. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
  52. package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
  53. package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
  54. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
  56. package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
  57. package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
  58. package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
  59. package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
  60. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  61. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  62. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  63. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  64. package/src/components/ui/legacy/calendar/legacy-event-modal.tsx +1559 -0
  65. package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
  66. package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
  67. package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
  68. package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
  69. package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
  70. package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
  71. package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
  72. package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
  73. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
  74. package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
  75. package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
  76. package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
  77. package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
  78. package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -0
  79. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  80. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  81. package/src/components/ui/legacy/calendar/provider-readonly-dialog.test.tsx +52 -0
  82. package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -0
  83. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  84. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  85. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  86. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  87. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
  88. package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -0
  89. package/src/components/ui/popover.tsx +3 -1
  90. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  91. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  92. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  93. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  94. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  95. package/src/components/ui/text-editor/editor.tsx +11 -3
  96. package/src/components/ui/text-editor/extensions.ts +8 -2
  97. package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
  98. package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
  99. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  100. package/src/components/ui/text-editor/markdown.ts +11 -0
  101. package/src/components/ui/text-editor/tool-bar.tsx +45 -102
  102. package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
  103. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  104. package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
  105. package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
  106. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
  107. package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
  108. package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
  109. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  110. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  111. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +147 -0
  112. package/src/hooks/calendar-connection-types.ts +49 -0
  113. package/src/hooks/calendar-creation-request.test.ts +62 -0
  114. package/src/hooks/calendar-creation-request.ts +34 -0
  115. package/src/hooks/calendar-event-update-queue-types.ts +12 -0
  116. package/src/hooks/calendar-event-write-payload.ts +57 -0
  117. package/src/hooks/collaboration-document-hydration.ts +35 -0
  118. package/src/hooks/notification-subscription.ts +5 -2
  119. package/src/hooks/supabase-provider.ts +76 -112
  120. package/src/hooks/use-calendar-sync.tsx +41 -62
  121. package/src/hooks/use-calendar.tsx +44 -82
  122. package/src/hooks/use-exchange-rates.ts +4 -15
  123. package/src/hooks/use-local-storage.ts +9 -11
  124. package/src/hooks/use-native-calendar-occurrences.ts +85 -0
  125. package/src/hooks/use-workspace-presence.ts +9 -9
  126. package/src/hooks/use-yjs-collaboration.ts +68 -27
@@ -34,6 +34,7 @@ import { formatLunarDay, getLunarDate } from '../../../../lib/lunar-calendar';
34
34
  import { Button } from '../../button';
35
35
  import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
36
36
  import { calendarDraftDate } from './calendar-period';
37
+ import { pastEventTreatment } from './past-event-treatment';
37
38
  import { useCalendarSettings } from './settings/settings-context';
38
39
 
39
40
  dayjs.extend(utc);
@@ -61,6 +62,7 @@ function MonthEvent({
61
62
  zone?: string;
62
63
  onOpen: (id: string) => void;
63
64
  }) {
65
+ const { preservePastEventOpacity } = useCalendar();
64
66
  const t = useTranslations('calendar');
65
67
  const title = event.title || t('views.untitled_event');
66
68
  const allDay = isAllDayEvent(event);
@@ -72,8 +74,9 @@ function MonthEvent({
72
74
  title={title}
73
75
  style={calendarEventStyle(event)}
74
76
  className={cn(
75
- 'flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:ring-1 hover:ring-current focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
76
- allDay && 'font-medium'
77
+ 'relative flex w-full min-w-0 items-center gap-1 rounded px-1 py-1 text-left text-[10px] leading-tight transition-colors hover:ring-1 hover:ring-current focus-visible:outline-2 focus-visible:outline-ring sm:px-1.5 sm:text-xs',
78
+ allDay && 'font-medium',
79
+ pastEventTreatment(event, preservePastEventOpacity)
77
80
  )}
78
81
  >
79
82
  {continued ? (
@@ -0,0 +1,21 @@
1
+ import { Repeat } from '@tuturuuu/icons';
2
+ import { useTranslations } from 'next-intl';
3
+ import { ContextMenuContent, ContextMenuItem } from '../../context-menu';
4
+
5
+ export function NativeRecurrenceContextMenu({
6
+ onEdit,
7
+ readOnly,
8
+ }: {
9
+ onEdit: () => void;
10
+ readOnly: boolean;
11
+ }) {
12
+ const t = useTranslations('calendar.recurrence');
13
+ return (
14
+ <ContextMenuContent>
15
+ <ContextMenuItem disabled={readOnly} onSelect={onEdit}>
16
+ <Repeat className="size-4" />
17
+ {t('edit')}
18
+ </ContextMenuItem>
19
+ </ContextMenuContent>
20
+ );
21
+ }
@@ -0,0 +1,245 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { InternalApiError } from '@tuturuuu/internal-api/client';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ const mock = vi.hoisted(() => ({
7
+ create: vi.fn(),
8
+ read: vi.fn(),
9
+ mutate: vi.fn(),
10
+ actor: { actorId: 'actor', assertActive: vi.fn() },
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/calendar-series', () => ({
13
+ createNativeCalendarSeries: mock.create,
14
+ getNativeCalendarSeries: mock.read,
15
+ mutateNativeCalendarSeries: mock.mutate,
16
+ }));
17
+ vi.mock('../../../../hooks/use-workspace-visibility', () => ({
18
+ useWorkspaceActor: () => mock.actor,
19
+ }));
20
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
21
+
22
+ import { NativeRecurrenceDialog } from './native-recurrence-dialog';
23
+
24
+ const series = {
25
+ id: 'series',
26
+ ws_id: 'ws',
27
+ revision: 4,
28
+ workspace_calendar_id: null,
29
+ rule: {
30
+ version: 1,
31
+ frequency: 'daily',
32
+ interval: 1,
33
+ timeZone: 'UTC',
34
+ end: { type: 'never' },
35
+ },
36
+ anchor: {
37
+ startLocal: '2026-10-05T09:00:00',
38
+ endLocal: '2026-10-05T10:00:00',
39
+ allDay: false,
40
+ },
41
+ payload: { title: 'Daily' },
42
+ exceptions: [],
43
+ };
44
+ const occurrence = {
45
+ id: 'event',
46
+ ws_id: 'ws',
47
+ seriesId: 'series',
48
+ seriesRevision: 2,
49
+ originalStartLocal: '2026-10-06T09:00:00',
50
+ start_at: '2026-10-06T09:00:00Z',
51
+ end_at: '2026-10-06T10:00:00Z',
52
+ title: 'Occurrence',
53
+ };
54
+ function setup(
55
+ props: Partial<Parameters<typeof NativeRecurrenceDialog>[0]> = {}
56
+ ) {
57
+ const client = new QueryClient({
58
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
59
+ });
60
+ const close = vi.fn();
61
+ const view = render(
62
+ <QueryClientProvider client={client}>
63
+ <NativeRecurrenceDialog
64
+ wsId="ws"
65
+ timezone="UTC"
66
+ open
67
+ onOpenChange={close}
68
+ {...props}
69
+ />
70
+ </QueryClientProvider>
71
+ );
72
+ return { ...view, close, client };
73
+ }
74
+ beforeEach(() => {
75
+ vi.resetAllMocks();
76
+ HTMLElement.prototype.scrollIntoView = vi.fn();
77
+ vi.stubGlobal(
78
+ 'ResizeObserver',
79
+ class {
80
+ observe() {}
81
+ unobserve() {}
82
+ disconnect() {}
83
+ }
84
+ );
85
+ mock.actor.actorId = 'actor';
86
+ mock.create.mockResolvedValue(series);
87
+ mock.read.mockResolvedValue(series);
88
+ mock.mutate.mockResolvedValue(series);
89
+ });
90
+ describe('native recurrence form interactions', () => {
91
+ it('creates through the series API and closes only after success', async () => {
92
+ const { close } = setup();
93
+ fireEvent.change(screen.getByLabelText('title'), {
94
+ target: { value: 'Planning' },
95
+ });
96
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
97
+ await waitFor(() => expect(mock.create).toHaveBeenCalledTimes(1));
98
+ expect(mock.create.mock.calls[0]?.[0]).toBe('ws');
99
+ expect(mock.create.mock.calls[0]?.[1]).toMatchObject({
100
+ event: { title: 'Planning' },
101
+ rule: { version: 1, frequency: 'weekly', timeZone: 'UTC' },
102
+ requestId: expect.any(String),
103
+ });
104
+ await waitFor(() => expect(close).toHaveBeenCalledWith(false));
105
+ });
106
+ it('retains the draft and request ID after transport failure for an idempotent retry', async () => {
107
+ mock.create.mockRejectedValueOnce(new InternalApiError('failed', 503));
108
+ const { close } = setup();
109
+ fireEvent.change(screen.getByLabelText('title'), {
110
+ target: { value: 'Planning' },
111
+ });
112
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
113
+ await screen.findByRole('alert');
114
+ expect(close).not.toHaveBeenCalled();
115
+ expect(screen.getByLabelText('title')).toHaveValue('Planning');
116
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
117
+ await waitFor(() => expect(mock.create).toHaveBeenCalledTimes(2));
118
+ expect(mock.create.mock.calls[1]?.[1].requestId).toBe(
119
+ mock.create.mock.calls[0]?.[1].requestId
120
+ );
121
+ });
122
+ it('rejects an invalid form before sending a mutation', async () => {
123
+ setup();
124
+ fireEvent.change(screen.getByLabelText('title'), {
125
+ target: { value: 'Planning' },
126
+ });
127
+ fireEvent.change(screen.getByLabelText('interval'), {
128
+ target: { value: '0' },
129
+ });
130
+ fireEvent.submit(
131
+ screen.getByRole('button', { name: 'save' }).closest('form')!
132
+ );
133
+ expect(screen.getByRole('alert')).toHaveTextContent('invalid');
134
+ expect(mock.create).not.toHaveBeenCalled();
135
+ });
136
+ it('disables writes in a read-only calendar', () => {
137
+ setup({ readOnly: true });
138
+ expect(screen.getByRole('button', { name: 'save' })).toBeDisabled();
139
+ expect(screen.getByLabelText('title')).toBeDisabled();
140
+ expect(mock.create).not.toHaveBeenCalled();
141
+ });
142
+ it('fences writes when the verified account scope changed', () => {
143
+ mock.actor.assertActive.mockImplementation(() => {
144
+ throw new Error('actor changed');
145
+ });
146
+ setup();
147
+ fireEvent.change(screen.getByLabelText('title'), {
148
+ target: { value: 'Planning' },
149
+ });
150
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
151
+ expect(screen.getByRole('alert')).toHaveTextContent('invalid');
152
+ expect(mock.create).not.toHaveBeenCalled();
153
+ });
154
+ it('edits a virtual occurrence using the loaded series revision and immutable original slot', async () => {
155
+ setup({ occurrence });
156
+ const title = await screen.findByLabelText('title');
157
+ fireEvent.change(title, { target: { value: 'New title' } });
158
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
159
+ await waitFor(() => expect(mock.mutate).toHaveBeenCalledTimes(1));
160
+ expect(mock.mutate.mock.calls[0]?.slice(0, 2)).toEqual(['ws', 'series']);
161
+ expect(mock.mutate.mock.calls[0]?.[2]).toMatchObject({
162
+ scope: 'this',
163
+ expectedRevision: 4,
164
+ originalStartLocal: '2026-10-06T09:00:00',
165
+ event: { title: 'New title' },
166
+ });
167
+ expect(mock.mutate.mock.calls[0]?.[2].rule).toBeUndefined();
168
+ expect(mock.create).not.toHaveBeenCalled();
169
+ });
170
+ it.each(['all', 'future'] as const)(
171
+ 'saves the selected %s scope with its original slot and recurrence rule',
172
+ async (scope) => {
173
+ setup({ occurrence });
174
+ await screen.findByLabelText('title');
175
+ fireEvent.keyDown(screen.getByRole('combobox', { name: 'scope' }), {
176
+ key: 'ArrowDown',
177
+ });
178
+ expect(
179
+ screen.queryByText('futureExceptionsReset')
180
+ ).not.toBeInTheDocument();
181
+ fireEvent.click(await screen.findByRole('option', { name: scope }));
182
+ if (scope === 'future') {
183
+ expect(screen.getByText('futureExceptionsReset')).toBeVisible();
184
+ } else {
185
+ expect(
186
+ screen.queryByText('futureExceptionsReset')
187
+ ).not.toBeInTheDocument();
188
+ }
189
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
190
+ await waitFor(() => expect(mock.mutate).toHaveBeenCalledTimes(1));
191
+ expect(mock.mutate.mock.calls[0]?.[2]).toMatchObject({
192
+ scope,
193
+ expectedRevision: 4,
194
+ originalStartLocal: occurrence.originalStartLocal,
195
+ rule: { frequency: 'daily' },
196
+ anchor: {
197
+ startLocal:
198
+ scope === 'all'
199
+ ? series.anchor.startLocal
200
+ : occurrence.originalStartLocal,
201
+ },
202
+ });
203
+ }
204
+ );
205
+ it('omits the tail-reset notice when the first occurrence becomes a whole-series edit', async () => {
206
+ setup({
207
+ occurrence: {
208
+ ...occurrence,
209
+ originalStartLocal: series.anchor.startLocal,
210
+ },
211
+ });
212
+ await screen.findByLabelText('title');
213
+ fireEvent.keyDown(screen.getByRole('combobox', { name: 'scope' }), {
214
+ key: 'ArrowDown',
215
+ });
216
+ fireEvent.click(await screen.findByRole('option', { name: 'future' }));
217
+ expect(screen.queryByText('futureExceptionsReset')).not.toBeInTheDocument();
218
+ });
219
+ it('keeps permission failures visible without closing or pretending a save succeeded', async () => {
220
+ mock.mutate.mockRejectedValue(new InternalApiError('denied', 403));
221
+ const { close } = setup({ occurrence });
222
+ await screen.findByLabelText('title');
223
+ fireEvent.click(screen.getByRole('button', { name: 'save' }));
224
+ await screen.findByRole('alert');
225
+ expect(close).not.toHaveBeenCalled();
226
+ expect(screen.getByLabelText('title')).toHaveValue('Occurrence');
227
+ });
228
+ it('resets draft and retry identity when reopened in a different workspace', async () => {
229
+ const { rerender, client, close } = setup();
230
+ fireEvent.change(screen.getByLabelText('title'), {
231
+ target: { value: 'Private draft' },
232
+ });
233
+ rerender(
234
+ <QueryClientProvider client={client}>
235
+ <NativeRecurrenceDialog
236
+ wsId="other"
237
+ timezone="UTC"
238
+ open
239
+ onOpenChange={close}
240
+ />
241
+ </QueryClientProvider>
242
+ );
243
+ expect(screen.getByLabelText('title')).toHaveValue('');
244
+ });
245
+ });
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { getCalendarProviderSeriesCapabilities } from '@tuturuuu/internal-api/calendar-provider-series';
4
+ import { getNativeCalendarSeries } from '@tuturuuu/internal-api/calendar-series';
5
+ import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
6
+ import { useTranslations } from 'next-intl';
7
+ import { useWorkspaceActor } from '../../../../hooks/use-workspace-visibility';
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ } from '../../dialog';
15
+ import { NativeRecurrenceForm } from './native-recurrence-form';
16
+ export function NativeRecurrenceDialog({
17
+ wsId,
18
+ open,
19
+ onOpenChange,
20
+ occurrence,
21
+ timezone,
22
+ readOnly = false,
23
+ }: {
24
+ wsId: string;
25
+ open: boolean;
26
+ onOpenChange: (open: boolean) => void;
27
+ occurrence?: CalendarEvent;
28
+ timezone?: string;
29
+ readOnly?: boolean;
30
+ }) {
31
+ const actor = useWorkspaceActor();
32
+ const t = useTranslations('calendar.recurrence');
33
+ const series = useQuery({
34
+ queryKey: [
35
+ 'native-calendar-series',
36
+ wsId,
37
+ actor?.actorId,
38
+ occurrence?.seriesId,
39
+ ],
40
+ enabled: open && !!actor && !!occurrence?.seriesId,
41
+ queryFn: async ({ signal }) => {
42
+ actor!.assertActive();
43
+ const value = await getNativeCalendarSeries(wsId, occurrence!.seriesId!, {
44
+ signal,
45
+ });
46
+ actor!.assertActive();
47
+ return value;
48
+ },
49
+ retry: false,
50
+ });
51
+ const capabilities = useQuery({
52
+ queryKey: ['calendar-provider-capabilities', wsId, actor?.actorId],
53
+ enabled: open && !!actor && !readOnly,
54
+ queryFn: async ({ signal }) => {
55
+ actor!.assertActive();
56
+ const value = await getCalendarProviderSeriesCapabilities(wsId, {
57
+ signal,
58
+ });
59
+ actor!.assertActive();
60
+ return value;
61
+ },
62
+ retry: false,
63
+ staleTime: 0,
64
+ });
65
+ if (!open) return null;
66
+ const known = !!actor && (!occurrence || (!!series.data && !series.isError));
67
+ return (
68
+ <Dialog open={open} onOpenChange={onOpenChange}>
69
+ <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
70
+ <DialogHeader>
71
+ <DialogTitle>{t(occurrence ? 'edit' : 'create')}</DialogTitle>
72
+ <DialogDescription>{t('description')}</DialogDescription>
73
+ </DialogHeader>
74
+ {known ? (
75
+ <NativeRecurrenceForm
76
+ key={`${actor!.actorId}:${wsId}:${occurrence?.id ?? 'new'}:${series.data?.revision ?? 0}`}
77
+ wsId={wsId}
78
+ actor={actor!}
79
+ series={series.data}
80
+ occurrence={occurrence}
81
+ timezone={timezone}
82
+ readOnly={readOnly}
83
+ capabilities={capabilities.data}
84
+ onDone={() => onOpenChange(false)}
85
+ onReload={() => void series.refetch()}
86
+ />
87
+ ) : (
88
+ <p role="status">
89
+ {t(series.isPending && actor ? 'loading' : 'unavailable')}
90
+ </p>
91
+ )}
92
+ </DialogContent>
93
+ </Dialog>
94
+ );
95
+ }
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+ import { useTranslations } from 'next-intl';
3
+ import { Checkbox } from '../../checkbox';
4
+ import { Input } from '../../input';
5
+ import { Label } from '../../label';
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectTrigger,
11
+ SelectValue,
12
+ } from '../../select';
13
+ import {
14
+ type RecurrenceDraft,
15
+ recurrenceAllDay,
16
+ } from './native-recurrence-model';
17
+ import { NativeRecurrencePatternFields } from './native-recurrence-pattern-fields';
18
+ export function NativeRecurrenceFields({
19
+ draft,
20
+ setDraft,
21
+ occurrenceOnly,
22
+ disabled,
23
+ nativeStorageHint = true,
24
+ }: {
25
+ draft: RecurrenceDraft;
26
+ setDraft: (draft: RecurrenceDraft) => void;
27
+ occurrenceOnly: boolean;
28
+ disabled: boolean;
29
+ nativeStorageHint?: boolean;
30
+ }) {
31
+ const t = useTranslations('calendar.recurrence');
32
+ const field = (key: keyof RecurrenceDraft, value: unknown) =>
33
+ setDraft({ ...draft, [key]: value });
34
+ return (
35
+ <fieldset disabled={disabled} className="space-y-3">
36
+ <div className="space-y-1">
37
+ <Label htmlFor="series-title">{t('title')}</Label>
38
+ <Input
39
+ id="series-title"
40
+ value={draft.title}
41
+ maxLength={1000}
42
+ onChange={(e) => field('title', e.target.value)}
43
+ required
44
+ />
45
+ </div>
46
+ <div className="grid grid-cols-2 gap-3">
47
+ {(['startLocal', 'endLocal'] as const).map((key) => (
48
+ <div key={key} className="space-y-1">
49
+ <Label htmlFor={key}>
50
+ {t(key === 'startLocal' ? 'start' : 'end')}
51
+ </Label>
52
+ <Input
53
+ id={key}
54
+ type="datetime-local"
55
+ value={draft[key]}
56
+ onChange={(e) => field(key, e.target.value)}
57
+ required
58
+ />
59
+ </div>
60
+ ))}
61
+ </div>
62
+ <div className="space-y-1">
63
+ <Label htmlFor="series-timezone">{t('timezone')}</Label>
64
+ <Input
65
+ id="series-timezone"
66
+ value={draft.timeZone}
67
+ onChange={(e) => field('timeZone', e.target.value)}
68
+ disabled={occurrenceOnly || !!draft.retainedRule}
69
+ required
70
+ />
71
+ </div>
72
+ <label className="flex items-center gap-2">
73
+ <Checkbox
74
+ checked={draft.allDay}
75
+ disabled={occurrenceOnly}
76
+ onCheckedChange={(value) =>
77
+ setDraft(recurrenceAllDay(draft, value === true))
78
+ }
79
+ />
80
+ {t('allDay')}
81
+ </label>
82
+ {draft.retainedRule && !occurrenceOnly && (
83
+ <p className="text-muted-foreground text-xs">{t('advancedPattern')}</p>
84
+ )}
85
+ {!occurrenceOnly && !draft.retainedRule && (
86
+ <>
87
+ <NativeRecurrencePatternFields draft={draft} setDraft={setDraft} />
88
+ <div className="grid grid-cols-2 gap-3">
89
+ <div>
90
+ <Label>{t('ending')}</Label>
91
+ <Select
92
+ value={draft.endType}
93
+ onValueChange={(value) => field('endType', value)}
94
+ >
95
+ <SelectTrigger aria-label={t('ending')}>
96
+ <SelectValue />
97
+ </SelectTrigger>
98
+ <SelectContent>
99
+ {(['never', 'count', 'until'] as const).map((value) => (
100
+ <SelectItem value={value} key={value}>
101
+ {t(value)}
102
+ </SelectItem>
103
+ ))}
104
+ </SelectContent>
105
+ </Select>
106
+ </div>
107
+ {draft.endType !== 'never' && (
108
+ <div>
109
+ <Label htmlFor="series-endvalue">{t(draft.endType)}</Label>
110
+ <Input
111
+ id="series-endvalue"
112
+ type={draft.endType === 'count' ? 'number' : 'date'}
113
+ min={draft.endType === 'count' ? 1 : undefined}
114
+ max={draft.endType === 'count' ? 10000 : undefined}
115
+ value={draft.endValue}
116
+ onChange={(e) => field('endValue', e.target.value)}
117
+ required
118
+ />
119
+ </div>
120
+ )}
121
+ </div>
122
+ </>
123
+ )}
124
+ <div>
125
+ <Label htmlFor="series-location">{t('location')}</Label>
126
+ <Input
127
+ id="series-location"
128
+ value={draft.location}
129
+ onChange={(e) => field('location', e.target.value)}
130
+ />
131
+ </div>
132
+ {nativeStorageHint && (
133
+ <p className="text-muted-foreground text-xs">{t('nativeOnly')}</p>
134
+ )}
135
+ </fieldset>
136
+ );
137
+ }