Skip to content

feat(mrf-cutover): default CreateFormModal to MRF with storage-mode escape hatch (4/6)#9465

Merged
kevin9foong merged 7 commits into
developfrom
feat/create-form-modal-cutover
Jun 4, 2026
Merged

feat(mrf-cutover): default CreateFormModal to MRF with storage-mode escape hatch (4/6)#9465
kevin9foong merged 7 commits into
developfrom
feat/create-form-modal-cutover

Conversation

@kevin9foong

@kevin9foong kevin9foong commented May 21, 2026

Copy link
Copy Markdown
Contributor

Stacked PR 4/6 for MRF cutover. Base: refactor/modal-cutover-prep.

Problem

Per the PRD (issue #9454): the Create Form modal currently defaults to storage mode and offers it side-by-side with MRF, which slows org-wide MRF migration and leaves admins on the legacy v1 webhook schema by default. Closes #9453.

Solution

Under the mrf-cutover flag:

  • Default CreateFormModal to Multirespondent — no mode toggle on the first screen.
  • New CreateFormStorageModeScreen reachable via the escape-hatch link ("old version of FormSG"), composed from the escapeHatchCopy foundation in PR 1. Title input + create button, with a "back" affordance to return to the MRF default screen.
  • Match Figma fidelity: hide the data-classification infobox in the cutover flow, use 2.5rem spacing.
  • Fix sliding-animation bug + undefined email crash that surfaced during escape-hatch navigation.
  • Storybook stories for the three escape-hatch copy variants (payments-only, +children, +webhook v1).

When the flag is off, the original modal renders unchanged.

Breaking Changes

No - gated behind mrf-cutover feature flag. Backend createForm still accepts Encrypt without flag checks (see docs/adr/0001-frontend-only-storage-form-gating.md).

Tests

TC1: flag on, default MRF flow

  • Enable mrf-cutover flag; open Create Form
  • Confirm MRF default screen renders (no mode toggle, no data-classification infobox)
  • Submit a title → confirm an MRF is created

TC2: escape hatch → storage mode

  • On the MRF default screen, click "old version of FormSG"
  • Confirm CreateFormStorageModeScreen renders with title input + create button
  • Submit → confirm a storage-mode form is created
  • From the storage screen, click "back" → confirm return to MRF screen without state loss

TC3: escape-hatch copy by beta flag

  • User with no extra flags → copy mentions payments only
  • User with betaFlags.children → copy mentions payments + children fields
  • User with betaFlags.createStorageModeForV1Webhook → copy mentions payments + webhooks v1

TC4: flag off

  • Disable mrf-cutover flag; open Create Form
  • Confirm original modal renders unchanged

@kevin9foong kevin9foong requested a review from a team as a code owner May 21, 2026 07:15
Base automatically changed from refactor/modal-cutover-prep to feat/webhook-v1-infobox May 21, 2026 07:20
@kevin9foong kevin9foong force-pushed the feat/webhook-v1-infobox branch from e17eda1 to a903570 Compare May 21, 2026 07:23
@kevin9foong kevin9foong force-pushed the feat/create-form-modal-cutover branch from d7024b6 to 4db88d8 Compare May 21, 2026 07:23
@kevin9foong kevin9foong force-pushed the feat/webhook-v1-infobox branch from a903570 to bbb670a Compare June 3, 2026 01:57
Base automatically changed from feat/webhook-v1-infobox to develop June 3, 2026 01:58
kevin9foong and others added 5 commits June 3, 2026 10:53
…e mode

When mrf-cutover is on, the create-form details screen no longer offers
a response-mode choice — Multirespondent is the implicit default. An
"old version of FormSG" link composed from the user's beta flags
navigates to a new storage-mode-only screen (title + create + back).
Submitting routes through the existing storage-mode mutation.

Closes #9453.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Wrap escape-hatch link in an info InlineMessage to match Figma
- Storage-mode page header reads "Set up a Storage mode form"
- Add storage-mode subtitle explaining that storage mode is outdated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… spacing in cutover flow

The MRF default screen previously showed both the escape-hatch infobox
and the existing data-classification infobox, cluttering the layout.
The cutover Figma drops the latter and uses 2.5rem rhythm around the
escape-hatch infobox and between input/button on the storage-mode page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kevin9foong kevin9foong force-pushed the feat/create-form-modal-cutover branch from 4db88d8 to 9965cf9 Compare June 3, 2026 02:53

@kevin9foong kevin9foong left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 AI code review

Reviewed git diff origin/develop...HEAD (target develop) across four axes — Standards, Spec, Architecture, Divergent — each verified against false positives. 4 inline comments posted; all non-blocking.

Dropped during verification (for transparency):

  • MRF default leaking into the Duplicate Form modal — the responseMode: Multirespondent injection lives in the shared useCommonFormWizardProvider, but that line is already on develop and isn't part of this PR's diff, so it's out of scope here (the dupe modal is #9454).
  • -1 animation-direction hack in goToStorageModeDetails — acceptable: correctly breadcrumbed TODO [MRF-CUTOVER], and the real slide-animation fix is out of scope for code the cutover deletes.
  • Escape-hatch copy hardcoded in escapeHatchCopy.ts — out of this diff (introduced by the merged #9451).

🤖 This review was generated by an AI code review. Please verify before acting on it.

Comment on lines +54 to +66
aria-label="Back"
icon={<BiArrowBack />}
onClick={goToMrfDetails}
mr="0.5rem"
/>
Set up a Storage mode form
</Flex>
</Container>
</ModalHeader>
<ModalBody whiteSpace="pre-wrap">
<Container maxW="45rem" p={0}>
<Text textStyle="body-2" color="secondary.500" mb="2rem">
Storage mode is outdated and no longer receives new features. Only

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

issue (non-blocking): new user-facing copy here is hardcoded English instead of going through t().

What's happening: the back aria-label="Back" (line 54), the header Set up a Storage mode form (line 59), and the body text Storage mode is outdated… (line 66) are inline strings — but the same screen already uses t('features.workspace.modals.forms.create…') for the title label and the create button.

Why it bites later: these strings won't appear in the ms / ta / zh locales, and copy now lives in two different conventions within one file.

Suggestion: route all three through t() with new keys, like the sibling screens. SaveSecretKeyContent.tsx even wraps its aria-labels in t().

Confidence: 95 — verified; flagged independently by the Standards, Spec, Architecture and Divergent axes.

🤖 This comment was generated by an AI code review. Please verify before acting on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ignored for now since we do not wish to support i18n since deprecation message is going to be temporary.

useCreateFormWizard,
} from '../CreateFormWizardContext'

const FORM_TITLE_LENGTH_WARNING = 65

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

suggestion (non-blocking): extract the duplicated title input rather than copy-pasting it.

What's happening: FORM_TITLE_LENGTH_WARNING = 65 (line 27) and the whole title FormControl block (label, Skeleton, Input, validation, length-warning, ~lines 71–90) are copied near-verbatim from CreateFormDetailsScreen.tsx (constant at line 35, block at lines 91–115).

Why it bites later: the two copies drift — a change to the warning threshold or the validation has to be made in both, and this cutover screen is temporary so divergence is likely.

Suggestion: pull the shared part into a small <FormTitleInput/> (both screens already share useFormTitleValidationRules and one formMethods), or at minimum export the constant.

Confidence: 82 — verified; raised by the Standards and Divergent axes.

🤖 This comment was generated by an AI code review. Please verify before acting on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

accepted. clarified with kenneth (pm) that we probably will keep this setup modal in the near/mid term, so ideally want to ensure this component is well implemented without dups. this will also accelerate AI coding in the future.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed, moved it into its own reusable component

publicKey: keypair.publicKey,
workspaceId,
emails: emails.filter(Boolean),
emails: emails ? emails.filter(Boolean) : cutoverDefaultEmails,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

suggestion (non-blocking): set the cutover email default at the form's defaultValues, not inside the submit handler.

What's happening: emails: emails ? emails.filter(Boolean) : cutoverDefaultEmails guards a field typed non-optional string[] (CreateFormWizardContext.tsx:26), but the new storage screen never registers emails, so at runtime it is undefined — the type and the runtime disagree. The [user.email] default itself is correct (it matches the legacy EmailFormRecipientsInput default).

Suggestion — pick one:

  • seed defaultValues.emails = user?.email ? [user.email] : [] when isMrfCutoverEnabled, next to the existing responseMode default, so the create handler stays honest with its type; or
  • narrow the type to emails?: string[].

Confidence: 75 — verified type/runtime mismatch; below the correctness gate's 80 bar, kept as Architecture / Divergent design feedback.

🤖 This comment was generated by an AI code review. Please verify before acting on it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

emails is indeed set as must be defined - it is a type violation for me to do a check for email definition here. will apply a fix to this and subsequent PRs.

@kevin9foong kevin9foong Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lets do option 2, following the mental model “We might not have emails in form state; fix it when we send.”

this keeps the same decision made in this PR but resolves the type violation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed!

features: { [featureFlags.mrfCutover]: { defaultValue: true } },
})

