feat: configurable default remote assignment validity period - #1441
Conversation
Admins can set an instance-wide default number of days that new remote assignments stay valid, replacing the hard-coded one-year default. The remote-assignment create dialog seeds its expiry date from this setting, falling back to 365 days when unconfigured. The admin settings page now autosaves every field (no Save buttons) with a SaveStatus indicator: a Features card (uploader), a Settings card (the new default validity), and the browser-local Preferences card. Closes #1433 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t duration - Prevent Enter in the instrument search bar from submitting the bare SearchBar <form> when there are no matches, which reloaded the app and kicked the user back to login (affects administer-instruments and remote-assignment). Enter now always preventDefaults, selecting the highlighted instrument only when one exists. - Remote-assignment create dialog: autofocus the search bar and move the dialog's initial focus to the submit button so Enter submits instead of landing on the date picker. - Add unit tests: instrument-showcase Enter behaviour, default assignment expiry resolution, and the settings duration parser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
flushDurationOnBlur previously read from a React ref that stays stale when Playwright fill() sets the DOM value without firing React onChange (Firefox number-input quirk). Reading event.target.value directly is also more correct — the blur event carries the ground truth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gdevenyi
left a comment
There was a problem hiding this comment.
Review — configurable assignment validity period
The core feature is small, well-scoped and correct: the constant/bound live in schemas, the API change is minimal, and the tests are pointed at the right things (parseDurationDays, getDefaultAssignmentExpiry, and the Enter regression). The InstrumentShowcase fix is a genuine bug fix — libui's SearchBar really does render a bare <form> with no onSubmit, so Enter was triggering a native submit and a full page reload. The comment explaining that is exactly the kind AGENTS.md wants.
The issues are mostly in the autosave rewrite, which is a bigger and riskier change than the feature it's carrying.
Should fix
-
A failed save reports success.
autosavesets'saved'inonSettled, which runs on error too.useUpdateSetupStateMutationhas noonError, so the global axios interceptor fires a red toast — and the user sees a red "request failed" toast next to a green "All changes saved" pill, with the input still showing the value that wasn't stored.onSuccess/onErrorinstead ofonSettled, and revert the input on error. -
The e2e test can't be run twice. It sets the duration to
45and asserts on the "All changes saved" pill. On a second run against the same database the value is already 45, soflushDurationOnBlur'sparsed !== savedDurationRef.currentguard short-circuits, no request is sent, the pill never appears, and the test fails. Even on a clean run the pill self-hides after 2000 ms, so it's a race on a slow runner. TheuniqueIdfixture exists for exactly this. -
The autosave state machine itself is untested. Debounce-on-type, flush-on-blur, flush-on-unmount, revert-on-invalid, and resync-when-the-server-value-changes are five interacting behaviours and the place bugs will actually live — but the three new unit tests cover two pure helpers and one keydown.
parseDurationDaysin isolation doesn't tell you the field saves when you navigate away. -
Route modules now export test-only helpers.
export { parseDurationDays }andexport { getDefaultAssignmentExpiry }exist only so the tests can import them. Moving them to@/utilskeeps route files exporting justRoute, andgetDefaultAssignmentExpiryin particular is domain logic that has nothing to do with routing.
Worth considering
-
MAX_ASSIGNMENT_DURATION_DAYSlives inschemas/setupwhileDEFAULT_ASSIGNMENT_DURATION_DAYSlives inschemas/assignment, andsettings.tsximports from both to render one field. They're two halves of one rule. -
Focusing the dialog's submit button on open is an accessibility regression and makes a stray Enter create an assignment with the default expiry — see the inline note.
-
SaveStatushardcodesbg-white/95/border-slate-200/70rather than theme tokens, so it won't follow a branded instance's palette. -
The spec sets both
test.use({ actingRole: 'ADMIN' })and callsauthenticateAs('ADMIN');actingRoleonly feedsgetPageModel, which this spec doesn't use. One of the two is dead.
Coordination with the other open PRs
This branch collides with two of the others, which GitHub won't show because it compares each against main independently:
| Conflict | Files |
|---|---|
| #1441 × #1442 | apps/web/src/routes/_app/admin/settings.tsx (both rewrite the same Toggle/handleSave block into incompatible autosave layouts) and apps/web/src/components/SaveStatus.tsx (add/add) |
| #1441 × #1439 | apps/web/src/routes/_app/session/remote-assignment.tsx — #1439 deletes the entire libui <Form> this PR modifies, replacing it with a hand-rolled free-text date field. Both PRs also add the identical document.querySelector('[data-testid="instrument-search-bar"] input') autofocus effect. |
Worth noting that this PR's version of autosave is the correct one: it destructures mutate and depends on [mutate] (with a comment explaining why), whereas #1442 depends on [updateSetupStateMutation], which React Query recreates every render. If these get reconciled, keep this one.
| ); | ||
|
|
||
| /** Returns the whole-day count if `raw` is a valid duration, otherwise null. */ | ||
| const parseDurationDays = (raw: string): null | number => { |
There was a problem hiding this comment.
Since this is the validation boundary and is unit-tested as such, Number() is looser than the tests imply: parseDurationDays('0x10') → 16, '1e3' → 1000, '+45' → 45, ' 45 ' → 45. The <input type="number"> makes most of those unreachable in practice, but the function is exported and tested as the rule, so the rule should be the one you mean:
const parseDurationDays = (raw: string): null | number => {
if (!/^\d+$/.test(raw.trim())) return null;
const parsed = Number(raw);
return parsed >= 1 && parsed <= MAX_ASSIGNMENT_DURATION_DAYS ? parsed : null;
};Also worth adding '0x10' / '1e3' to the rejection table so the intent is pinned.
| component: RouteComponent | ||
| }); | ||
|
|
||
| export { parseDurationDays }; |
There was a problem hiding this comment.
A trailing export appended after Route purely so the test can reach it. getDefaultAssignmentExpiry in remote-assignment.tsx does the same.
Both are pure functions with no routing involvement — @/utils/assignment-duration.ts (or similar) would let the tests import them directly, keep route modules exporting only Route, and let settings.tsx and remote-assignment.tsx share the day-count logic instead of each owning half of it.
| await expect(settingsPage.pageHeader).toContainText('Application Settings'); | ||
|
|
||
| await settingsPage.setDefaultAssignmentDuration(45); | ||
| await expect(page.getByText('All changes saved')).toBeVisible(); |
There was a problem hiding this comment.
This assertion makes the spec non-repeatable and racy.
Non-repeatable: the test writes 45 and never restores it. On the next run the stored value is already 45, so flushDurationOnBlur's parsed !== savedDurationRef.current guard skips the mutation entirely — no request, no pill, test fails. That also breaks the "independent, self-seeding tests" property established in #1429.
Racy: SaveStatus reverts to idle 2000 ms after the save settles. Playwright's auto-retry doesn't help if the whole window closes before the first poll on a loaded runner.
Suggest asserting only on the durable outcome, and varying the value:
const days = 30 + (Number(uniqueId.replace(/\D/g, '').slice(-2)) % 60);
await settingsPage.setDefaultAssignmentDuration(days);
await page.reload();
await expect(settingsPage.defaultAssignmentDurationInput).toHaveValue(String(days));The reload already proves the save happened; the pill adds a timing dependency for no extra coverage. (Also: this instance-wide write leaks into any other spec that reads the setting — worth restoring it in an afterEach.)
| <Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}> | ||
| <Dialog.Content> | ||
| <Dialog.Content | ||
| onOpenAutoFocus={(event) => { |
There was a problem hiding this comment.
Two concerns with moving initial focus to the submit button.
Accessibility: a screen-reader or keyboard user opening this dialog now lands on "Submit" rather than the dialog's heading or first control, so they never hear the title or the expiry field unless they shift-tab backwards. Radix's default (focus the first focusable element) is the accessible behaviour.
Accidental submission: combined with InstrumentShowcase's Enter-to-select, one Enter opens the dialog and the next creates a real remote assignment with the default expiry. Assignments are user-visible artefacts with live URLs, so an accidental one isn't free.
If the goal is "Enter submits rather than opening the date picker", focusing the expiry input achieves that too and keeps context. querySelector('button[type="submit"]') is also a DOM reach into libui's internals — a ref on the submit button would survive a libui restructure.
| }, [currentSession]); | ||
|
|
||
| useEffect(() => { | ||
| const input = document.querySelector<HTMLInputElement>('[data-testid="instrument-search-bar"] input'); |
There was a problem hiding this comment.
This works — libui's SearchBar spreads data-testid onto its <form> and renders the <input> inside — but it couples a route component to a test attribute and to libui's internal DOM shape, and fails silently if either changes.
InstrumentShowcase already owns the input; an autoFocus prop on it (or a forwarded ref) makes the intent explicit and type-checked.
Note #1439 adds this exact effect to this exact file as well.
| }); | ||
|
|
||
| /** Upper bound (in days) for a configured assignment validity period; roughly ten years. */ | ||
| const MAX_ASSIGNMENT_DURATION_DAYS = 3650; |
There was a problem hiding this comment.
The bound lives here but the fallback (DEFAULT_ASSIGNMENT_DURATION_DAYS = 365) lives in schemas/assignment, so settings.tsx imports from two modules to render one input and one hover-card. They're the min/max/default of a single rule.
Since the value is persisted on SetupState and validated by $SetupState/$UpdateSetupStateData, both constants belong here; schemas/assignment can re-export if the assignment side wants it under that name.
|
CI is red on this branch, so I have not reviewed it yet. Failing check: The unit tests and lint pass (33/33 turbo tasks). The e2e suite fails on one spec, the one this PR adds: 33 of 34 e2e tests passed; this was the only failure. Please get that spec green and push. I'll pick the review back up then. |
|
Suggested model: Fable 5 Sizing the remaining work on this PR for whoever picks it up, human or agent. It touches |
joshunrau
left a comment
There was a problem hiding this comment.
Requesting changes so this tracks as needing author action.
Blocking: lint-and-test is failing on a genuine error — the e2e spec this PR adds. Details in my earlier comment: settings.spec.ts:7 "should persist the default assignment duration" times out waiting for getByText('All changes saved'). 33 of 34 e2e tests pass; this is the only failure.
No content review has been done on this branch yet — it's gated on CI being green. That starts once the spec passes.
One thing to know before you rebase: this PR overlaps #1442 on six files and #1439 on five, and all three independently add apps/web/src/components/SaveStatus.tsx, which doesn't exist on main. Ownership of the shared files is being settled; hold off restructuring anything in apps/api/src/setup/, packages/schemas/src/setup/setup.ts or SaveStatus.tsx until we confirm.
joshunrau
left a comment
There was a problem hiding this comment.
CI is red on this branch, so I have not reviewed it yet.
The lint-and-test job fails in the end-to-end suite:
[firefox] › src/specs/settings.spec.ts:7:3 › application settings ›
should persist the default assignment duration @smoke
Error: expect(locator).toBeVisible() failed
Error: element(s) not found
> 15 | await expect(page.getByText('All changes saved')).toBeVisible();
The spec this PR adds waits for the text All changes saved after saving the default assignment duration, and that text never appears. Please get the job green — either the save indicator needs to render that text, or the spec needs to assert on whatever the settings page actually shows on a successful save.
Review resumes automatically once CI passes.
Reviewed at commit bf54846.
…r project The spec wrote a fixed `45` and asserted the transient "All changes saved" pill. Both the chromium and firefox projects run @smoke specs against one shared database, so by the time firefox ran the stored value was already 45, the settings page's parsed-vs-saved guard skipped the save, and the pill never rendered — the CI failure. Derive the value from `uniqueId` so each run writes something new, and assert the durable outcome after a reload instead of the self-hiding pill. Awaiting the PATCH response also keeps the reload from aborting the in-flight save, and asserting it is ok surfaces a server error instead of a confusing value mismatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main added a lint rule banning vitest imports under src/routes, which the two tests this branch placed there now trip. `parseDurationDays` and `getDefaultAssignmentExpiry` were only exported so those tests could reach them, so move both to a util instead — the route files export just `Route` again, and the min/max/default of one rule now live together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
joshunrau
left a comment
There was a problem hiding this comment.
Thanks — the feature works and the search-bar Enter fix is a genuinely good catch. Lint is clean and the unit tests pass. A few things before this goes in.
Good news on scope: the autosave redesign of the settings page is approved and stays in this PR, so nothing needs unwinding there. The new Prisma column still needs Joshua's sign-off, which is the one thing outstanding on his side. Everything below is yours — and note that items 2, 3 and 8 are exactly the autosave machinery being kept, so they matter more now, not less:
apps/web/src/utils/assignment-duration.ts:7—parseDurationDayshand-writes the same rule$DefaultAssignmentDurationDaysalready encodes inpackages/schemas/src/setup/setup.ts:183(integer, >= 1, <= max). Export the schema frompackages/schemasand make the parser asafeParseofNumber(raw)over it, so there is one definition rather than two that have to stay in step.apps/web/src/routes/_app/admin/settings.tsx:67— the save indicator flips to "All changes saved" inonSettled, so it reports success when the PATCH fails. Move it toonSuccessand give failure its own state.apps/web/src/routes/_app/admin/settings.tsx:116— the unmount flush callsmutatefrom a component that is unmounting, so React Query's globalthrowOnErrorhas nowhere to throw and a failed save disappears silently while the admin has already navigated away. Pass an explicitonErrorthat surfaces it.apps/web/src/routes/_app/session/remote-assignment.tsx:98— thedocument.querySelector('[data-testid="instrument-search-bar"] input')autofocus effect is a verbatim copy of the one inaccessible-instruments.tsx:20, and it drives focus by test id from outside React. Please giveInstrumentShowcaseanautoFocusprop that focuses its ownSearchBarvia a ref, and use it at both call sites.apps/web/src/routes/_app/session/remote-assignment.tsx:133—onOpenAutoFocusfinds the submit button withquerySelector; use a ref. And could you say why focus should start on Submit? As written a keyboard or screen-reader user opens the dialog one Enter away from creating an assignment, having never reached the "Expires At" field.- There is no end-to-end test for the actual feature.
testing/src/specs/settings.spec.tsproves the settings input persists, but nothing checks that the create-assignment dialog's "Expires At" reflects the configured duration — which is what #1433 asked for. Please add that. testing/src/specs/settings.spec.ts:8— the@smoketag is what makes this spec run in two browsers against one shared instance-wide value, which is the collision theuniqueId-derived duration works around.testing/AGENTS.mdreserves@smokefor critical flows; dropping it here removes the problem and the workaround.apps/web/src/components/SaveStatus.tsx:12—bg-white/95,border-slate-200/70,dark:bg-slate-800/95andtext-green-600are raw colour scales;apps/web/AGENTS.mdasks for semantic libui tokens (bg-popover,border-border).
If you hand this to Claude Code, Fable 5 is the right size — the work spans the Prisma schema, the shared Zod contract, two web routes and the e2e suite at once.
Reviewed at commit d1359af.
Reconciles the SMTP-only refactor with the two mail fixes pushed to the branch and with everything main gained since (#1441, #1444, #1477). The branch had patched the HTTP transport rather than removed it, so the conflicts were resolved toward the maintainer's decision 1: the HTTP client and SigV4 signer go, which makes both patches unnecessary rather than wrong. `isMailEnabled` no longer needs an HTTP branch, and the provider names come back out of `eslint.config.js` since no JSX mentions them any more. Kept from the other side: - #1441's `getDefaultAssignmentExpiry` replaces the hardcoded one-year default in the create-assignment form. - #1444's `$Email`/`$PhoneNumber`/`omittedIfBlank` validation helpers in the user-create form, replacing the old `PHONE_REGEX`. - The deduplicated welcome-email notification in `useCreateUserMutation`, now also opted out of the router error boundary so the call site's own password-error handling is the only path. - #1477's page objects and specs, unioned with the two added here. `SaveStatus.tsx` is restored from main: #1442 has landed and `admin/settings` now consumes it, so decision 4's "pick it back up once #1442 lands" applies. `activeLanguages` and `utils/languages.ts` are still absent from main, so those stay dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #1433
Summary
Admins can now set an instance-wide default number of days that new remote assignments stay valid, replacing the previously hard-coded one-year default. The remote-assignment create dialog seeds its expiry date from this setting, falling back to 365 days when it hasn't been configured.
Changes
packages/schemasassignment: addedDEFAULT_ASSIGNMENT_DURATION_DAYS(365) as the shared fallback.setup: addeddefaultAssignmentDurationDays(positive int, ≤MAX_ASSIGNMENT_DURATION_DAYS= 3650) to$SetupStateand$UpdateSetupStateData;MAX_ASSIGNMENT_DURATION_DAYSis exported as the single source of truth for the bound.apps/apidefaultAssignmentDurationDays Int?column on theSetupStatePrisma model.getState()returns it;UpdateSetupStateDtoaccepts it (persisted via the existingupdateState).apps/webSaveStatusindicator. The duration field autosaves via debounce-on-type, flush-on-blur, and flush-on-unmount so navigating away always persists.SearchBar<form>— previously this reloaded the app and bounced the user to the login page (affected both the administer-instruments and remote-assignment pages).SetupServiceunit test provingupdateStatepersists the field andgetStatereturns it.testing/src/specs/settings.spec.ts) that sets the duration and verifies it persists across reload.Testing
pnpm --filter @opendatacapture/web lint(tsc + eslint) — clean.🤖 Generated with Claude Code