Skip to content

Commit 4731ad6

Browse files
authored
Merge branch 'main' into chore/checkstyle-validate-nonblocking
2 parents 7c0f1c1 + c7159e6 commit 4731ad6

25 files changed

Lines changed: 1270 additions & 666 deletions

File tree

frontend/Caddyfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"
4242
X-Content-Type-Options "nosniff"
4343
Strict-Transport-Security "max-age=31536000"
44+
# connect-src must include the regional Cognito IdP host (cognito-idp.<region>.amazonaws.com):
45+
# Amplify calls it for SILENT TOKEN REFRESH. Without it the browser blocks the refresh, the
46+
# access/id token expires at its TTL, the next request 401s and the user is signed out
47+
# mid-session. The Hosted UI domain below only covers the OAuth login redirect, not refresh.
4448
Content-Security-Policy "default-src 'self' https://*.gov.bc.ca;
4549
script-src 'self' https://*.gov.bc.ca;
4650
style-src 'self' https://fonts.googleapis.com https://use.fontawesome.com 'unsafe-inline';
@@ -49,7 +53,7 @@
4953
frame-ancestors 'self';
5054
form-action 'self';
5155
block-all-mixed-content;
52-
connect-src 'self' https://*.gov.bc.ca wss://*.gov.bc.ca https://{$COGNITO_DOMAIN:lza-prod-fam-user-pool-domain.auth.ca-central-1.amazoncognito.com};"
56+
connect-src 'self' https://*.gov.bc.ca wss://*.gov.bc.ca https://{$COGNITO_DOMAIN:lza-prod-fam-user-pool-domain.auth.ca-central-1.amazoncognito.com} https://cognito-idp.{$COGNITO_REGION:ca-central-1}.amazonaws.com;"
5357
Referrer-Policy "same-origin"
5458
Permissions-Policy "fullscreen=(self), camera=(), microphone=()"
5559
Cross-Origin-Resource-Policy "same-origin"

