OTTER-690: refactor the Set Up page and move the study title to Step 1 - #980
Conversation
a4900df to
d655777
Compare
nathanstitt
left a comment
There was a problem hiding this comment.
seems to be on the right track but we need to fix the disabled button with silent error case flagged, that seems dangerous
| // here is the one `validate()` returns, not `form.errors`, so the decision cannot race the | ||
| // state update that populates it. | ||
| const attemptContinue = useCallback(() => { | ||
| const { hasErrors, errors } = form.validate() |
There was a problem hiding this comment.
form.validate() runs the whole step1FieldsSchema, including the fields that are currently locked, but visibleFieldIds() deliberately drops the locked ids. So if a locked field is the only thing failing, hasErrors is true, focusFirstInvalid finds nothing to focus, and we return without opening the modal — a dead button with no visible error anywhere.
The concrete path: the PR says existing drafts with a longer title are never truncated, and a locked title renders through ReadOnlyField, which has no error slot at all. A submitted study whose title is 61+ characters would land exactly here. Same shape for a locked language once the partner's supported list changes underneath it.
That's the OTTER-647 failure mode this hook works hard to avoid everywhere else. It's unreachable today because /edit 404s anything non-DRAFT, but the whole visibleFieldIds design already says locked fields shouldn't gate, so it'd be nice if the validation agreed. Could we skip locked paths when deciding hasErrors — something like checking whether any unlocked path has an error, rather than the schema-wide flag?
There was a problem hiding this comment.
Fixed, and you are right that the design already said so. The gate is now "did a field the researcher can act on fail" rather than the schema-wide hasErrors: focusFirstInvalid returns the id it flagged, or null when nothing visible failed, so its return value is the decision.
management-app/src/app/[orgSlug]/study/request/use-setup-form.ts
Lines 70 to 82 in 52e1e38
Since the resolver is step1FieldsSchema alone, validate() can only key errors under title, orgSlug or language, and all three are in FIELD_ID_TO_FORM_PATH, so the narrower check has no fourth category to swallow. Only locked paths stop gating, which is the intent.
Regression test uses your first case, a submitted study whose stored title is 61 characters: the modal opens and no message is rendered anywhere.
management-app/src/app/[orgSlug]/study/request/setup-form.test.tsx
Lines 774 to 789 in 52e1e38
| * things, and a shared component would let a call site pass a word count against a character | ||
| * limit without anything failing. | ||
| */ | ||
| export const CharacterCounter: FC<CharacterCounterProps> = ({ count, maxCharacters }) => { |
There was a problem hiding this comment.
This is byte-for-byte the same render as WordCounter — same Text size="xs", same c={isOverLimit ? 'red' : 'dimmed'}, same {count}/{max} — and WordCounter already grew a unit prop for exactly this kind of variation.
The doc comment argues that a shared component would let a call site pass a word count against a character limit. That's true, but it's true of the split version too: nothing stops someone passing countWords(x) into count. The type is number either way, so the split buys naming, not enforcement.
If the naming is what we want, could we get it more cheaply — e.g. export const CharacterCounter = ({count, maxCharacters}) => <WordCounter wordCount={count} maxWords={maxCharacters} />? That keeps one implementation of the over-limit styling so the two can't drift, which feels like the thing actually worth protecting.
There was a problem hiding this comment.
Took your cheap version. CharacterCounter keeps its name and props and delegates the render, so the over-limit styling has one definition:
management-app/src/components/character-counter.tsx
Lines 21 to 23 in 52e1e38
Agreed the split buys naming and not enforcement, and the doc comment now says that instead of claiming otherwise.
Worth knowing for direction: OTTER-737 (#991, stacked two up from here) deletes word-counter.tsx outright along with countWords and the WordCountPlugin, and CharacterCounter becomes the only counter with its body inlined again. So this delegation is deliberately pointing at the file that is going away rather than the other way round, which costs nothing since #991 rewrites this file anyway.
| // The title rule is selected by workflow, not by action: a DRAFT row's title is owned by | ||
| // Step 1 and capped at 60 characters, while a CHANGE-REQUESTED row's is owned by the | ||
| // resubmit form and still governed by its 20-word rule. | ||
| if (status === 'DRAFT' && (studyInfo.title?.length ?? 0) > STUDY_TITLE_MAX_CHARACTERS) { |
There was a problem hiding this comment.
Worth a look: this throws on the title length before the lab-ownership row filter further down runs, so a lab member outside the study's lab can distinguish "known id, currently DRAFT" from everything else just by sending an over-long title and reading which error comes back.
The PR notes this under "left alone on purpose", and the reasoning about not splitting the atomic UPDATE is fair. But status here already comes from getInfoForStudyId in the middleware, so we've done the read regardless — the round trip isn't the cost. The cheap version might be to move this check after verified, keeping the single UPDATE and just reordering when we complain. Happy to be told it's not worth it, but it seemed worth having the option on the record rather than only in the description.
There was a problem hiding this comment.
Fixed, and thanks for putting it on the record rather than leaving it in the description. You were right that the read is already paid for.
One adjustment to the mechanics: moving the check after verified would have written the over-long title before complaining, because the UPDATE is what produces verified. So instead of reordering past it, the rule is now gated on the same fact the row filter gates on. getInfoForStudyId already selects submittedByOrgId, so the gate is free, the single atomic UPDATE is untouched, and validation still precedes the write:
management-app/src/server/actions/study-request.ts
Lines 245 to 264 in 52e1e38
A caller outside the lab now falls through to the generic Study is not editable or you do not have access instead of getting a distinct title error. Test asserts the failure carries no title key and the stored title is unchanged:
management-app/src/server/actions/study-request.test.ts
Lines 722 to 742 in 52e1e38
| // none, and `study_title_required_when_not_draft` rejects that the moment status leaves | ||
| // DRAFT. Resolve it here so the researcher gets a message rather than a raw DB error; | ||
| // /proposal redirects such a draft to Step 1 before it can reach this point. | ||
| const submittedTitle = |
There was a problem hiding this comment.
Small thing: with titleMode: 'omit' now the common DRAFT path, 'title' in snapshotFields is false almost every time, so this extra selectFrom('study') fires on essentially every submit.
We could fold it into the middleware's existing study read (getInfoForStudyId) or into the claiming UPDATE's returning, and drop a round trip from the hot path. Not a correctness issue — the guard itself is a good catch and I like that it turns a raw constraint violation into a real validation error.
There was a problem hiding this comment.
Fixed via the middleware route. getInfoForStudyId already reads this row, so the title now comes back with it and the second lookup is gone:
management-app/src/server/db/queries.ts
Lines 354 to 370 in 52e1e38
management-app/src/server/actions/study-request.ts
Lines 425 to 431 in 52e1e38
The returning option turns out not to work here: by the time the claiming UPDATE returns, the status has already left DRAFT, so study_title_required_when_not_draft has fired and produced exactly the raw error this guard exists to replace. That is now written down next to the check so nobody tries it later.
There was a problem hiding this comment.
Reversing part of this, and the round trip you flagged comes back. Sorry for the churn.
Folding the read into getInfoForStudyId put study.title into the ability subject, and requireAbilityTo serializes that subject into the permission_denied it returns to a caller it just refused (action.ts:102-118, then :210). The note above that code spells out the rule I broke: middleware may return ids and slugs, never row content, because the subject is handed to someone who has no right to the record. It is middleware for many actions, so every one of them was returning the study's title to anyone who guessed an id. Your cross-lab oracle comment on the other thread is what surfaced it.
So the shared query carries identifiers only again, and the persisted title is read in the handler, after the ability check:
management-app/src/server/db/queries.ts
Lines 398 to 409 in 7b6a42f
management-app/src/server/actions/study-request.ts
Lines 430 to 440 in 7b6a42f
Your returning finding still stands unchanged and is still written down there. The lookup is one extra query per submit, on a path that is already doing an UPDATE plus three signed-URL calls, so I think it is the right trade against the disclosure. A middleware chained after requireAbilityTo would keep the handler clean and read just as late, but nothing in the codebase does that today and it seemed the wrong PR to introduce the pattern. Say the word if you would rather have it that way.
| form.setFieldValue('language', null) | ||
| form.clearFieldError('language') | ||
| } | ||
| }, [selectedOrgSlug, form, data, isLocked]) |
There was a problem hiding this comment.
form in the dep array is a fresh object on every render (Mantine rebuilds the return value each time), so this effect re-runs on every render rather than only when selectedOrgSlug / data / isLocked change.
It happens to be harmless because setFieldValue short-circuits when the value is unchanged — that's the only thing standing between this and a render loop. The codebase elsewhere handles exactly this by excluding form with an exhaustive-deps disable and a note about Mantine ref semantics (use-yjs-form-map.ts does it twice). Could we do the same here so the safety isn't resting on a library implementation detail? The carried-over version had the same shape, so this is inherited rather than introduced — just a good moment to fix it while the file is being rewritten anyway.
There was a problem hiding this comment.
Done, matching the use-yjs-form-map.ts shape (prose note, then the bare disable on the line before the array, which is where the rule actually reports):
Fair point that it was inherited rather than introduced. Good moment to fix it, agreed.
| // unreachable anyway. This is also the one place the title is trimmed; validation | ||
| // measures the raw length so it agrees with the character counter. | ||
| const draftInfo = { | ||
| title: formValues.title?.trim() || undefined, |
There was a problem hiding this comment.
The || undefined here means a blank title is dropped from the payload entirely rather than sent. On the update path that's exactly right — it preserves the stored title.
On the create path it means we can persist a brand-new study row with no title at all, since step1DraftStudyApiSchema accepts title as nullable/optional. The comment says the Save & continue gate makes that unreachable, and it does today, but it's the same class of untitled-draft row that this PR then has to add two separate recovery guards for (/proposal redirect, finalizeStudySubmissionAction).
Since onSaveDraftStudyAction is Step 1's alone, would it be worth making title required (non-empty) in step1DraftStudyApiSchema for the create case, so we stop minting new rows that need those guards?
There was a problem hiding this comment.
Worth it, done. step1DraftStudyApiSchema now requires a non-blank title, and since it is onSaveDraftStudyAction's alone the tightening does not reach the update or resubmit paths:
management-app/src/app/[orgSlug]/study/request/form-schemas.ts
Lines 146 to 163 in 52e1e38
Cap is checked before blankness so the message matches what the caller did, and emptiness is measured trimmed like studyTitleField.
The || undefined had to split, since one payload can no longer serve both calls. Update keeps it for the reason you gave; create requires a title and the guard gives TypeScript the narrowing:
management-app/src/contexts/study-request/hooks/use-save-draft.ts
Lines 33 to 69 in 52e1e38
The two recovery guards stay, because rows predating this card exist regardless. This only stops new ones joining them. Something that argued for it: the tests needing an untitled row already manufacture one with a direct update study set title = null, rather than getting one out of the action.
One existing test changed as a result. The OpenStax flow test created its draft with language only and asserted title came back NULL, which described the pre-OTTER-690 flow where Step 2 supplied the title. It now passes one at create, matching what Step 1 actually sends:
management-app/src/server/actions/study-request.test.ts
Lines 699 to 720 in 52e1e38
52e1e38 to
661aaba
Compare
b67e017 to
3b17124
Compare
| const result = await onUpdateDraftStudyAction({ studyId, studyInfo: { title: OVER_LIMIT } }) | ||
|
|
||
| expect(result).toHaveProperty('error') | ||
| expect(result).not.toMatchObject({ error: expect.objectContaining({ title: expect.any(String) }) }) |
There was a problem hiding this comment.
This test passes with or without the fix it is meant to protect.
I reverted the handler gate to the old unconditional form:
if (status === 'DRAFT' && (studyInfo.title?.length ?? 0) > STUDY_TITLE_MAX_CHARACTERS) {and this test still passed. A console.log at the top of the handler never printed. The actual rejection is:
{"error":{"permission_denied":"in onUpdateDraftStudyAction action; cannot update Study..."}}
The cross-lab caller is stopped by CASL in requireAbilityTo('update', 'Study') before the handler body runs at all, because permissions.ts:104 is row-scoped:
permit('update', 'Study', { submittedByOrgId: { $in: usersResearcherOrgIds } })That scoping landed in f1b7761 ("Enforce the submitting lab through CASL instead of a handler check"), which is already in this PR's base. So a cross-lab caller can never reach the title check regardless of its position, and the not.toMatchObject assertion is satisfied by the permission_denied shape rather than by the ordering.
Two consequences worth separating:
- The reordering itself is still the right shape — defence in depth, and it costs nothing since
submittedByOrgIdcomes from the middleware read. Keep it. - The test's name promises it guards the oracle, and it doesn't. As written it would stay green if someone moved the title check back above the lab gate, which is exactly the revert it exists to catch.
If you want it to pin the behaviour, it needs a caller who passes CASL but fails the lab-membership gate — e.g. a user who belongs to some lab in session.orgs but not the study's submittedByOrgId, so the handler is actually entered. Otherwise I'd suggest renaming it to reflect what it really asserts (that a cross-lab update is rejected without a title-specific message) so the coverage isn't overstated.
No objection to merging either way — the production behaviour is correct.
There was a problem hiding this comment.
You are correct. The test does not protect the gate. I put the gate back to its old form, and the test still passed.
But you cannot make the caller that you suggest. CASL builds usersResearcherOrgIds from the lab orgs in the session (permissions.ts:43). The handler builds userLabOrgIds from the same orgs. Each one then compares its list to the same submittedByOrgId. This is one rule, written two times.
So CASL refuses a member of a different lab before the handler starts. Only an SI admin who holds manage all can pass CASL and then fail the gate.
I did not write a test for the SI admin. The oracle is not a risk for that user, and such a test would make a rule from a condition that nobody selected. I used your second option instead. The gate stays, the test name tells what the test does, and the comment gives CASL as the cause of the refusal.
management-app/src/server/actions/study-request.test.ts
Lines 719 to 741 in 7b6a42f
I also corrected the comments at study-request.ts:239 and :396. They said that CASL update Study is "org-type-scoped (any lab member)". Commit f1b7761 made that text incorrect.
Your comment showed a more serious problem. In my change for your comment about the second lookup, I added study.title to the middleware. The data from the middleware becomes the ability subject. requireAbilityTo then puts that subject into the permission_denied message, and the action sends this message to the caller that it refused. In this test, the caller from the other lab received "title": "LabA Draft". The old assertion did not find it, because the assertion examined only the title key.
getInfoForStudyId now gives identifiers only. The handler reads the title after the permission check. The test also makes sure that the refusal shows no part of the stored title. This change adds again the second query that you asked me to remove, and I give the reasons on that thread.
management-app/src/server/db/queries.ts
Lines 398 to 409 in 7b6a42f
management-app/src/server/actions/study-request.ts
Lines 430 to 440 in 7b6a42f
I recommend a different card for one more problem. The subject always contained status and researcherId, for all actions that use this middleware. The correction is to keep the serialized subject in the logger and in Sentry, and to stop sending it to the caller. The work looks small: 33 of the 35 test references to permission_denied use objectContaining, and one uses stringContaining. Tell me if you agree, and I will make the card.
Groundwork for OTTER-690's Set Up page rebuild. No existing screen changes behavior. - CharacterCounter: sibling of WordCounter for fields specified in characters. Kept separate rather than folded into WordCounter behind a unit flag, since the two count different things and a shared component would let a call site pass a word count against a character limit silently. - ProposalStepHeader: studyTitle becomes optional. Step 1 reuses this header before a study exists and its spec forbids the title as body text. Omitting the prop drops the row; passing any string, including a blank one, renders it exactly as before, so the eight existing call sites are untouched. - ReadOnlyField: label over value for fields the user can see but not change, mirroring the layout the submitted-proposal views already use.
Rebuilds the Set Up page (Step 1) and moves ownership of study.title onto it.
Phases B and C land together because neither half works alone: Step 1 persisting
the title without the collaboration and mirror scoping loses data, and Step 1
requiring a title before the Step 1 input exists leaves study creation with no
title field anywhere.
Title ownership is now flow-scoped:
- DRAFT: Step 1 owns study.title and writes it through the draft actions.
- CHANGE-REQUESTED: edit-and-resubmit keeps its collaborative title, unchanged.
Nothing is deleted to achieve that, only scoped, because the resubmit flow
shares the schema, the Yjs map, buildStudyInfo and the title mirror.
- /proposal redirects CHANGE-REQUESTED to /edit-and-resubmit. Nothing routes a
study there today (the dashboard sends it to the page built for that state,
which carries the reviewer feedback and resubmission note /proposal has no UI
for), so this is not a user-visible change. It is what lets ProposalProvider
be unconditionally DRAFT instead of every consumer re-deriving the split.
- useYjsFormMap takes an optional collabKeys set. The DRAFT editor drops title
from it; the resubmit flow passes nothing and keeps today's behavior. The
effects key on the contents rather than the array identity so an inline
literal cannot rebuild the provider on every render.
- The title mirror is narrowed to CHANGE-REQUESTED. Legacy fields-docs still
carry a title key the DRAFT client no longer maintains, so leaving DRAFT in
would flush a blank or stale value over the Step 1 one.
- buildStudyInfo takes a required TitleMode ('send' | 'omit' | 'omitIfBlank'),
replacing two independently-settable booleans. Required, so a new caller has
to choose rather than inherit a default at a write path.
- A DRAFT-only Step 2 resolver drops the title rule, since that page no longer
renders the field and a required rule on an unrendered field is a submit
blocker nothing can clear.
- ReviewerPreview takes the title as a prop: the DRAFT footer passes the
persisted value, the resubmit footer the live form value.
- The Step 1 and update params schemas are split rather than derived. The update
action also serves the resubmit autosave, so a 60-character cap in its schema
would reject resubmit payloads before the handler could read the persisted
status. The rule is applied by workflow instead: 60 characters on creation and
on DRAFT updates, unchanged on CHANGE-REQUESTED.
Set Up page:
- One card built on the reused ProposalStepHeader, replacing the two STEP 1A /
STEP 1B panels.
- Study title field with a 60-character limit, measured raw so the counter and
the validator can never disagree. Typing past the limit stays possible.
- Save & continue is never disabled: clicking it is what surfaces the errors,
flags every visible field at once and moves focus to the first.
- Confirmation modal reusing the existing modal component.
- Data Partner and programming language render read-only once persisted, and the
title too once the proposal is submitted, each guarded on a value actually
existing so a draft that never got one cannot be locked out of completion.
- Changing Data Partner clears a language the new partner cannot run.
Also fixes a latent flake in acknowledgements-table.test.tsx, unrelated to this
card: two tests looked up a faker-named user in a 25-row page of every user in
the shared test database, so they failed once enough users sorted ahead of it.
The specs drove UI this change removes: the "Proceed to Step 2" button, the STEP 1A eyebrow and the Step 2 "Study Title" field. Without these edits the suite fails in CI regardless of whether the feature works. - selectOrgAndLanguage becomes fillStep1: it asserts the STEP 1 / Set up study header and fills the title here, where it now lives. - confirmStep1 covers the new confirmation modal, which is a new await point between Step 1 and /proposal. - fillAndSubmitProposal drops the title fill and asserts the field is gone. Callers keep their own copy of the title for dashboard row lookups. - The Step 2 resume test asserts the revisited Step 1 shows the title as saved and the Data Partner and language as text rather than controls. It is also the canary for draftHasStep2Progress, which now has only the dataset selection to key on. - The blur-validation test is rewritten for the new model: the button is live from page load, a click with everything blank flags two fields (never three, since the language field is not on the page yet) and focuses the first, the character-limit error appears and clears while typing, and Cancel on the modal returns with values intact. uniqueTitle is capped at 60 characters. It could previously generate ~85, which Step 1 now rejects, so every UI-driven flow would have stopped on the first page. The suffix and timestamp are what make it unique, so the decorative words are what gets trimmed. Seeded studies write to the database directly and are unaffected. These specs are updated but not executed locally.
…rewritten Code review follow-ups on the Step 1 setup refactor. A DRAFT predating this branch can have a NULL title: the migration that made the column nullable cleared every 'Untitled Draft' placeholder, and Step 2 no longer carries a field to put one back. Submitting one violated the study_title_required_when_not_draft check constraint as an unhandled error, and the dashboard routes any draft with Step 2 progress straight past Step 1. /proposal now sends a blank-title draft back to Step 1, and finalizeStudySubmissionAction resolves the title it would write and fails with an actionable error rather than the raw constraint violation. The programming-language sync effect ran above the isLocked early return, so a partner whose supported language set changed could clear or overwrite a read-only value. visibleFieldIds skips locked ids, so the resulting required error had nothing to focus and Continue silently did nothing. The Data Partner Select was interactive with an empty option list while Clerk resolved: a query held back by `enabled` reports fetchStatus 'idle', so isLoading is false and the control was never disabled. Also drops the unused isStep1Valid, which ran the full resolver on every render of a provider mounted on every /study route; keeps the errorLive footer row mounted so its live region predates its first message; and stops uniqueTitle emitting a leading separator when a long suffix leaves no room for words. renderWithProviders takes an optional queryClient so a test can prime a query before the first render. A locked field renders nothing derived from the languages query, so without that the regression test raced the click it was meant to assert on.
7b6a42f to
25a81dd
Compare
Total coverage
Detailed report3 files with a coverage regression
|
see OTTER-690
Rebuilds the Set Up page (Step 1) as a single card and moves the study title onto it, so every saved draft has a title from the moment it is created.
The study title moved from Step 2 to Step 1
Step 2 no longer renders a title field. The field, its validation, its persistence and its error handling all live on the Set Up page now.
Who owns
study.titleOwnership is scoped by flow, not by page:
DRAFTCHANGE-REQUESTEDedit-and-resubmitNothing was deleted to get there, only scoped. The resubmit flow shares the proposal schema, the Yjs field map,
buildStudyInfoand the title mirror, so dropping the title from any of them would break resubmission. In detail:useYjsFormMaptakes an optionalcollabKeysset. The DRAFT Step 2 editor passes a reduced set withouttitle; the resubmit flow passes nothing and behaves as before. Without this, a cold fields-doc would seed a blank title over the Step 1 one, and a warm one would seed a stale title.DRAFT, CHANGE-REQUESTEDtoCHANGE-REQUESTEDonly. Legacy fields-docs still hold atitlekey that the DRAFT client no longer maintains, so leaving DRAFT in would flush that stale key over the Step 1 value on the next debounce.buildStudyInfotakes a requiredTitleMode('send' | 'omit' | 'omitIfBlank') instead of two independent booleans. Required rather than defaulted, so a new caller has to choose instead of inheriting one by accident. All four write paths are covered: DRAFT save-on-navigate and DRAFT final submit omit the title, resubmit sends it, and resubmit autosave omits it only when blank (a NULL title on a non-DRAFT row violatesstudy_title_required_when_not_draft).The server-side rule is picked by workflow rather than by action, because every entry point except study creation is shared.
onUpdateDraftStudyActionserves both Step 1 updates and the resubmit autosave, so its params schema stays permissive and the handler applies the 60-character rule only when the persisted row isDRAFT. Its schema is now declared standalone instead of derived from the create schema, so tightening creation cannot leak into it and reject a resubmit payload before the handler sees the status.The
/proposalredirect for CHANGE-REQUESTED, and why it is safe/proposalnow redirects aCHANGE-REQUESTEDstudy to/edit-and-resubmit.Nothing routes a study there today. The only two ways into
/proposalare Step 1, which is always a DRAFT, and the dashboard resume link, which is gated onisDraft. A change-requested study goes to/submittedand then to/edit-and-resubmit, the page built for that state: it carries the reviewer feedback and the resubmission note that/proposalhas no UI for. Anyone arriving on a stale bookmark now lands on the working page instead of a half-working one.That redirect is what lets
ProposalProviderbe unconditionally DRAFT. Without it, four separate things would each need their own status branch: the collaborative key set, the form resolver, the title mode on threebuildStudyInfocall paths, and the reviewer preview's title source.proposal/page.test.tsxpins the redirect, so reverting it fails loudly.Drafts created before this change
The migration that made
study.titlenullable also cleared every'Untitled Draft'placeholder, so drafts with no title at all exist wherever it has run. Step 2 used to give them one; it no longer has the field. Two guards cover them:/proposalsends a draft with a blank title back to Step 1. This matters because the dashboard routes any draft with Step 2 progress straight to/proposal, so without it those drafts would never see a title field again.finalizeStudySubmissionActionworks out the title it is about to write (the submitted one if the caller sent it, otherwise the persisted one) and returns a normal validation error when it is blank. Before this, the status change hitstudy_title_required_when_not_draftand surfaced as an unhandled database error with no way to recover in the UI.The 60-character limit
The old rule was 20 words, enforced on Step 2 and shared with the resubmit form. The new Step 1 rule is 60 characters:
61/60in the counter and still validate, so the counter and the validator can never disagree.aria-live="polite", because it can appear before any blur or click. The blank-title error stays on blur and on click, so clearing the box does not flash an error mid-edit.The resubmit flow deliberately keeps its 20-word rule, so the two flows now enforce different title rules. That inconsistency is real and worth a follow-up card, but harmonizing it is a bigger product change than this one.
Deferred acceptance criteria
/editstill returns not-found for any non-DRAFT study and nothing links there, so the criteria about the disabled title surviving navigation, a reload and a new session cannot be demonstrated. Relaxing that gate is out of scope here and belongs with the later navigation cards. Please treat these as deferred rather than ticking them.Open question
The card says the title is disabled "permanently" once submitted, and that it should appear "only on the set up page". Read literally, that reaches the
edit-and-resubmitpage, which by definition renders a study that was submitted and sent back, and which today has a fully editable collaborative title. This PR leaves that page alone: the card never names it, and the rules sit under headings scoped to Set Up and Proposal. If the intent is to lock it there too, that is a separate and larger change. Worth tightening the wording either way.Other notes
studyIddoes. Locking on the id alone would leave a draft that never got a language permanently uncompletable.enabledreportsfetchStatus: 'idle', soisLoadingis false and the control would otherwise be openable with an empty list.Save & continueis never disabled. Clicking it is what surfaces the errors, and a disabled button explains nothing. It also switches fromvariant="primary", which resolves to no styles at all, to thefilledvariant the card asks for.renderWithProvidersaccepts an optionalqueryClient, so a test can prime a query before the first render. A locked field renders nothing derived from its query, which leaves no DOM signal to wait on, and without priming the regression test raced the interaction it was meant to assert on.onUpdateDraftStudyActiongates the title-length rule on lab membership, so the message cannot tell a caller that a guessed id is a real, currently-DRAFT study. CASL scopesupdate Studyto the caller's own labs, so a lab member outside the submitting lab is already denied before the handler runs; the gate is defense in depth against that rule widening, and it is what stops a caller who passed the ability check on a broader grant.submittedByOrgIdalready arrives from the middleware's read, so the gate costs no extra query and the single atomic UPDATE stays intact. The length check still runs before that UPDATE rather than after it, which is what keeps an over-long title from being written and only then rejected.acknowledgements-table.test.tsx, unrelated to this card. Two tests looked up a faker-named user inside a 25-row page listing every user in the shared test database, so they failed whenever enough users happened to sort ahead of it.study.titleis already nullable with the right check constraint.docs/study-screens-logic.mdneeds no update.Worth noting on the Step 2 refactor card: the title field was removed here, and Step 2 now assumes a non-blank persisted title.
Review follow-ups
Save & continuenow decides on the fields the researcher can act on, rather than on the resolver's schema-wide error flag. The resolver covers locked fields too, and a locked field renders read-only text with no error slot and nothing focusable, so a locked failure used to stop the click with no message anywhere and no field to correct.focusFirstInvalidreturning null is the gate now, which is exactly "nothing on this page is failing". A locked value is the persisted server one and authoritative, so it is not the researcher's to fix.step1DraftStudyApiSchemabelongs toonSaveDraftStudyActionalone, so requiring it there does not reach the update or resubmit paths, whose titles are owned elsewhere. This stops new untitled rows being minted; the/proposalandfinalizeStudySubmissionActionguards stay, because rows predating this card still need them.CharacterCounterdelegates toWordCounterrather than repeating its render, so the over-limit styling has one definition and the two cannot drift.finalizeStudySubmissionActionreads the persisted title in its handler, after the ability check. It cannot be folded into the middleware's read of the same row: middleware output becomes the ability subject, andrequireAbilityToserializes that subject into thepermission_deniedit returns to a caller it just refused, so a title carried that far would travel back to anyone who guessed a study id.getInfoForStudyIdis middleware for many actions, so it now carries identifiers only. The read also cannot move into the claiming UPDATE'sreturning: by then the status has left DRAFT and the check constraint has already produced the raw error this guard exists to replace.formfrom its dependency array, with the same note and disableuse-yjs-form-map.tsuses. Listing it re-ran the effect on every render, with onlysetFieldValueshort-circuiting on an unchanged value standing between that and a loop.Testing
pnpm run checksand the unit suite pass: 295 files, 3521 tests.The end-to-end specs were updated but not executed locally. They needed updating regardless of the feature, since they drove UI this change removes: the "Proceed to Step 2" button, the STEP 1A eyebrow and the Step 2 "Study Title" field.
uniqueTitlealso had to be capped at 60 characters, since it could previously generate around 85 and every UI-driven flow would have stopped on the first page.