export const MrfCutoverOn = Template.bind({})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

issue (non-blocking): the storage-mode page itself has no Storybook story / Chromatic baseline.

What's happening: the added stories (MrfCutoverOn + the three beta-flag variants) all render only the MRF default screen. None has a play / userEvent step that clicks the escape-hatch link through to CreateFormStorageModeScreen, so that screen is never captured.

Why it matters: issue #9453 asks for stories covering "escape-hatch open / storage-mode page", and without one Chromatic won't baseline the new screen.

Suggestion: add one story with a play function that clicks the escape-hatch link and asserts the storage-mode page renders.

Confidence: 75 — verified unmet acceptance criterion; scored just under the Spec gate's 80 bar, included here as a follow-up.

🤖 This comment was generated by an AI code review. Please verify before acting on it.

@kevin9foong kevin9foong Jun 4, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

lets skip this for now, it is a good to have, but not strictly necessary.

@kevin9foong

Copy link
Copy Markdown
Contributor Author

Code Review: PR #9468 — refactor(mrf-cutover): lift wizard navigation + pass source as prop (2/6)

Author: kevin9foong · Base: developrefactor/modal-cutover-prep · +235 / −56 · 15 files · stacked PR 2/6

Overview

Scaffolding for the upcoming MRF cutover. Three concrete changes:

  1. Lifts MRF-cutover navigation (goToStorageModeDetails / goToMrfDetails, new StorageModeDetails flow state, isMrfCutoverEnabled flag) into the shared useCommonFormWizardProvider, threaded through all three modals (Create / Duplicate / Use-Template).
  2. Decouples DuplicateFormModal from React contextsource (form id + workspace id) now passed as explicit props instead of read from WorkspaceRowsContext / WorkspaceContext, so it can be mounted outside the workspace tree (e.g. from settings in PR 3/6).
  3. Adds two flagsmrf-cutover growthbook flag (defined, not activated) and createStorageModeForV1Webhook user beta flag (schema + zod type + test), plus a new composeEscapeHatchCopy util.

