Skip to content

Commit dde7ce1

Browse files
authored
[TW-7527] fix(@ownmail/app): preserve intent across mail and calendar workflows (#374)
1 parent 5bd131e commit dde7ce1

33 files changed

Lines changed: 3710 additions & 361 deletions

‎.changeset/eleven-rats-raise.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": patch
3+
---
4+
5+
Show event time zones and end dates, and require an explicit choice for repeated daylight-saving times while preserving meeting duration.

‎.changeset/fancy-frogs-start.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": minor
3+
---
4+
5+
Add bulk mail selection with loaded-only scope, reversible actions, partial-failure recovery, and accessible mobile controls.

‎.changeset/jolly-canyons-yell.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": patch
3+
---
4+
5+
Confirm draft discard and empty-subject sending, preserve recovery from failed draft deletion, and make keyboard mail search predictable.

‎.changeset/ready-teeth-slide.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": patch
3+
---
4+
5+
Preserve calendar duration and exact timestamps, protect unsaved event edits across navigation, and hydrate draft resume controls consistently.

‎.changeset/short-donkeys-read.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": patch
3+
---
4+
5+
Keep draft save status accurate during slow requests, show recoverable save failures, and preserve the latest recipient and cleared-field edits.

‎.changeset/tender-books-appear.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ownmail/app": patch
3+
---
4+
5+
Apply the same reversible loaded-conversation bulk actions and selection recovery to mail search results.

‎labs/ownmail/packages/app/src/app/components/MobileTabBar.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
*/
55
import { Link } from '@tanstack/react-router'
66
import { Calendar, Mail, Settings, Users } from 'lucide-react'
7+
import { useMailSelectionActions } from '#features/mail/components/MailSelectionActions'
78
import { GLASS_BAR_BOTTOM_EDGE, GLASS_BAR_CLASS } from '#shared/components/ui/glass'
89
import { cn } from '#shared/lib/utils'
910
import {
@@ -31,14 +32,19 @@ export function MobileTabBar({
3132
active: MobileTab
3233
context?: 'primary' | 'thread'
3334
}) {
35+
const selectionActions = useMailSelectionActions()
3436
return (
3537
<div
3638
data-slot="mobile-bottom-bar"
37-
data-context={context}
39+
data-context={selectionActions ? 'selection' : context}
3840
{...GLASS_BAR_BOTTOM_EDGE}
3941
className={cn('mobile-tab-bar md:hidden', GLASS_BAR_CLASS)}
4042
>
41-
{context === 'primary' ? (
43+
{selectionActions ? (
44+
<div role="toolbar" aria-label="Selected mail actions" className="mobile-primary-tabs">
45+
{selectionActions}
46+
</div>
47+
) : context === 'primary' ? (
4248
<nav aria-label="Primary mobile" className="mobile-primary-tabs">
4349
{TABS.map((tab) => {
4450
const Icon = tab.icon
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// @vitest-environment jsdom
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
import {
4+
createBrowserHistory,
5+
createRootRoute,
6+
createRoute,
7+
createRouter,
8+
Link,
9+
Outlet,
10+
RouterProvider,
11+
} from '@tanstack/react-router'
12+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
13+
import userEvent from '@testing-library/user-event'
14+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15+
import { EventModal } from './EventModal.js'
16+
17+
const { createEvent } = vi.hoisted(() => ({ createEvent: vi.fn() }))
18+
vi.mock('#features/calendar/server/calendar-fns', () => ({
19+
createEvent,
20+
deleteEvent: vi.fn(),
21+
rsvpEvent: vi.fn(),
22+
updateEvent: vi.fn(),
23+
}))
24+
vi.mock('#server/fns', () => ({ searchContacts: vi.fn().mockResolvedValue([]) }))
25+
26+
beforeAll(() => {
27+
window.scrollTo = vi.fn()
28+
vi.stubGlobal(
29+
'ResizeObserver',
30+
class {
31+
observe() {}
32+
unobserve() {}
33+
disconnect() {}
34+
},
35+
)
36+
Element.prototype.scrollIntoView = vi.fn()
37+
Element.prototype.hasPointerCapture = vi.fn(() => false)
38+
Element.prototype.setPointerCapture = vi.fn()
39+
Element.prototype.releasePointerCapture = vi.fn()
40+
})
41+
42+
let history: ReturnType<typeof createBrowserHistory>
43+
beforeEach(() => {
44+
window.history.replaceState(null, '', '/calendar')
45+
createEvent.mockReset().mockResolvedValue({ eventId: 'created' })
46+
})
47+
afterEach(() => {
48+
cleanup()
49+
history?.destroy()
50+
})
51+
52+
async function renderCalendar(editing = false) {
53+
const root = createRootRoute({
54+
component: () => (
55+
<>
56+
<Link to="/mail">Go to mail</Link>
57+
<Outlet />
58+
</>
59+
),
60+
})
61+
const calendar = createRoute({
62+
getParentRoute: () => root,
63+
path: '/calendar',
64+
component: () => (
65+
<EventModal
66+
event={
67+
editing
68+
? {
69+
id: 'e1',
70+
title: 'Meeting',
71+
calendar_id: 'cal1',
72+
when: { object: 'timespan', start_time: 1783515600, end_time: 1783519200 },
73+
}
74+
: null
75+
}
76+
startInEdit={editing}
77+
defaultStart={new Date(2026, 6, 8, 9)}
78+
calendarId="cal1"
79+
calendarName="Work"
80+
calendars={[]}
81+
onClose={vi.fn()}
82+
/>
83+
),
84+
})
85+
const mail = createRoute({
86+
getParentRoute: () => root,
87+
path: '/mail',
88+
component: () => <h1>Mail destination</h1>,
89+
})
90+
history = createBrowserHistory()
91+
const router = createRouter({ routeTree: root.addChildren([calendar, mail]), history })
92+
render(
93+
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
94+
<RouterProvider router={router} />
95+
</QueryClientProvider>,
96+
)
97+
await screen.findByLabelText('Title')
98+
return router
99+
}
100+
101+
describe('EventModal navigation protection', () => {
102+
it('allows navigation and reload without prompting for a pristine event', async () => {
103+
const user = userEvent.setup()
104+
await renderCalendar()
105+
const unload = new Event('beforeunload', { cancelable: true })
106+
window.dispatchEvent(unload)
107+
expect(unload.defaultPrevented).toBe(false)
108+
await user.click(screen.getByRole('link', { name: 'Go to mail' }))
109+
await screen.findByRole('heading', { name: 'Mail destination' })
110+
})
111+
112+
it('keeps edited content on cancelled module navigation, then proceeds only on discard', async () => {
113+
const user = userEvent.setup()
114+
await renderCalendar()
115+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Keep meeting' } })
116+
await user.click(screen.getByRole('link', { name: 'Go to mail' }))
117+
await screen.findByRole('dialog', { name: 'Discard event changes?' })
118+
await user.click(screen.getByRole('button', { name: 'Keep editing' }))
119+
expect(screen.getByLabelText('Title')).toHaveValue('Keep meeting')
120+
expect(window.location.pathname).toBe('/calendar')
121+
await user.click(screen.getByRole('link', { name: 'Go to mail' }))
122+
await user.click(await screen.findByRole('button', { name: 'Discard changes' }))
123+
await screen.findByRole('heading', { name: 'Mail destination' })
124+
})
125+
126+
it('requests the browser reload warning only while unsaved changes exist', async () => {
127+
await renderCalendar()
128+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Unsaved meeting' } })
129+
const unload = new Event('beforeunload', { cancelable: true })
130+
window.dispatchEvent(unload)
131+
expect(unload.defaultPrevented).toBe(true)
132+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: '' } })
133+
const cleanUnload = new Event('beforeunload', { cancelable: true })
134+
window.dispatchEvent(cleanUnload)
135+
expect(cleanUnload.defaultPrevented).toBe(false)
136+
})
137+
138+
it('intercepts browser Back and keeps the intended destination for explicit discard', async () => {
139+
const user = userEvent.setup()
140+
const router = await renderCalendar()
141+
await router.navigate({ to: '/mail' })
142+
await screen.findByRole('heading', { name: 'Mail destination' })
143+
await router.navigate({ to: '/calendar' })
144+
await screen.findByRole('heading', { name: 'New event' })
145+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Back protection' } })
146+
history.back()
147+
await screen.findByRole('dialog', { name: 'Discard event changes?' })
148+
await user.keyboard('{Escape}')
149+
await waitFor(() => expect(window.location.pathname).toBe('/calendar'))
150+
expect(screen.getByLabelText('Title')).toHaveValue('Back protection')
151+
history.back()
152+
await user.click(await screen.findByRole('button', { name: 'Discard changes' }))
153+
await screen.findByRole('heading', { name: 'Mail destination' })
154+
})
155+
})
156+
157+
it('protects an existing event edit during navigation and releases its blocker after discard', async () => {
158+
const user = userEvent.setup()
159+
const router = await renderCalendar(true)
160+
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'Changed meeting' } })
161+
// Calling navigation directly also covers browser/command-driven transitions outside the modal.
162+
void router.navigate({ to: '/mail' })
163+
await screen.findByRole('dialog', { name: 'Discard event changes?' })
164+
await user.click(screen.getByRole('button', { name: 'Discard changes' }))
165+
await screen.findByRole('heading', { name: 'Mail destination' })
166+
})
167+
168+
it('keeps the pending event save mounted when a route change is requested', async () => {
169+
const user = userEvent.setup()
170+
createEvent.mockImplementation(() => new Promise(() => {}))
171+
const router = await renderCalendar()
172+
await user.click(screen.getByRole('button', { name: 'Save event' }))
173+
void router.navigate({ to: '/mail' })
174+
await waitFor(() => expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled())
175+
expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled()
176+
expect(screen.queryByRole('dialog', { name: 'Discard event changes?' })).not.toBeInTheDocument()
177+
expect(window.location.pathname).toBe('/calendar')
178+
})

0 commit comments

Comments
 (0)