Skip to content

Refuse to untick the last allowed submission format - #210

Merged
jodeleeuw merged 1 commit into
testfrom
fix/validation-require-one-format
Aug 27, 2026
Merged

jodeleeuw merged 1 commit into
testfrom
fix/validation-require-one-format

Conversation

@jodeleeuw

Copy link
Copy Markdown
Member

Follow-up to #209. Option 1 from the discussion there, UI only — no server change.

Why

With validation on and neither format allowed, functions/src/api-data.ts:86-104 cannot reach either validator:

let valid: boolean = false;
if (exp_data.allowJSON) { /* ... */ }
if (exp_data.allowCSV && !valid) { /* ... */ }
if (!valid) { res.status(400).json(MESSAGES.INVALID_DATA); ... }

Every submission takes the 400, requiredFields is never consulted, and the experiment silently stops collecting. The error the researcher eventually finds in the log says "The data are not valid according to the validation parameters set for this experiment." — pointing at the participant's submission, when the submission never mattered.

It's two clicks in a panel that gives no sign the second one means something categorically different from the first.

What changed

The checkbox group declines to go empty, the way TagListInput declines a duplicate. Declining is just not calling setValidationSettings — the group is controlled, so the tick mark never moves and the save effect never fires.

Two things that weren't free:

The refusal needs a voice. A control that declines silently is indistinguishable from one that is broken, so the checkboxes gain the same always-one-line message slot TagListInput uses — steady-state guidance swapped for the reason, never a block that mounts underneath and pushes the rest of the panel down on every click. The transient-notice machinery is extracted to useTransientNotice and shared by both, so the two refusals in this panel say their piece for the same duration. TagListInput's 25 tests pass unchanged across that extraction.

The refusal needs to reach the accessibility tree. Ark has already flipped the real <input> by the time onValueChange runs; refusing leaves React state untouched, so React re-renders with the same checked prop and never resets the node. What the researcher sees stays correct — the tick is drawn from the controlled group value — but the hidden input is what a screen reader reads, and it now says unchecked: the opposite of what happened. resyncFormatInputs puts the property back. This was caught by toBeChecked(), which reads the property rather than the attribute.

Scope

This is a UI floor, not a guarantee. The server is unchanged, so a document that already holds the state — or one written any other way — behaves exactly as it did. That was a deliberate call, not an oversight.

Tests

Three cases added. Two fail with the guard disabled.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SHZNijLwUhn26yCbYbn1UN

With validation on and neither format allowed, api-data.ts cannot reach
either validator: `valid` starts false, both branches are skipped, and
every submission takes the 400. The experiment silently stops collecting,
and the error the researcher eventually finds in the log says the DATA is
invalid -- pointing at the participant's submission, when the submission
never mattered. Two clicks in a panel that gives no sign the second one
means something categorically different from the first.

There is no legitimate reading of "validate submissions, accept nothing",
so the control now declines rather than saving it, the way TagListInput
declines a duplicate. Declining is just not calling setValidationSettings:
the group is controlled, so the tick mark never moves and the save effect
never fires.

Two things that were not free:

The refusal needs a voice. A control that declines silently is
indistinguishable from one that is broken, so the checkboxes gain the same
always-one-line message slot TagListInput uses -- steady-state guidance
swapped for the reason, never a block that mounts underneath and pushes
the rest of the panel down on every click. The transient-notice machinery
is now `useTransientNotice`, shared by both, so the two refusals in this
panel say their piece for the same duration.

And the refusal needs to reach the accessibility tree. Ark has already
flipped the real <input> by the time onValueChange runs; refusing leaves
React state untouched, so React re-renders with the same `checked` prop
and never resets the node. What the researcher SEES stays correct -- the
tick is drawn from the controlled group value -- but the hidden input is
what a screen reader reads, and it now says unchecked: the opposite of
what happened, and worse than no feedback at all. `resyncFormatInputs`
puts the property back.

This is a UI floor, not a guarantee. The server is unchanged, so a
document that already holds the state still behaves as it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHZNijLwUhn26yCbYbn1UN
@jodeleeuw
jodeleeuw merged commit 7267841 into test Aug 27, 2026
1 check passed
@jodeleeuw
jodeleeuw deleted the fix/validation-require-one-format branch August 27, 2026 12:11
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.

1 participant