Skip to content

Commit 80a7a2e

Browse files
gpascucciclaude
andcommitted
fix(print): stale-context guard, friendly 404, deferred revoke, Clear button
Addresses PR #303 review (SScholefield + Rylan-cgi): - Stale-context guard: handleGenerate captures the dispatch-time isCurrent() and gates the download + success/error banners + busy release on it, so switching mill/year mid-render can't download the old context's PDF or repaint a "Done" banner under the new context. A reset-on-context-change effect releases the lock/banners for the new context. - Friendly 404: a valid mill/year with no rows in the ticked schedules (404 ERR-005) now shows "No data to print for the selected schedules." instead of the verbatim "Schedule not found."; 400/409 keep the verbatim problem+json detail (ERR-002/003/004). - triggerDownload defers URL.revokeObjectURL via setTimeout(…, 0) so Firefox/Safari don't cancel the download by revoking in the click's task; drops the now-unneeded appendChild/remove. Test uses fake timers. - Clear button resets to the S06 default (Schedule Information re-checked, everything else cleared); Schedule Information now defaults checked on load. Tests: added 404-message, stale-context-no-download, and Clear-reset cases; guard mocked directly to control isCurrent/contextMissing. Suite 821 passing, lint clean, build green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef9ceef commit 80a7a2e

5 files changed

Lines changed: 171 additions & 43 deletions

File tree

frontend/src/components/printSchedules/__tests__/PrintSchedules.test.tsx

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,17 @@ import PrintSchedules from '../index'
99

1010
const PRINT_URL = 'http://localhost:3000/api/v1/reports/print'
1111

12-
// Working mill/year comes from the mill/year context; drive it directly so the page under test does
13-
// not depend on the provider or a mill-context fetch.
14-
const ctx = vi.hoisted(() => ({ millId: 514 as number | null, year: 2021 as number | null }))
15-
vi.mock('@/context/millYear/useMillYear', () => ({ default: () => ({ ...ctx }) }))
12+
// Drive the mill/year context guard directly so the page under test does not depend on the provider or
13+
// a mill-context fetch — and so `isCurrent` / `contextMissing` can be controlled per test.
14+
const guard = vi.hoisted(() => ({
15+
millId: 514 as number | null,
16+
year: 2021 as number | null,
17+
contextMissing: false,
18+
isCurrent: () => true,
19+
}))
20+
vi.mock('@/hooks/useScheduleContextGuard', () => ({
21+
useScheduleContextGuard: () => guard,
22+
}))
1623

1724
// The tombstone self-sources the working context (and would fetch /v1/mill-context); stub it — this
1825
// suite is about the selection form + download, not the header.
@@ -27,8 +34,10 @@ vi.mock('@/utils/download', async (importOriginal) => ({
2734
}))
2835

2936
beforeEach(() => {
30-
ctx.millId = 514
31-
ctx.year = 2021
37+
guard.millId = 514
38+
guard.year = 2021
39+
guard.contextMissing = false
40+
guard.isCurrent = () => true
3241
vi.mocked(triggerDownload).mockReset()
3342
})
3443

@@ -37,21 +46,21 @@ afterEach(() => {
3746
})
3847

