Skip to content

feat(web): add the bulk remote assignment wizard - #1528

Open
thomasbeaudry wants to merge 30 commits into
mainfrom
feat/bulk-remote-assignments-web
Open

feat(web): add the bulk remote assignment wizard#1528
thomasbeaudry wants to merge 30 commits into
mainfrom
feat/bulk-remote-assignments-web

Conversation

@thomasbeaudry

Copy link
Copy Markdown
Collaborator

The web half of group-scoped bulk remote assignments (#1500), on top of the API and gateway merged in #1521.

/group/bulk-remote-assignments walks 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. xlsx is 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

isBulkRemoteAssignmentsEnabled sits beside the uploader flag. With it off the group links stay exactly where they are today; with it on they collapse into a Group Actions menu 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

  • resolveSubjectIds re-scopes a pasted identifier. A custom id is stored as Group$id but 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.
  • The exported identifier is deliberately not truncated. The app displays a short form everywhere, but a prefix resolves to no record, so the file carries the full value.
  • No component tests for the wizard. The parsing utility and the hooks are covered; the steps themselves are not.

Follow-up found while testing

StartSessionForm, IdentificationForm and utils/upload.ts all scope an entered identifier unconditionally. Pasting a digest into Start Session produces Group$<digest>, which matches nothing, and the upload path creates any subject it cannot find — so this silently makes a duplicate. Pre-existing on main and untouched here; the guard added in this PR is what those three call sites need, lifted into subject-utils.

🤖 Generated with Claude Code

thomasbeaudry and others added 28 commits September 10, 2026 01:02
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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">
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants