feat(web): add the bulk remote assignment wizard - #1528
Open
thomasbeaudry wants to merge 30 commits into
Open
Conversation
Web tier for #1500, on top of the all-or-nothing backend. - Source step offers a subject picker (custom identifiers only, inferred by excluding 64-char hash ids), file upload and paste. - CSV, TSV and XLSX. xlsx is dynamically imported so only a user who selects a workbook pays for it. - Timepoints step replaces InstrumentShowcase: add (instrument, expiry) pairs, each applying to every selected subject. - PII never leaves the browser. Ids are derived with generateSubjectHash and raw rows are dropped once resolved; no error message can contain a value from the file. - Group links become a "Group Actions" group whose children are gated independently. Route file added but route-tree.ts is NOT regenerated, per the repo rule that the user does that manually. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he source step Addresses testing feedback: - The source step offered subject selection, upload and paste at once, with no defined precedence if two were used. It is now one mode at a time. - The subject picker showed the group-scoped identifier verbatim. It now strips the scope and truncates to the group's display length, as the datahub does. - The picker listed only subjects with a chosen identifier, which hid most of a group. It now lists every subject, with date of birth and sex alongside, since a hash identifies nobody on sight. Adds isBulkRemoteAssignmentsEnabled, an instance toggle beside the uploader one. With it off the group links stay flat exactly as they are today; with it on they collapse into a Group Actions menu with the bulk page added. Children remain gated independently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Lost while inserting the bulk assignment flag beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three loose buttons read as unrelated actions rather than a choice between them. Uses libui Tabs, as the login page editor does, and adds a short description of what the page does and the all-or-nothing guarantee before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Active tab takes the primary fill, matching the submit button. - The paste tab states the same column requirements as the file tab, since both go through one parser and accept exactly the same columns. - Centre the continue button under the subject table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces ClientTable in the select panel: its formatter returns a string, so it cannot hold a select control, and it offers no per-row styling. The Table primitives allow both, and a selected row carries data-state="selected", reusing the highlight libui already defines rather than a colour invented here. Search matches the values actually rendered, so a hit always corresponds to something visible in the row. Also centres the paste button, matching the one under the table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A checkbox leads each row, as it does in every other selectable table, and the header one selects or clears everything the current search shows. Drops the tick column the previous table needed, which the checkbox now reads from state directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row was flex with a min-width on the select, so the column grew to fit whichever title was chosen and the date field and button shifted with it. Fixed grid columns instead, with minmax(0, ...) so a long title clips rather than widening its column, and truncation in the added list so a row's height and geometry do not depend on the name either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mpletion The steps had grown ad hoc — different heading treatments, some with none, actions wherever they landed. StepLayout gives each the same card, heading level, spacing and footer, with a progress line, so the flow reads as one thing. The results step now lists each subject with its assignment link, and offers copy-per-link, copy-all, and a CSV that carries the url. The link is the point of the export; without it the file said only that something had been created. Drops the start-over button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two downloads used to overwrite each other in the browser's folder. Named from the local clock rather than UTC, so it matches when the user pressed the button, and without colons, which a filename cannot carry on Windows. Lives in the utility module with the rest of the export logic, so the format is pinned by a test rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…back Breadcrumbs replace the slash-separated line: chevron separators, the current step as a filled pill, completed steps clickable, later steps disabled since they depend on work not done yet. Making them clickable required fixing what Back already did wrong. Each step held its own data, so leaving it threw the data away: Back from Instruments discarded every selected subject, and Back from Review discarded every instrument added. Subject selection and the timepoint list now live in the wizard, and the steps are controlled, so moving between steps by either route preserves the batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e wizard The breadcrumbs used a filled primary pill for the current step, which read as a call to action rather than a position marker. They are now libui Buttons like the datahub's, outline throughout with the current step in secondary, so nothing competes with the real primary action in the footer. Container goes from 56rem to 70rem, a quarter wider, which the subject and results tables both benefit from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page was hidden from the nav but still reachable by url with the gateway absent or the instance toggle off, so a bookmark landed on a page whose endpoints are not mounted. It now guards itself like every other gateway-dependent route, and joins the suite that enforces that. Copy all links called navigator.clipboard directly. That is undefined outside a secure context, so on a plain http instance it threw straight past the `void` with nothing shown; on success it said nothing either. It now reports failure and points at the CSV, and confirms a copy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card resized on each step and on each source tab, because a subject table is far taller than a dropzone, so the footer and its primary button jumped around as the user moved through the flow. Every step now reserves the same height, which also stops the instrument step reading as a small card in a wide container. Also drops "or none of them are" from the opening description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The export said `expiresAt 2027-09-10T23:59:59.999Z` and `instrumentId __V2__0c5b91...`, neither of which a person can read. It now carries a plain date and the instrument's name. When the batch came from a file or a paste, each original row is echoed back with the link added: a resolved identifier is a hash, so without their own columns beside it the user cannot tell whose link is whose. Copy all links now copies a subjectId/url table, tab separated so it pastes into a spreadsheet as columns. Note the exported file then holds whatever personal information was uploaded, beside live assignment links. It is built in the browser and never sent anywhere, but it is sensitive once saved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion The mapping rendered as filled badges, which read as buttons rather than as a statement about the file. It is now a labelled two-column table: the column in your file, and what it was read as, with unmapped columns shown as not used. The preview used ClientTable, whose pagination footer showed "1-4/4" with every control disabled, because the preview is capped at four rows. Table primitives instead. Height goes back to sizing with content; only the width stays fixed across steps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…isabled Header rows in the wizard's four tables now carry a muted fill, so the labels read as a header strip rather than as a first row of data. The current breadcrumb is disabled — you are already on that step — and the browser was dimming it to match the steps you cannot reach yet. It keeps the accent colour and full opacity instead, so position is legible without turning it back into a filled button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A muted fill alone still read as another data row. The header now carries the secondary ground with its own foreground colour and weight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
text-destructive resolves to red-600, which sits too close to the tinted ground behind it. The message uses an explicit darker step, with a lighter one in dark mode; the panel's border and ground stay on the token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The message asked for white text, which is only legible on a solid ground, so the panel is filled destructive with destructive-foreground rather than a pale tint with coloured text. Replaces every em dash in the feature with a hyphen, in copy and comments alike. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… counts The instruments step showed a running count in the header corner and review showed the same figure twice, once in the corner and once as a multiplication with no result. Review now reads as the whole equation, in bold, and the corner counts are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app shows a subject as the first characters of its identifier, with any group scope removed, so a file carrying only the full 64 character hash could not be reconciled with what was on screen. The export now carries both: `subject` as displayed, `subjectId` as stored. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tifier Copy all links produced a subject/url pair while the download carried the uploaded personal information beside each link, so what was pasted could not be matched against the user's own file. Both now come from one builder, the clipboard tab separated so it pastes as columns. The export also carried the full 64 character identifier next to the displayed one, which was noise beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The export carried the identifier truncated to the group's display length, which reads well but cannot be pasted back in: a prefix resolves to no record, and for a digest 55 of the 64 characters were gone. It is now written in full, with only the group scope removed. ID mode accepts that same form. A custom identifier is stored scoped to its group but is displayed, exported and discussed without the prefix, so a pasted value is re-scoped before it is sent. A digest and an already scoped value are left alone. Verified against real records: an exported value for both a custom and a digest subject, pasted back through ID mode, resolves to the record it came from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h them A refusal said how many subjects were unavailable but not which, leaving the user to guess. Each issue now lists them, and when the batch came from a file they are named by the row the user supplied: a derived identifier is a hash and would tell them nothing. The list is capped and scrolls, so a refusal naming hundreds does not push the actions off screen. Subject also leads each exported and copied row rather than sitting between the uploaded columns, pinned by a test because perfectionist sorts object literals and only the spread keeps it first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…x filter The picker was built from raw Table primitives, so it had no sorting, no column filters and no paging, and carried a hand-rolled search box. The rest of the app uses libui's DataTable, which is TanStack backed and supports all of it; the reason for avoiding ClientTable, that its formatter returns a string and cannot hold a checkbox, never applied to DataTable. Subject and date of birth sort, sex filters through a control beside the table's own search, as the datahub does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er control Sorting is now driven from each column label with a chevron showing the current direction, rather than from a separate control beside the search box. DataTableHead renders whatever a column supplies and adds no sort affordance of its own, so it lives in the header. Also pins that an admin assigned to a group gets the group actions: an admin holds manage all, so those links are gated on having a current group rather than on the permission level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
The wizard skips preflight and has unresolved validation, workbook handling, mapping, reset, and coverage gaps.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a feature-gated web wizard for creating group-scoped bulk remote assignments.
Changes:
- Adds subject import, mapping, assignment configuration, review, and export UI.
- Adds bulk-assignment hooks, validation utilities, navigation, and route guards.
- Adds an administrator-controlled feature toggle and supporting tests/stories.
File summaries
| File | Description |
|---|---|
packages/schemas/src/setup/setup.ts |
Defines the feature-toggle contract. |
apps/web/src/utils/bulk-assignments.ts |
Parses, validates, resolves, and exports bulk data. |
apps/web/src/utils/__tests__/bulk-assignments.test.ts |
Tests bulk-assignment utilities. |
apps/web/src/routes/_app/group/bulk-remote-assignments.tsx |
Adds the wizard route and guards. |
apps/web/src/routes/_app/admin/settings.tsx |
Adds the administrator toggle. |
apps/web/src/route-tree.ts |
Registers the generated route. |
apps/web/src/hooks/useNavItems.ts |
Adds grouped navigation entries. |
apps/web/src/hooks/useBulkAssignments.ts |
Adds preflight and creation mutations. |
apps/web/src/hooks/__tests__/useNavItems.test.ts |
Tests navigation gating. |
apps/web/src/hooks/__tests__/useBulkAssignments.test.ts |
Tests bulk mutation hooks. |
apps/web/src/components/BulkRemoteAssignmentWizard/types.ts |
Defines wizard state types. |
apps/web/src/components/BulkRemoteAssignmentWizard/TimepointsStep.tsx |
Implements instrument and expiry selection. |
apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx |
Provides shared wizard layout and navigation. |
apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx |
Implements subject selection and data input. |
apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx |
Implements review and refusal handling. |
apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx |
Displays inferred column mappings. |
apps/web/src/components/BulkRemoteAssignmentWizard/index.ts |
Exports the wizard component. |
apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx |
Renders validation and API errors. |
apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx |
Coordinates wizard state and submission. |
apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.stories.tsx |
Adds representative Storybook states. |
apps/web/src/__tests__/gateway-route-guards.test.ts |
Tests gateway and feature-toggle guards. |
apps/api/src/setup/setup.service.ts |
Returns and persists the toggle. |
apps/api/src/setup/dto/update-setup-state.dto.ts |
Exposes the toggle in update requests. |
apps/api/prisma/schema.prisma |
Stores the toggle in setup state. |
Review details
Suppressed comments (2)
apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx:257
- The direct-selection path never enforces
BULK_ASSIGNMENT_MAX_SUBJECTS; that limit is applied only when resolving uploaded/pasted rows. A group with more than 500 subjects can therefore proceed to review and only receive a generic schema 400 on submit. Cap selection/select-all at the shared limit or show a blocking validation message before continuing.
<Button
data-testid="bulk-use-selected-subjects"
disabled={selected.size === 0}
type="button"
onClick={() => onSubjectsSelected([...selected])}
apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx:228
- The results state offers only copy/download actions and has no “start over” path, although #1500 explicitly requires restarting from results. Users must reload or leave the route, and the retained source rows are never explicitly cleared. Add a reset action that clears parsed/source/subject/timepoint/assignment/error state and returns to
SOURCE.
footer={
<React.Fragment>
- Files reviewed: 24/24 changed files
- Comments generated: 12
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+351
to
+352
| const dateOfBirth = new Date(`${rawDateOfBirth}T00:00:00.000Z`); | ||
| if (Number.isNaN(dateOfBirth.getTime())) { |
| timepoints={timepoints} | ||
| onBack={() => goTo('SOURCE')} | ||
| onChange={setTimepoints} | ||
| onConfirm={() => goTo('REVIEW')} |
Comment on lines
+103
to
+105
| <Table.Cell className={field ? undefined : 'text-muted-foreground italic'}> | ||
| {field ? t(FIELD_LABELS[field]) : t({ en: 'Not used', fr: 'Non utilisée' })} | ||
| </Table.Cell> |
| </Button> | ||
| <Button | ||
| data-testid="bulk-submit" | ||
| disabled={isSubmitting || (hasConflict && !allowDuplicates)} |
|
|
||
| const toggleAllShown = () => { | ||
| const next = new Set(selected); | ||
| for (const row of rows) { |
Comment on lines
+19
to
+21
| const setupStateQuery = useSetupStateQuery(); | ||
| const instrumentInfoQuery = useInstrumentInfoQuery(); | ||
| const subjectsQuery = useSubjectsQuery({ params: { groupId: currentGroup?.id } }); |
| export async function parseWorkbook(file: File): Promise<BulkParseResult> { | ||
| assertFileSize(file); | ||
| const { read, utils } = await import('xlsx'); | ||
| const workbook = read(await file.arrayBuffer(), { cellDates: true, type: 'array' }); |
Comment on lines
+280
to
+283
| const rows = utils.sheet_to_json<{ [key: string]: string }>(workbook.Sheets[sheetName]!, { | ||
| defval: '', | ||
| raw: false | ||
| }); |
| activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES, | ||
| branding: branding.success ? branding.data : null, | ||
| defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null, | ||
| isBulkRemoteAssignmentsEnabled: Boolean(savedOptions?.isBulkRemoteAssignmentsEnabled), |
Comment on lines
+155
to
+156
| return ( | ||
| <div className="mx-auto flex max-w-[70rem] flex-col gap-6" data-testid="bulk-remote-assignment-wizard"> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The web half of group-scoped bulk remote assignments (#1500), on top of the API and gateway merged in #1521.
/group/bulk-remote-assignmentswalks three steps: choose subjects, choose instruments and expiries, review. The batch is all-or-nothing, so review is the last point at which anything can be changed.Choosing subjects
One source at a time: pick from the group's subjects, upload a
.csv/.tsv/.xlsx, or paste delimited data. Headers are matched case- and accent-insensitively against English and French aliases, and the mapping is shown back before anything is sent.In PII mode the identifiers are derived in the browser with
generateSubjectHash, so only derived ids ever reach the API.xlsxis imported dynamically, so only a user who picks a workbook pays for it.Results
Each subject's assignment link, with a per-row copy, copy-all, and a CSV. When the batch came from a file, the export echoes back the row the user supplied beside the link, since a derived identifier is a hash and cannot otherwise be matched to a person. The clipboard carries the same rows as the file, from the same builder, so the two cannot drift.
Admin toggle
isBulkRemoteAssignmentsEnabledsits beside the uploader flag. With it off the group links stay exactly where they are today; with it on they collapse into aGroup Actionsmenu with the new page. Children are gated independently, so losing one ability does not hide the others. The route guards itself on both the toggle and the gateway rather than only being hidden from the nav.Worth a reviewer's attention
resolveSubjectIdsre-scopes a pasted identifier. A custom id is stored asGroup$idbut displayed and exported without the prefix, so a user pasting what they see would otherwise match nothing. Digests and already-scoped values are left alone.Follow-up found while testing
StartSessionForm,IdentificationFormandutils/upload.tsall scope an entered identifier unconditionally. Pasting a digest into Start Session producesGroup$<digest>, which matches nothing, and the upload path creates any subject it cannot find — so this silently makes a duplicate. Pre-existing onmainand untouched here; the guard added in this PR is what those three call sites need, lifted intosubject-utils.🤖 Generated with Claude Code