Keep it in rotation card (Feature 21) #179

4 changed files with 83 additions and 6 deletions
Showing only changes of commit 1cc5b4f6ab - Show all commits
+36 -2
View File
@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clickPath, parsePushPayload } from '../push/notification.ts'
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
import { QueryClientProvider } from '@tanstack/react-query'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
@@ -19,6 +19,7 @@ const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date
afterEach(() => {
vi.unstubAllGlobals()
Reflect.deleteProperty(navigator, 'serviceWorker')
})
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
@@ -53,7 +54,7 @@ function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, decisions }
return { fetchMock, decisions, server }
}
// decided is the server's answer to a decision on review.
@@ -244,4 +245,37 @@ describe('RotationCheck', () => {
await renderHome(path)
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
})
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
// navigator.serviceWorker is where the worker's postMessage to this
// window arrives.
const container = new EventTarget()
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
const { fetchMock, server } = stubApi([])
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
await renderHome('/')
const home = await screen.findByRole('heading', { name: 'Pizza night' })
await waitFor(() => expect(reads()).toBe(1))
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
// Within the cached list's staleTime the Rotation check comes due and
// its notification is tapped while Home is open at /.
server.push(SHAKSHUKA)
const origin = 'https://mealquest.test'
const win = {
url: `${origin}/`,
focused: true,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
} satisfies ClickWindow
await act(() =>
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
)
expect(win.navigate).not.toHaveBeenCalled()
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
expect(reads()).toBe(2)
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
})
})
+18 -3
View File
@@ -1,7 +1,9 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { photoId } from '../dinners/api.ts'
import { isNotificationOpened } from '../push/notification.ts'
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
@@ -43,16 +45,29 @@ export function parseRotationReviews(where: string, body: unknown): RotationRevi
// useRotationReviews is the pending Rotation checks (GET
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
// reads them afresh, so Home opened from a rotation_review notification
// shows the check it is about.
// reads them afresh, and so does a notification tap that focuses Home
// already open (the service worker's NotificationOpened), so Home opened
// from a rotation_review notification shows the check it is about.
export function useRotationReviews() {
const { apiFetch } = useApi()
return useQuery({
const query = useQuery({
queryKey: ROTATION_REVIEWS_KEY,
queryFn: async () =>
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
refetchOnMount: 'always',
})
const { refetch } = query
useEffect(() => {
// Without a service worker there are no notifications to tap.
if (!('serviceWorker' in navigator)) return
const container = navigator.serviceWorker
const onMessage = (event: MessageEvent<unknown>) => {
if (isNotificationOpened(event.data)) void refetch()
}
container.addEventListener('message', onMessage)
return () => container.removeEventListener('message', onMessage)
}, [refetch])
return query
}
// useDecideRotationReview answers "Keep it in rotation?" for a pending
+14
View File
@@ -5,6 +5,7 @@ import {
GENERIC_NOTICE,
appPath,
clickPath,
isNotificationOpened,
openNotification,
parsePushPayload,
planClick,
@@ -178,6 +179,7 @@ describe('openNotification', () => {
focused,
focus: vi.fn(async () => {}),
navigate: vi.fn(async () => {}),
postMessage: vi.fn(),
} satisfies ClickWindow
}
@@ -193,6 +195,18 @@ describe('openNotification', () => {
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
expect(win.postMessage).not.toHaveBeenCalled()
expect(all.openWindow).not.toHaveBeenCalled()
})
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
const win = window('/', true)
const all = clients([win])
await openNotification({ url: '/' }, all, origin)
expect(win.focus).toHaveBeenCalled()
expect(win.navigate).not.toHaveBeenCalled()
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
expect(all.openWindow).not.toHaveBeenCalled()
})
+15 -1
View File
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
export type ClickWindow = AppWindow & {
focus(): Promise<unknown>
navigate(url: string): Promise<unknown>
postMessage(message: NotificationOpened): void
}
// NotificationOpened is the message a tap posts to an open window it focuses
// without navigating, because the window is already at url: nothing remounts
// there, so the screen reads afresh what the notification is about.
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
// isNotificationOpened reports whether a message from the service worker is
// NotificationOpened.
export function isNotificationOpened(message: unknown): message is NotificationOpened {
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
}
export type ClickClients<W extends ClickWindow> = {
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
}
// openNotification carries out a tap on a notification with data: focus
// first, while the tap still allows it, then navigate.
// first, while the tap still allows it, then navigate; a window already at
// the url is told NotificationOpened instead.
export async function openNotification<W extends ClickWindow>(
data: unknown,
clients: ClickClients<W>,
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
}
await plan.window.focus()
if (plan.navigate) await plan.window.navigate(plan.url)
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
}