3948
describe('PrintSchedules', () => {
40-
it('renders schedules/options with the deferred ones disabled ("coming soon"), Generate disabled', () => {
49+
it('renders schedules/options (deferred disabled, Schedule information default-checked), Generate disabled', () => {
4150
render(<PrintSchedules />)
4251
expect(screen.getByRole('checkbox', { name: 'Select all schedules' })).toBeInTheDocument()
43-
// Renderable schedules + content options are enabled.
4452
expect(screen.getByRole('checkbox', { name: 'Schedule 7A' })).toBeEnabled()
4553
expect(screen.getByRole('checkbox', { name: 'Schedule 11' })).toBeEnabled()
46-
expect(screen.getByRole('checkbox', { name: 'Comments' })).toBeEnabled()
54+
// S06 default: Schedule Information is pre-checked.
55+
expect(screen.getByRole('checkbox', { name: 'Schedule information' })).toBeChecked()
4756
// Deferred schedules + the Mill info report are shown but disabled with a coming-soon note.
4857
expect(screen.getByRole('checkbox', { name: 'Schedule 1 (coming soon)' })).toBeDisabled()
49-
expect(screen.getByRole('checkbox', { name: 'Schedule 8 (coming soon)' })).toBeDisabled()
5058
expect(
5159
screen.getByRole('checkbox', { name: 'Mill information report (coming soon)' }),
5260
).toBeDisabled()
53-
// Nothing selected yet → Generate is disabled.
61+
// No schedule selected yet → Generate is disabled; Clear is available.
5462
expect(screen.getByRole('button', { name: /Generate PDF/ })).toBeDisabled()
63+
expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument()
5564
})
5665

5766
it('"Select all schedules" checks only the renderable schedules', async () => {
@@ -60,7 +69,6 @@ describe('PrintSchedules', () => {
6069
for (const label of ['Schedule 5', 'Schedule 7B', 'Schedule 9', 'Schedule 11']) {
6170
expect(screen.getByRole('checkbox', { name: label })).toBeChecked()
6271
}
63-
// A deferred schedule stays disabled and unchecked.
6472
expect(screen.getByRole('checkbox', { name: 'Schedule 1 (coming soon)' })).not.toBeChecked()
6573
})
6674

@@ -82,29 +90,89 @@ describe('PrintSchedules', () => {
8290

8391
await waitFor(() => expect(vi.mocked(triggerDownload)).toHaveBeenCalledTimes(1))
8492
expect(screen.getByText(/generated and downloaded/i)).toBeInTheDocument()
85-
expect(sentBody).toMatchObject({ schedule5: true, printComments: true, allSchedules: false })
93+
expect(sentBody).toMatchObject({
94+
schedule5: true,
95+
printComments: true,
96+
printScheduleInformation: true,
97+
allSchedules: false,
98+
})
8699
expect(vi.mocked(triggerDownload).mock.calls[0][1]).toBe('schedules_print.pdf')
87100
})
88101

89-
it('shows the server error detail when the selection is rejected', async () => {
102+
it('passes through the verbatim server detail for a 400 rejection', async () => {
90103
server.use(
91104
http.post(PRINT_URL, () =>
92105
HttpResponse.json({ detail: 'Select at least one print option.' }, { status: 400 }),
93106
),
94107
)
95108
render(<PrintSchedules />)
96109

110+
// Schedule Information is on by default, so ticking a schedule is enough to enable Generate.
97111
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule 5' }))
98-
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule information' }))
99112
await userEvent.click(screen.getByRole('button', { name: /Generate PDF/ }))
100113

101114
expect(await screen.findByText('Select at least one print option.')).toBeInTheDocument()
102115
expect(vi.mocked(triggerDownload)).not.toHaveBeenCalled()
103116
})
104117

118+
it('shows a friendly message (not "Schedule not found") when the selection has no data (404)', async () => {
119+
server.use(
120+
http.post(PRINT_URL, () =>
121+
HttpResponse.json({ detail: 'Schedule not found.' }, { status: 404 }),
122+
),
123+
)
124+
render(<PrintSchedules />)
125+
126+
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule 5' }))
127+
await userEvent.click(screen.getByRole('button', { name: /Generate PDF/ }))
128+
129+
expect(
130+
await screen.findByText('No data to print for the selected schedules.'),
131+
).toBeInTheDocument()
132+
expect(screen.queryByText('Schedule not found.')).toBeNull()
133+
expect(vi.mocked(triggerDownload)).not.toHaveBeenCalled()
134+
})
135+
136+
it('ignores a stale response when the mill/year context changed mid-render (no download)', async () => {
137+
let handled = false
138+
server.use(
139+
http.post(PRINT_URL, () => {
140+
handled = true
141+
return new HttpResponse(new Blob(['%PDF-1.4 mock']), {
142+
headers: { 'Content-Type': 'application/pdf' },
143+
})
144+
}),
145+
)
146+
// The dispatch-time guard reports the context is no longer current when the response comes back.
147+
guard.isCurrent = () => false
148+
render(<PrintSchedules />)
149+
150+
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule 5' }))
151+
await userEvent.click(screen.getByRole('button', { name: /Generate PDF/ }))
152+
153+
await waitFor(() => expect(handled).toBe(true))
154+
// The stale PDF must not download, and no "done" banner appears under the new context.
155+
expect(vi.mocked(triggerDownload)).not.toHaveBeenCalled()
156+
expect(screen.queryByText(/generated and downloaded/i)).toBeNull()
157+
})
158+
159+
it('Clear resets to the default (schedules cleared, Schedule Information re-checked)', async () => {
160+
render(<PrintSchedules />)
161+
162+
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule 5' }))
163+
await userEvent.click(screen.getByRole('checkbox', { name: 'Comments' }))
164+
await userEvent.click(screen.getByRole('checkbox', { name: 'Schedule information' })) // uncheck default
165+
expect(screen.getByRole('checkbox', { name: 'Schedule information' })).not.toBeChecked()
166+
167+
await userEvent.click(screen.getByRole('button', { name: 'Clear' }))
168+
169+
expect(screen.getByRole('checkbox', { name: 'Schedule 5' })).not.toBeChecked()
170+
expect(screen.getByRole('checkbox', { name: 'Comments' })).not.toBeChecked()
171+
expect(screen.getByRole('checkbox', { name: 'Schedule information' })).toBeChecked()
172+
})
173+
105174
it('gates on a missing mill/year context instead of showing the form', () => {
106-
ctx.millId = null
107-
ctx.year = null
175+
guard.contextMissing = true
108176
render(<PrintSchedules />)
109177
expect(screen.getByText('Select a mill and reporting year')).toBeInTheDocument()
110178
expect(screen.queryByRole('button', { name: /Generate PDF/ })).toBeNull()
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
@use '@carbon/react/scss/spacing' as *;
22

3-
// Print Schedules selection page: space the two checkbox groups and the action button apart.
3+
// Print Schedules selection page: space the two checkbox groups and the action buttons apart.
44
.print-schedules {
55
&__group {
66
margin-block-end: $spacing-07;
77
}
88

9-
.#{'cds'}--btn {
9+
// Generate + Clear sit on one row with a small gap.
10+
&__actions {
11+
display: flex;
12+
gap: $spacing-03;
1013
margin-block-start: $spacing-03;
1114
}
1215
}

frontend/src/components/printSchedules/index.tsx

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FC } from 'react'
2-
import { useState } from 'react'
2+
import { useEffect, useState } from 'react'
33
import { Button, Checkbox, Column, FormGroup, Grid, InlineNotification } from '@carbon/react'
44
import { Printer } from '@carbon/icons-react'
55
import apiService from '@/service/api-service'
@@ -67,23 +67,43 @@ const comingSoon = (label: string) => `${label} (coming soon)`
6767
const noneSelected = <T extends string>(keys: readonly { key: T }[]): Record<T, boolean> =>
6868
Object.fromEntries(keys.map((k) => [k.key, false])) as Record<T, boolean>
6969

70+
// The S06 default selection: Schedule Information pre-checked, everything else cleared. Used on first
71+
// load and by the Clear button.
72+
const defaultOptions = (): Record<OptionFlag, boolean> => ({
73+
printScheduleInformation: true,
74+
printComments: false,
75+
printMillInformationReport: false,
76+
})
77+
7078
/**
7179
* Print Schedules selection page (Epic 20.3). Mirrors the legacy PrintSchedulesMB screen: pick any of
7280
* the twelve schedules (+ "select all") and the print options, then download the combined bookmarked
7381
* PDF the backend assembles at {@code POST /api/v1/reports/print} for the working mill/year. Printing
7482
* is read-only for every role (BR-01); the server is authoritative for selection validation.
7583
*/
7684
const PrintSchedules: FC = () => {
77-
const { millId, year, contextMissing } = useScheduleContextGuard()
85+
const { millId, year, contextMissing, isCurrent } = useScheduleContextGuard()
7886

7987
const [schedules, setSchedules] = useState<Record<ScheduleFlag, boolean>>(() =>
8088
noneSelected(SCHEDULES),
8189
)
82-
const [options, setOptions] = useState<Record<OptionFlag, boolean>>(() => noneSelected(OPTIONS))
90+
const [options, setOptions] = useState<Record<OptionFlag, boolean>>(defaultOptions)
8391
const [busy, setBusy] = useState(false)
8492
const [message, setMessage] = useState<string | null>(null)
8593
const [error, setError] = useState<string | null>(null)
8694

95+
useEffect(() => {
96+
// On a mill/year change, drop the in-flight lock + banners from the previous context, so a context
97+
// switch can't leave a stale "Done"/error banner or a stuck Generate button; a late response is
98+
// separately ignored via isCurrent() (in handleGenerate). The selection itself is intentionally kept.
99+
// Deliberate reset-on-context-change — the synchronous setState here is the point.
100+
/* eslint-disable @eslint-react/set-state-in-effect */
101+
setBusy(false)
102+
setMessage(null)
103+
setError(null)
104+
/* eslint-enable @eslint-react/set-state-in-effect */
105+
}, [millId, year])
106+
87107
// "All"/enable guards consider only the renderable schedules and available options — the disabled
88108
// ones can never be selected, so they must not gate (or be swept into) a generate.
89109
const allSelected = RENDERABLE_SCHEDULES.every((s) => schedules[s.key])
@@ -102,6 +122,10 @@ const PrintSchedules: FC = () => {
102122
}
103123

104124
async function handleGenerate() {
125+
// Capture the dispatch-time context guard: if the user switches mill/year while Jasper renders the
126+
// sections, the late response must NOT download or repaint under the new context (the same
127+
// stale-response guard the schedule pages apply to their writes).
128+
const dispatchedCurrent = isCurrent
105129
setBusy(true)
106130
setError(null)
107131
setMessage(null)
@@ -115,16 +139,40 @@ const PrintSchedules: FC = () => {
115139
.post(`${PRINT_PATH}?millId=${String(millId)}&year=${String(year)}`, body, {
116140
responseType: 'blob',
117141
})
142+
if (!dispatchedCurrent()) {
143+
return
144+
}
118145
triggerDownload(response.data as Blob, PDF_FILENAME)
119146
setMessage('Your Print Schedules PDF has been generated and downloaded.')
120147
} catch (err: unknown) {
121-
// A 400/404/409 problem+json arrives as a Blob under responseType:'blob' — parse it for `detail`.
122-
setError((await extractBlobDetail(err)) ?? 'Unable to generate the PDF. Please try again.')
148+
if (!dispatchedCurrent()) {
149+
return
150+
}
151+
// With selection validation client-gated, the real-world failure is a valid mill/year that simply
152+
// has no rows in the ticked schedules → 404 ERR-005. Verbatim "Schedule not found." reads wrong for
153+
// a print, so special-case it; keep the verbatim problem+json detail for 400/409 (ERR-002/003/004),
154+
// where the legacy-verbatim-text rule actually applies. (Blob error body → extractBlobDetail.)
155+
const status = (err as { response?: { status?: number } })?.response?.status
156+
if (status === 404) {
157+
setError('No data to print for the selected schedules.')
158+
} else {
159+
setError((await extractBlobDetail(err)) ?? 'Unable to generate the PDF. Please try again.')
160+
}
123161
} finally {
124-
setBusy(false)
162+
if (dispatchedCurrent()) {
163+
setBusy(false)
164+
}
125165
}
126166
}
127167

168+
function handleClear() {
169+
// Reset to the S06 default: Schedule Information re-checked, all schedules and other options cleared.
170+
setSchedules(noneSelected(SCHEDULES))
171+
setOptions(defaultOptions())
172+
setMessage(null)
173+
setError(null)
174+
}
175+
128176
return (
129177
<div className="app-page">
130178
<ScheduleTombstone title="Print Schedules" />
@@ -195,13 +243,18 @@ const PrintSchedules: FC = () => {
195243
))}
196244
</FormGroup>
197245

198-
<Button
199-
renderIcon={Printer}
200-
disabled={!canGenerate}
201-
onClick={() => void handleGenerate()}
202-
>
203-
{busy ? 'Generating…' : 'Generate PDF'}
204-
</Button>
246+
<div className="print-schedules__actions">
247+
<Button
248+
renderIcon={Printer}
249+
disabled={!canGenerate}
250+
onClick={() => void handleGenerate()}
251+
>
252+
{busy ? 'Generating…' : 'Generate PDF'}
253+
</Button>
254+
<Button kind="secondary" disabled={busy} onClick={handleClear}>
255+
Clear
256+
</Button>
257+
</div>
205258
</>
206259
)}
207260
</Column>

frontend/src/utils/download.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ describe('triggerDownload', () => {
77
vi.unstubAllGlobals()
88
})
99

10-
it('creates an object URL, clicks a download anchor, and revokes the URL', () => {
10+
it('creates an object URL, clicks a download anchor, and revokes the URL only after the click task', () => {
11+
vi.useFakeTimers()
1112
const createObjectURL = vi.fn(() => 'blob:mock-url')
1213
const revokeObjectURL = vi.fn()
1314
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
@@ -17,7 +18,12 @@ describe('triggerDownload', () => {
1718

1819
expect(createObjectURL).toHaveBeenCalledTimes(1)
1920
expect(click).toHaveBeenCalledTimes(1)
21+
// Revocation is deferred (revoking in the click's task cancels the download in Firefox/Safari).
22+
expect(revokeObjectURL).not.toHaveBeenCalled()
23+
24+
vi.runAllTimers()
2025
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
26+
vi.useRealTimers()
2127
})
2228
})
2329

frontend/src/utils/download.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,14 @@ import { extractDetail } from '@/utils/error'
77
*/
88
export function triggerDownload(blob: Blob, filename: string): void {
99
const url = URL.createObjectURL(blob)
10-
try {
11-
const anchor = document.createElement('a')
12-
anchor.href = url
13-
anchor.download = filename
14-
document.body.appendChild(anchor)
15-
anchor.click()
16-
anchor.remove()
17-
} finally {
18-
URL.revokeObjectURL(url)
19-
}
10+
const anchor = document.createElement('a')
11+
anchor.href = url
12+
anchor.download = filename
13+
anchor.click()
14+
// Defer revocation: revoking synchronously in the same task as the synthetic <a download> click can
15+
// cancel the download in Firefox/Safari (Chrome tolerates it). Let the browser start reading the blob
16+
// first. A detached anchor needs no appendChild/remove.
17+
setTimeout(() => URL.revokeObjectURL(url), 0)
2018
}
2119

2220
/**

0 commit comments

Comments
 (0)