The refactor (#1, #2) is clean and the behavioral claim holds: with mrf-cutover off, defaults and navigation are unchanged.

Strengths

  • Prop-drilling over context is the right callDupeFormWizardSource makes the modal's dependencies explicit and testable, and the test file is correctly simplified (drops WorkspaceContext / WorkspaceRowsContext mocks, passes formIdToDuplicate directly).
  • The default-value override is correctly gated and spread-ordered so the flag wins only when on.
  • Good test coverage on the genuinely-logic-bearing piece (composeEscapeHatchCopy) including the 1/2/3-reason Oxford-comma branches and the falsy-flag case. Backend persistence test for the new beta flag is appropriate.
  • The whitespace-only cleanup of the Chakra RemoveScroll HACK comment is harmless.

Issues & Suggestions

1. Dead code in this PR (confirmed by grep). None of composeEscapeHatchCopy, StorageModeDetails, goToStorageModeDetails, or goToMrfDetails is consumed by any UI in this diff — they're inert until later PRs. That's defensible for stacked scaffolding, but two consequences worth deciding on:

  • If mrf-cutover were ever flipped on before PR 3+ lands, goToStorageModeDetails sets currentStep to StorageModeDetails, for which no screen renders → blank wizard step. Since the flag is off this is latent, but it's a footgun in a stacked series. Consider a code comment noting the screen arrives in a later PR.
  • Verify your lint/CI doesn't fail on unused exports.

2. composeEscapeHatchCopy bypasses i18n. Every other string in these modals goes through t(...), but this util hardcodes English ('Need payments? Use the ', 'old version of FormSG'). If this copy is user-facing it should likely be translatable, or at least follow the project's i18n convention. Flag for intent — acceptable if MRF cutover is deliberately SG-English-only and temporary.

3. formIdToDuplicate: FormId | undefined is required-but-nullable. Making the prop required (not ?:) while allowing undefined is a slightly awkward contract — every caller must pass it explicitly, but it can still be empty. That's intentional (forces callers to think about source), so fine, but consider documenting why it's | undefined rather than optional.

4. formId as FormId cast in PreviewFormBanner. formId comes from usePublicFormContext() (a string). The cast is reasonable for a branded id type, but if the context can yield an empty/undefined formId, the cast masks it. Low risk given usePreviewForm is gated on !!sourceFormId downstream.

Risk Assessment

Low. The behavior-changing path is feature-flagged off, the context→prop migration preserves the existing isDefaultWorkspace ? undefined : activeWorkspace._id logic (just relocated to WorkspaceRowsProvider), and tests were updated to match. Main residual risk is the latent blank-screen if the flag is enabled out of order — mitigated by the stacked-PR sequencing.

Verdict

Approve with minor comments. Solid, well-scoped refactor that does what the description says. I'd want answers on (2) i18n intent and a comment addressing (1) the order-of-activation footgun before merge, but neither blocks. The TC1 manual checklist (flag-off modal flows) is still unchecked — confirm that's been run.

made with claude's /review skill

@kevin9foong kevin9foong merged commit 46add49 into develop Jun 4, 2026
28 checks passed
@kevin9foong kevin9foong deleted the feat/create-form-modal-cutover branch June 4, 2026 04:12
@eliotlim eliotlim mentioned this pull request Jun 4, 2026
24 tasks
eliotlim added a commit that referenced this pull request Jun 4, 2026
* chore: add agent context and scratch to gitignore (#9544)

* Merge pull request #9545 from opengovsg/fix/optional-pdf-display

fix: render signature question with empty answer when signature is not captured in PDF

* Merge pull request #9465 from opengovsg/feat/create-form-modal-cutover

feat(mrf-cutover): default CreateFormModal to MRF with storage-mode escape hatch (4/6)

* fix(NumberField): remove +/- stepper buttons (#9444) (#9448)

Co-authored-by: dew1997 <109208984+dew1997@users.noreply.github.com>

* fix: enable line breaks in thank you page message (#9507)

* fix: enable line breaks in thank you page message (#9433)

* fix: lint frontend EndPageBlock (#9509)

Fix frontend lint error in EndPageBlock

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: Bhanu Pratap Singh Rathore <bhanur05@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>

* Merge pull request #9527 from opengovsg/fix/signature-hover-theme-color

fix(sig): match hover state to form theme color

* fix(table): add default for dropdown columns (#9526)

fix(table): add default for dropdown columns (#9476)

Co-authored-by: Raj Shekar Patha <rajashekarpatha07@gmail.com>

* feat(i18n): add landing payments and not found error pages (#9522)

* feat(i18n): add landing payments and not found error pages (#9497)

feat: add i18n for landing payments and not found error pages

* fix(i18n/payments): remove spurious whitespace

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(payments): add i18n t hook to `useCallback()`

---------

Co-authored-by: Samuel Tan <126168312+SAMTAN444@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* chore: bump version to 7.23.0

---------

Co-authored-by: Kevin Foong <55353265+kevin9foong@users.noreply.github.com>
Co-authored-by: scottheng96 <44297674+scottheng96@users.noreply.github.com>
Co-authored-by: LoneRifle <LoneRifle@users.noreply.github.com>
Co-authored-by: dew1997 <109208984+dew1997@users.noreply.github.com>
Co-authored-by: Bhanu Pratap Singh Rathore <bhanur05@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Raj Shekar Patha <rajashekarpatha07@gmail.com>
Co-authored-by: Samuel Tan <126168312+SAMTAN444@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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.

CreateFormModal: default to MRF + escape hatch + storage-mode page

2 participants