Skip to content

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

Merged
joshunrau merged 44 commits into
mainfrom
feat/bulk-remote-assignments-web
Sep 16, 2026
Merged

joshunrau merged 44 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 thread apps/web/src/utils/bulk-assignments.ts Outdated
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 } });
Comment thread apps/web/src/utils/bulk-assignments.ts Outdated
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 thread apps/web/src/utils/bulk-assignments.ts Outdated
Comment on lines +280 to +283
const rows = utils.sheet_to_json<{ [key: string]: string }>(workbook.Sheets[sheetName]!, {
defval: '',
raw: false
});
Comment thread apps/api/src/setup/setup.service.ts Outdated
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">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yes, test the hook I requested

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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
@gdevenyi
gdevenyi requested a balanced review from Copilot September 14, 2026 14:38

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

thomasbeaudry and others added 7 commits September 14, 2026 10:58
…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 joshunrau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Comment thread apps/web/src/hooks/useNavItems.ts Outdated
) {
groupItems.push({
icon: SendIcon,
label: t({ en: 'Bulk Remote Assignments', fr: 'Tâches à distance en lot' }),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I would say we should just name this "Remote Assignments", it is more aesthetic

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Renamed to "Remote Assignments" in 0c41a54 — nav item, page heading and the admin setting ("Enable Remote Assignments"), so the three agree.

Comment thread apps/api/src/setup/setup.service.ts Outdated
activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES,
branding: branding.success ? branding.data : null,
defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null,
isBulkRemoteAssignmentsEnabled: savedOptions?.isBulkRemoteAssignmentsEnabled !== false,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

just verify that is it impossible for isBulkRemoteAssignmentsEnabled to be set to true, if isGatewayEnabled is not true

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

remove the try catch, we can just add notification and return if clipboard is not available for some reason

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

extract all of this to a single hook

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

only make public what needs to be public

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

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">

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

yes, test the hook I requested

(instrument) => !timepoints.some((timepoint) => timepoint.instrumentId === instrument.id)
);

const todayISO = new Date().toISOString().split('T')[0]!;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

use toBasicIsoString from libjs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 0c41a54 — the export is toBasicISOString, now used in TimepointsStep and in the route that computes the default expiry.

Comment on lines +117 to +120
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is wrong, ignore

thomasbeaudry and others added 2 commits September 15, 2026 23:43
- 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
@thomasbeaudry

thomasbeaudry commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

@joshunrau addressed in 0c41a54 (rebased onto 120fdf2).

Inline comments — replied on each thread: nav renamed to "Remote Assignments", wizard state extracted into useBulkAssignmentWizard with 12 unit tests, clipboard try/catch removed, toBasicISOString used for both date spots, and the toggle now refuses to be enabled without the gateway (ForbiddenException in updateState, plus getState deriving it from the gateway so a document written under one cannot report it on afterwards). I skipped the TimepointsStep date-constraint thread you marked "ignore".

Overall comments

  • Title case applied across the wizard: step titles, tab labels, buttons, table headers and field labels. French left in sentence case, as elsewhere.
  • Step header rebuilt: the "1. Subjects / 2. Instruments" buttons are now numbered markers joined by a rule, capped at max-w-md so it no longer stretches across a wide card.
  • Tables: subject picker, column mapping, preview and results all get a border, shadow-sm and taller row padding.

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>
joshunrau and others added 3 commits September 16, 2026 13:13
…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>
@joshunrau
joshunrau merged commit 5598421 into main Sep 16, 2026
6 checks passed
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.

3 participants