Keep it in rotation card (Feature 21) #179
@@ -1,7 +1,7 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
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 { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
@@ -19,6 +19,7 @@ const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||||
})
|
})
|
||||||
|
|
||||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
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}`)
|
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
return { fetchMock, decisions }
|
return { fetchMock, decisions, server }
|
||||||
}
|
}
|
||||||
|
|
||||||
// decided is the server's answer to a decision on review.
|
// decided is the server's answer to a decision on review.
|
||||||
@@ -244,4 +245,37 @@ describe('RotationCheck', () => {
|
|||||||
await renderHome(path)
|
await renderHome(path)
|
||||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useEffect } from 'react'
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import { photoId } from '../dinners/api.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_PATH = '/api/v1/rotation-reviews'
|
||||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
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
|
// useRotationReviews is the pending Rotation checks (GET
|
||||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
||||||
// reads them afresh, so Home opened from a rotation_review notification
|
// reads them afresh, and so does a notification tap that focuses Home
|
||||||
// shows the check it is about.
|
// already open (the service worker's NotificationOpened), so Home opened
|
||||||
|
// from a rotation_review notification shows the check it is about.
|
||||||
export function useRotationReviews() {
|
export function useRotationReviews() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ROTATION_REVIEWS_KEY,
|
queryKey: ROTATION_REVIEWS_KEY,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
||||||
refetchOnMount: 'always',
|
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
|
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
GENERIC_NOTICE,
|
GENERIC_NOTICE,
|
||||||
appPath,
|
appPath,
|
||||||
clickPath,
|
clickPath,
|
||||||
|
isNotificationOpened,
|
||||||
openNotification,
|
openNotification,
|
||||||
parsePushPayload,
|
parsePushPayload,
|
||||||
planClick,
|
planClick,
|
||||||
@@ -178,6 +179,7 @@ describe('openNotification', () => {
|
|||||||
focused,
|
focused,
|
||||||
focus: vi.fn(async () => {}),
|
focus: vi.fn(async () => {}),
|
||||||
navigate: vi.fn(async () => {}),
|
navigate: vi.fn(async () => {}),
|
||||||
|
postMessage: vi.fn(),
|
||||||
} satisfies ClickWindow
|
} satisfies ClickWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +195,18 @@ describe('openNotification', () => {
|
|||||||
expect(win.focus).toHaveBeenCalled()
|
expect(win.focus).toHaveBeenCalled()
|
||||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
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()
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
|||||||
export type ClickWindow = AppWindow & {
|
export type ClickWindow = AppWindow & {
|
||||||
focus(): Promise<unknown>
|
focus(): Promise<unknown>
|
||||||
navigate(url: string): 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> = {
|
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
|
// 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>(
|
export async function openNotification<W extends ClickWindow>(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
clients: ClickClients<W>,
|
clients: ClickClients<W>,
|
||||||
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
|
|||||||
}
|
}
|
||||||
await plan.window.focus()
|
await plan.window.focus()
|
||||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
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