Keep it in rotation card (Feature 21) #179
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user