feat(web): add the bulk remote assignment wizard - #1528
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>
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.
| const dateOfBirth = new Date(`${rawDateOfBirth}T00:00:00.000Z`); | ||
| if (Number.isNaN(dateOfBirth.getTime())) { |
| timepoints={timepoints} | ||
| onBack={() => goTo('SOURCE')} | ||
| onChange={setTimepoints} | ||
| onConfirm={() => goTo('REVIEW')} |
| <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) { |
| 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' }); |
| 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), |
| return ( | ||
| <div className="mx-auto flex max-w-[70rem] flex-col gap-6" data-testid="bulk-remote-assignment-wizard"> |
There was a problem hiding this comment.
yes, test the hook I requested
There was a problem hiding this comment.
Added apps/web/src/hooks/__tests__/useBulkAssignmentWizard.test.ts in 0c41a54: 12 cases covering source switching, back/breadcrumb state retention, the preflight refusal landing on review, transport failure vs refusal, the expiry payload, results, reset, and both clipboard paths.
…izard - Reject dates that Date normalizes silently (e.g. 2001-02-29 → 2001-03-01) - Wire up preflight mutation before entering review step - Make column mapping editable with dropdown selectors - Disable submit for non-conflict issues (SUBJECT/INSTRUMENT_UNAVAILABLE) - Select-all checkbox now operates on filtered rows, not all rows - Add future-date constraint on expiry input with min attribute - Extract assignment query key to single source of truth - Add route loader to prefetch setup state and subjects - Catch xlsx.read failures as BulkParseFailure - Preserve typed Excel date cells and normalize to ISO - Add setup service tests for isBulkRemoteAssignmentsEnabled toggle - Enforce BULK_ASSIGNMENT_MAX_SUBJECTS on direct selection path - Add "Start over" button on results step - Add test for impossible calendar date rejection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
…ances A new instance or a document predating the field now has the toggle on by default, so bulk remote assignments work out of the box. An admin can still disable it explicitly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
With isBulkRemoteAssignmentsEnabled defaulting to true, group nav items are nested inside a collapsible "Group Actions" menu. E2E tests that assert visibility of or click on /group/* nav buttons now expand the group first. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
A one-shot `isVisible()` guard ran before the sidebar rendered, skipping the expand and leaving nested links like the email templates page hidden. Expand through one page-object helper that waits for the group and only clicks it when collapsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
…port A random pick from 34000-34999 could land on a port already in use on a shared CI runner. `Server.start` does not reject on `EADDRINUSE`, so the test hung until its 5 s timeout instead of failing clearly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
joshunrau
left a comment
There was a problem hiding this comment.
overall comments
- should use title case when applicable consistent with the rest of the app
- the header thing (1. Subjects, 2. Instruments, etc.) looks bad, especially on large screens. Open it in the browser and improve the UI
- need padding for table - it needs to be opened in the browser and examined for UI
- need shadow for table or something, it looks very bad now
| ) { | ||
| groupItems.push({ | ||
| icon: SendIcon, | ||
| label: t({ en: 'Bulk Remote Assignments', fr: 'Tâches à distance en lot' }), |
There was a problem hiding this comment.
I would say we should just name this "Remote Assignments", it is more aesthetic
There was a problem hiding this comment.
Renamed to "Remote Assignments" in 0c41a54 — nav item, page heading and the admin setting ("Enable Remote Assignments"), so the three agree.
| activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES, | ||
| branding: branding.success ? branding.data : null, | ||
| defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null, | ||
| isBulkRemoteAssignmentsEnabled: savedOptions?.isBulkRemoteAssignmentsEnabled !== false, |
There was a problem hiding this comment.
just verify that is it impossible for isBulkRemoteAssignmentsEnabled to be set to true, if isGatewayEnabled is not true
There was a problem hiding this comment.
Enforced in 0c41a54. updateState now throws ForbiddenException if isBulkRemoteAssignmentsEnabled is set true while GATEWAY_ENABLED is false, and getState derives it from the gateway, so a document written while a gateway existed still reports false without one. Four cases added in setup.service.spec.ts, including disabling it without a gateway (still allowed, so an instance is never stuck with it on).
| } | ||
| await navigator.clipboard.writeText(toResultTsv(resultRows())); | ||
| setDidCopy(true); | ||
| } catch { |
There was a problem hiding this comment.
remove the try catch, we can just add notification and return if clipboard is not available for some reason
There was a problem hiding this comment.
Done in 0c41a54 — copyLinks notifies and returns when navigator.clipboard is absent, no try/catch.
| const { t } = useTranslation(); | ||
| // Each piece of the batch is held here rather than in the step that edits it, so moving between | ||
| // steps - by Back or by breadcrumb - never discards work the user has already done. | ||
| const [step, setStep] = useState<WizardStep>('SOURCE'); |
There was a problem hiding this comment.
extract all of this to a single hook
There was a problem hiding this comment.
only make public what needs to be public
There was a problem hiding this comment.
Extracted to apps/web/src/hooks/useBulkAssignmentWizard.ts in 0c41a54. The component now only renders: every step value, the mutations and the step transitions live in the hook. Raw uploaded rows stay private to it — describeSubject and resultRows are what the component gets instead.
| return ( | ||
| <div className="mx-auto flex max-w-[70rem] flex-col gap-6" data-testid="bulk-remote-assignment-wizard"> |
There was a problem hiding this comment.
yes, test the hook I requested
| (instrument) => !timepoints.some((timepoint) => timepoint.instrumentId === instrument.id) | ||
| ); | ||
|
|
||
| const todayISO = new Date().toISOString().split('T')[0]!; |
There was a problem hiding this comment.
use toBasicIsoString from libjs
There was a problem hiding this comment.
Done in 0c41a54 — the export is toBasicISOString, now used in TimepointsStep and in the route that computes the default expiry.
| type="date" | ||
| value={expiresAt} | ||
| onChange={(event) => setExpiresAt(event.target.value)} | ||
| /> |
- Extract every piece of wizard state into `useBulkAssignmentWizard`, exposing only what the components use, and cover it with unit tests. - Drop the clipboard try/catch: report the missing clipboard and return. - Refuse to enable the instance toggle while the gateway is disabled, and report it disabled whatever a document written under a gateway says, since the gateway is what serves a link. - Rename the nav item, page and setting to "Remote Assignments". - Use `toBasicISOString` rather than slicing an ISO string. - Title case for wizard labels, a numbered step header that does not stretch, and padding plus a shadow on each table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BBe3SLygptW8XQ4UimdF3v
|
@joshunrau addressed in 0c41a54 (rebased onto 120fdf2). Inline comments — replied on each thread: nav renamed to "Remote Assignments", wizard state extracted into Overall comments
Checks: web + api lint clean; 331/331 web unit tests; 325/325 api tests. |
…ents The route now lives at /group/remote-assignments, matching the heading and nav label it has always rendered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…yground stories @tailwindcss/vite detects sources from the Vite root, which for Storybook is storybook/ and holds no components, and react-core's globals.css only names libui and react-core. Any utility used solely inside apps/web or apps/playground was therefore never generated, so every story from those apps rendered without it: the bulk assignment wizard lost its max-width and its stepper cap, and looked nothing like the app. Storybook now imports its own config/globals.css, which imports react-core's entry and adds @source for the two app directories. Documented as one more registry in storybook/AGENTS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wizard root had auto margins and a max-width but no width, so as a flex column child it sized to its content: 1120px on the subject table, overflowing the page column at 1280px, and a few hundred pixels on the other steps. Inside it, an outer card wrapped bordered and shadowed boxes around every table, a boxed summary, and one bordered pill per instrument, while the primary action sat centred in the content on one step and in the footer on the others. One card now frames the flow. The stepper is a full-width top band, capped and centred, with check marks for completed steps; each step's actions live in a border-top footer; tables share a single WizardTable treatment and the instrument list and review summary share a TimepointTable; the subject picker takes the card's colour and loses its extra wrapper; the source-mode tabs are underlined; errors are a tinted alert rather than a solid red slab. The story renders inside the app frame through a reusable decorator, with new stories for the mapping, instruments and review steps. A unit test covers the stepper's current and navigable states and the footer slot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The existing bulk spec covers the API alone. This one seeds a group, a manager and two subjects, opens the page as that manager, and asserts the wizard fits the page column, the stepper spans the card, and selecting every subject advances to the instruments step. Nothing is submitted, so no assignment is created and the shared database is left as found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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