frontend/src/components/core/ScheduleActions/index.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ type ScheduleActionsProps = {
66
className: string
77
editable: boolean
88
saving: boolean
9-
checking: boolean
109
onSave: () => void
1110
onCheckStatus: () => void
1211
onDelete: () => void
@@ -28,7 +27,6 @@ const ScheduleActions: FC<ScheduleActionsProps> = ({
2827
className,
2928
editable,
3029
saving,
31-
checking,
3230
onSave,
3331
onCheckStatus,
3432
onDelete,
@@ -38,12 +36,7 @@ const ScheduleActions: FC<ScheduleActionsProps> = ({
3836
<Button kind="primary" size="md" disabled={!editable || saving} onClick={onSave}>
3937
Save
4038
</Button>
41-
<Button
42-
kind="tertiary"
43-
size="md"
44-
disabled={!editable || saving || checking}
45-
onClick={onCheckStatus}
46-
>
39+
<Button kind="tertiary" size="md" disabled={!editable || saving} onClick={onCheckStatus}>
4740
Check Status
4841
</Button>
4942
{showDelete && (

frontend/src/components/schedule1/__tests__/Schedule1.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ vi.mock('@tanstack/react-router', () => ({
1616

1717
import Schedule1 from '@/components/schedule1'
1818
import MillYearProvider from '@/context/millYear/MillYearProvider'
19+
import useMillYear from '@/context/millYear/useMillYear'
20+
21+
// Drives a mid-save mill/year change so the shared run() stale-response guard can be exercised
22+
// (Story 29.6). Module-level so it is not re-created per render (an @eslint-react rule forbids nested
23+
// component definitions).
24+
const StaleRaceHarness = () => {
25+
const { setContext } = useMillYear()
26+
return (
27+
<>
28+
<button type="button" onClick={() => setContext(999, 2020)}>
29+
change
30+
</button>
31+
<Schedule1 />
32+
</>
33+
)
34+
}
1935

2036
const URL = 'http://localhost:3000/api/v1/schedule1'
2137

@@ -663,3 +679,57 @@ describe('Schedule1 Other Costs navigation (Story 2.5)', () => {
663679
expect(mockNavigate).toHaveBeenCalledWith({ to: '/schedule-1/other-costs' })
664680
})
665681
})
682+
683+
describe('Schedule1 stale-response guard (Story 29.6)', () => {
684+
test('a mill/year change mid-save does not apply the stale response (AC1)', async () => {
685+
// The PUT is gated on an explicit release, not a wall-clock delay, so the "stale response settles
686+
// after the context change" ordering holds under any CI load. Routing save through the shared
687+
// useScheduleMutations run() gives Schedule 1 the isCurrent() guard it previously lacked.
688+
let releasePut = () => {}
689+
const putGate = new Promise<void>((resolve) => {
690+
releasePut = resolve
691+
})
692+
server.use(
693+
http.get(URL, ({ request }) =>
694+
request.url.includes('millId=999')
695+
? HttpResponse.json({
696+
...schedule1Doc,
697+
millId: 999,
698+
year: 2020,
699+
editable: false,
700+
comments: 'Context 999/2020 loaded',
701+
})
702+
: HttpResponse.json(schedule1Doc),
703+
),
704+
http.put(URL, async () => {
705+
await putGate
706+
return HttpResponse.json({
707+
...schedule1Doc,
708+
message: { key: 'dataSavedSuccesfullyInfoMsg', text: 'Data saved successfully' },
709+
})
710+
}),
711+
)
712+
713+
render(
714+
<MillYearProvider initial={{ millId: 514, year: 2021 }}>
715+
<StaleRaceHarness />
716+
</MillYearProvider>,
717+
)
718+
const user = userEvent.setup()
719+
720+
// Editable 514 loaded → dispatch the save (PUT now in flight) → switch mill/year before it settles.
721+
await screen.findByLabelText('Standing Tree to Loaded Truck cost')
722+
await user.click(screen.getAllByRole('button', { name: /^save$/i })[0])
723+
await user.click(screen.getByRole('button', { name: /change/i }))
724+
725+
// The new context's document has rendered (read-only 999/2020).
726+
expect(await screen.findByText('Context 999/2020 loaded')).toBeInTheDocument()
727+
728+
// Release the stale PUT, let its chain settle, then confirm nothing from it landed on 999/2020.
729+
releasePut()
730+
await waitFor(() => {
731+
expect(screen.queryByText('Data saved successfully')).not.toBeInTheDocument()
732+
expect(screen.queryByLabelText('Standing Tree to Loaded Truck cost')).not.toBeInTheDocument()
733+
})
734+
})
735+
})

frontend/src/components/schedule1/index.tsx

Lines changed: 48 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type Schedule1Response from '@/interfaces/Schedule1Response'
33
import type { LineItem } from '@/interfaces/Schedule1Response'
44
import type Schedule1Request from '@/interfaces/Schedule1Request'
55
import type CheckStatusResponse from '@/interfaces/CheckStatusResponse'
6-
import { useCallback, useState } from 'react'
6+
import { useState } from 'react'
77
import { useNavigate } from '@tanstack/react-router'
88
import {
99
Button,
@@ -21,11 +21,10 @@ import {
2121
TextArea,
2222
TextInput,
2323
} from '@carbon/react'
24-
import apiService from '@/service/api-service'
2524
import { WRITABLE_LINE_ITEM_CODES } from '@/interfaces/Schedule1Request'
26-
import useMillYear from '@/context/millYear/useMillYear'
25+
import { useScheduleContextGuard } from '@/hooks/useScheduleContextGuard'
2726
import { useScheduleDocument } from '@/hooks/useScheduleDocument'
28-
import { extractDetail } from '@/utils/error'
27+
import { useScheduleMutations } from '@/hooks/useScheduleMutations'
2928
import { fmtCurrency, fmtNumber, groupInput, numStrGroup, toNum } from '@/utils/number'
3029
import LoadingScreen from '@/components/core/LoadingScreen'
3130
import NotificationColumn from '@/components/core/NotificationColumn'
@@ -123,25 +122,30 @@ function buildRequest(doc: Schedule1Response, form: FieldValues): Schedule1Reque
123122
}
124123

125124
const Schedule1: FC = () => {
126-
const { millId, year } = useMillYear()
125+
const { millId, year, contextMissing, isCurrent } = useScheduleContextGuard()
127126
const navigate = useNavigate()
128-
const contextMissing = millId === null || year === null
129127

130-
const [saving, setSaving] = useState(false)
131-
const [saveMessage, setSaveMessage] = useState<string | null>(null)
132-
const [saveError, setSaveError] = useState<string | null>(null)
128+
// Save/delete/check-status all run through the shared hook's guarded run() (Story 29.6): a stale
129+
// in-flight write can no longer repaint a newly-switched mill/year. `saving` is the single in-flight
130+
// lock for every write (it also gates Check Status), replacing the old separate save/checking locks.
131+
const {
132+
saving,
133+
message: saveMessage,
134+
actionError: saveError,
135+
checkResult,
136+
setMessage: setSaveMessage,
137+
setActionError: setSaveError,
138+
setCheckResult,
139+
clearBanners,
140+
resetBanners,
141+
save,
142+
remove,
143+
checkStatus,
144+
} = useScheduleMutations<CheckStatusResponse>({ path: '/v1/schedule1', millId, year, isCurrent })
145+
133146
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
134147
const [confirmNavOpen, setConfirmNavOpen] = useState(false)
135148
const [otherCostsBlockedOpen, setOtherCostsBlockedOpen] = useState(false)
136-
const [checking, setChecking] = useState(false)
137-
const [checkResult, setCheckResult] = useState<CheckStatusResponse | null>(null)
138-
139-
// Clear the save/check notifications whenever a fresh document loads (mill/year change).
140-
const resetMessages = useCallback(() => {
141-
setSaveMessage(null)
142-
setSaveError(null)
143-
setCheckResult(null)
144-
}, [])
145149

146150
const { data, setData, form, setForm, setField, errorDetail, isLoading } =
147151
useScheduleDocument<Schedule1Response>({
@@ -151,7 +155,7 @@ const Schedule1: FC = () => {
151155
contextMissing,
152156
seedForm,
153157
mapLoadError: mapLoadErrorDetail,
154-
onReset: resetMessages,
158+
onReset: resetBanners,
155159
})
156160

157161
// Re-group a numeric field's value on blur, so it reads like the plain-text cells beside it. Only
@@ -177,44 +181,30 @@ const Schedule1: FC = () => {
177181
setSaveError('Please correct the highlighted fields before saving.')
178182
return
179183
}
180-
setSaving(true)
181-
setSaveMessage(null)
182-
setSaveError(null)
183-
setCheckResult(null) // a prior Check Status result is stale once the data changes
184-
apiService
185-
.getAxiosInstance()
186-
.put<Schedule1Response>(
187-
`/v1/schedule1?millId=${millId}&year=${year}`,
188-
buildRequest(data, form),
189-
)
190-
.then((response) => {
191-
setData(response.data)
192-
setForm(seedForm(response.data))
184+
clearBanners() // drop any prior banners incl. a now-stale Check Status result
185+
save<Schedule1Response>(buildRequest(data, form), {
186+
fallback: 'Schedule could not be saved.',
187+
onSuccess: (doc) => {
188+
setData(doc)
189+
setForm(seedForm(doc))
193190
// SUC-001 verbatim from the API message field (AD-8), never hardcoded.
194-
setSaveMessage(response.data.message?.text ?? null)
195-
})
196-
.catch((error: unknown) => {
197-
// Keep the entered values (S23/S24); surface the API's verbatim ProblemDetail.detail.
198-
setSaveError(extractDetail(error) || 'Schedule could not be saved.')
199-
})
200-
.finally(() => setSaving(false))
191+
setSaveMessage(doc.message?.text ?? null)
192+
},
193+
})
201194
}
202195

203196
const handleDelete = () => {
204197
if (saving) {
205198
return
206199
}
207200
setConfirmDeleteOpen(false)
208-
setSaving(true)
209-
setSaveMessage(null)
210-
setSaveError(null)
211-
setCheckResult(null) // the deleted schedule's check result is stale
212-
apiService
213-
.getAxiosInstance()
214-
.delete<{ message?: { text?: string } }>(`/v1/schedule1?millId=${millId}&year=${year}`)
215-
.then((response) => {
216-
// Delete removed the summary; a re-GET would 404, so reset to an empty schedule in place
217-
// (no re-fetch) and show SUC-002 from the API message.
201+
clearBanners() // the deleted schedule's check result / save banner are stale
202+
remove<{ message?: { text?: string } }>({
203+
fallback: 'Unable to delete Schedule 1.',
204+
// Delete removed the summary; a re-GET would 404, so reset to an empty schedule in place (no
205+
// re-fetch) and show SUC-002 from the API message. This per-page empty-state lives at the call
206+
// site (Story 29.6): single-doc Schedules 1/3 reset in place; list pages re-seed from a reload.
207+
onSuccess: (resp) => {
218208
setData((prev) =>
219209
prev
220210
? {
@@ -236,32 +226,20 @@ const Schedule1: FC = () => {
236226
: prev,
237227
)
238228
setForm({})
239-
setSaveMessage(response.data?.message?.text ?? null)
240-
})
241-
.catch((error: unknown) => {
242-
setSaveError(extractDetail(error) || 'Unable to delete Schedule 1.')
243-
})
244-
.finally(() => setSaving(false))
229+
setSaveMessage(resp?.message?.text ?? null)
230+
},
231+
})
245232
}
246233

247234
const handleCheckStatus = () => {
248-
if (!data || checking || saving) {
235+
if (!data || saving) {
249236
return
250237
}
251-
setChecking(true)
252-
setCheckResult(null)
253-
setSaveError(null)
254-
setSaveMessage(null) // don't leave a stale Save success banner beside a new check result
255-
apiService
256-
.getAxiosInstance()
257-
.post<CheckStatusResponse>(`/v1/schedule1/check-status?millId=${millId}&year=${year}`)
258-
.then((response) => {
259-
setCheckResult(response.data)
260-
})
261-
.catch((error: unknown) => {
262-
setSaveError(extractDetail(error) || 'Unable to check status.')
263-
})
264-
.finally(() => setChecking(false))
238+
clearBanners() // don't leave a stale Save success banner beside a new check result
239+
checkStatus<CheckStatusResponse>({
240+
fallback: 'Unable to check status.',
241+
onSuccess: setCheckResult,
242+
})
265243
}
266244

267245
const handleOtherCosts = () => {
@@ -541,7 +519,6 @@ const Schedule1: FC = () => {
541519
className="schedule-1__actions"
542520
editable={editable}
543521
saving={saving}
544-
checking={checking}
545522
onSave={handleSave}
546523
onCheckStatus={handleCheckStatus}
547524
onDelete={() => setConfirmDeleteOpen(true)}

frontend/src/components/schedule11/index.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import apiService from '@/service/api-service'
3030
import useMillYear from '@/context/millYear/useMillYear'
3131
import { useScheduleDocument } from '@/hooks/useScheduleDocument'
3232
import { extractDetail } from '@/utils/error'
33-
import { numStr } from '@/utils/number'
33+
import { numStr, numStrFixed } from '@/utils/number'
3434
import LoadingScreen from '@/components/core/LoadingScreen'
3535
import PageState from '@/components/core/PageState'
3636
import ScheduleTombstone from '@/components/core/ScheduleTombstone'
@@ -53,17 +53,12 @@ const BEC_CATALOGUE_PATH = '/v1/schedule11/biogeoclimatic-catalogue'
5353
const BEC_DEBOUNCE_MS = 250
5454

5555
// Legacy display masks (AD-5 no recompute — the values are server-computed, this only formats them).
56-
// Null renders BLANK, never "0": a null total means "no contributors", which is meaningful.
57-
const mask = (value: number | null | undefined, minFrac: number, maxFrac: number): string =>
58-
value === null || value === undefined
59-
? ''
60-
: value.toLocaleString('en-US', {
61-
minimumFractionDigits: minFrac,
62-
maximumFractionDigits: maxFrac,
63-
})
64-
const money = (value: number | null | undefined): string => mask(value, 0, 0) // #,###,##0
65-
const area = (value: number | null | undefined): string => mask(value, 1, 1) // #,###,##0.0
66-
const ratio = (value: number | null | undefined): string => mask(value, 2, 2) // #,###,##0.00
56+
// Delegate to the shared en-CA numStrFixed (Story 29.8): one locale app-wide, no local toLocaleString
57+
// copy. numStrFixed keeps the same contract — fixed decimals, and BLANK (never "0") on null, since a
58+
// null total means "no contributors", which is meaningful.
59+
const money = (value: number | null | undefined): string => numStrFixed(value, 0) // #,###,##0
60+
const area = (value: number | null | undefined): string => numStrFixed(value, 1) // #,###,##0.0
61+
const ratio = (value: number | null | undefined): string => numStrFixed(value, 2) // #,###,##0.00
6762

6863
// Whole-dollar costs: legacy accepted fractional input (ILCRCostConverter BigDecimal parse) and
6964
// Oracle COST NUMBER(15) ROUNDED it on insert, while the modern Integer wire would silently

frontend/src/components/schedule11/validation.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// the authoritative response.
66

77
import type { BiogeoclimaticOption } from '@/interfaces/Schedule11Response'
8+
import { parseDecimalInput } from '@/utils/number'
89

910
const LOCATION_MAX = 30
1011
const NET_AREA = { min: 0, max: 999_999.9 }
@@ -48,22 +49,10 @@ export interface LocationFormValues {
4849
comments: string
4950
}
5051

51-
// Legacy JSF numeric fields (NAR, costs) bind through a US-locale DecimalFormat converter, so the
52-
// accepted syntax is: an optional leading '-', digits with optional comma grouping, and an optional
53-
// '.' fractional part — the same format the page DISPLAYS (the money/area masks in index.tsx). Native
54-
// Number() diverges both ways and must NOT be used here: it rejects grouped input the legacy app
55-
// accepts (`Number('1,000')` -> NaN) and silently accepts JS-only forms the legacy app never allowed
56-
// (`1e2` -> 100, `0x10` -> 16, `Infinity`). Parsing with an explicit format keeps both this advisory
57-
// gate AND the submitted body (index.tsx buildBody) faithful to legacy. Returns the numeric value, or
58-
// null when the string is blank or not a valid decimal in that format.
59-
const DECIMAL_INPUT = /^-?(\d{1,3}(,\d{3})+|\d+)(\.\d+)?$/
60-
export const parseDecimalInput = (raw: string): number | null => {
61-
const trimmed = raw.trim()
62-
if (trimmed === '' || !DECIMAL_INPUT.test(trimmed)) {
63-
return null
64-
}
65-
return Number(trimmed.replace(/,/g, ''))
66-
}
52+
// The strict legacy-format decimal parser lives in @/utils/number (Story 29.8 consolidation — this
53+
// file's identical local copy was removed). Re-exported so callers importing it from this module keep
54+
// working; used below for the advisory NAR/cost gate.
55+
export { parseDecimalInput }
6756

6857
const validateCost = (raw: string): string | undefined => {
6958
const trimmed = raw.trim()

0 commit comments

Comments
 (0)