From 35a7fc2bb9279e9491daa425e8f979b3b5f9172c Mon Sep 17 00:00:00 2001 From: hueyyin Date: Wed, 15 Jul 2026 12:17:43 +0000 Subject: [PATCH 1/4] feat(webhooks): add workflow-data callout component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins wiring a webhook on an MRF form have no signal that only Plumber can act on data from every workflow step — other tools only use the first step's fields. Add a self-contained info callout (InlineMessage, markdown copy from a single i18n key) linking to Plumber and the simplified-modes FAQ, so the heads-up lives next to the endpoint URL where the integration decision is made. Co-Authored-By: Claude Fable 5 --- .../WebhookWorkflowInfobox.stories.tsx | 12 ++++++ .../WebhookWorkflowInfobox.test.tsx | 41 +++++++++++++++++++ .../WebhookWorkflowInfobox.tsx | 19 +++++++++ .../admin-form/settings/webhooks/en-sg.ts | 1 + .../admin-form/settings/webhooks/index.ts | 1 + 5 files changed, 74 insertions(+) create mode 100644 apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.stories.tsx create mode 100644 apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.test.tsx create mode 100644 apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.tsx diff --git a/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.stories.tsx b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.stories.tsx new file mode 100644 index 0000000000..4463833214 --- /dev/null +++ b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.stories.tsx @@ -0,0 +1,12 @@ +import { Meta, StoryFn } from '@storybook/react' + +import { WebhookWorkflowInfobox } from './WebhookWorkflowInfobox' + +export default { + title: 'Features/AdminForm/Settings/WebhookWorkflowInfobox', + component: WebhookWorkflowInfobox, +} as Meta + +const Template: StoryFn = () => + +export const Default = Template.bind({}) diff --git a/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.test.tsx b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.test.tsx new file mode 100644 index 0000000000..a40050338b --- /dev/null +++ b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.test.tsx @@ -0,0 +1,41 @@ +import { composeStories } from '@storybook/react' +import { render, screen } from '@testing-library/react' + +import { MRF_CUTOVER_FAQ_LINK } from 'formsg-shared/constants/links' + +import { OGP_PLUMBER } from '~constants/links' + +import * as stories from './WebhookWorkflowInfobox.stories' + +const { Default } = composeStories(stories) + +describe('WebhookWorkflowInfobox', () => { + it('renders the workflow-data heads-up message', () => { + render() + + expect( + screen.getByText(/can use data from all workflow steps/i), + ).toBeInTheDocument() + expect( + screen.getByText(/other tools can only use data from the first step/i), + ).toBeInTheDocument() + }) + + it('links "Plumber" to plumber.gov.sg', () => { + render() + + expect(screen.getByRole('link', { name: 'Plumber' })).toHaveAttribute( + 'href', + OGP_PLUMBER, + ) + }) + + it('links "Learn more" to the simplified-modes FAQ', () => { + render() + + expect(screen.getByRole('link', { name: /learn more/i })).toHaveAttribute( + 'href', + MRF_CUTOVER_FAQ_LINK, + ) + }) +}) diff --git a/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.tsx b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.tsx new file mode 100644 index 0000000000..97308f2f32 --- /dev/null +++ b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhookWorkflowInfobox.tsx @@ -0,0 +1,19 @@ +import { useTranslation } from 'react-i18next' + +import { MRF_CUTOVER_FAQ_LINK } from 'formsg-shared/constants/links' + +import { OGP_PLUMBER } from '~constants/links' +import InlineMessage from '~components/InlineMessage' + +export const WebhookWorkflowInfobox = (): JSX.Element => { + const { t } = useTranslation() + + return ( + + {t('features.adminForm.settings.webhooks.workflowInfobox', { + plumberUrl: OGP_PLUMBER, + learnMoreUrl: MRF_CUTOVER_FAQ_LINK, + })} + + ) +} diff --git a/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/en-sg.ts b/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/en-sg.ts index 89daa00739..e0c34d07cc 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/en-sg.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/en-sg.ts @@ -9,4 +9,5 @@ export const enSG = { label: 'Enable retries', description: `Your system must meet certain requirements before retries can be safely enabled. [Learn more]({url})`, }, + workflowInfobox: `If workflows are enabled, only [Plumber]({plumberUrl}) can use data from all workflow steps. Other tools can only use data from the first step. [Learn more]({learnMoreUrl})`, } diff --git a/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/index.ts b/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/index.ts index 67d797b927..d6c4b11089 100644 --- a/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/index.ts +++ b/apps/frontend/src/i18n/locales/features/admin-form/settings/webhooks/index.ts @@ -11,4 +11,5 @@ export interface Webhooks extends HasTitle { label: string description: string } + workflowInfobox: string } From d40139fd76fd2c61d9017a0ddc1b1dcb4044d288 Mon Sep 17 00:00:00 2001 From: hueyyin Date: Wed, 15 Jul 2026 12:20:40 +0000 Subject: [PATCH 2/4] feat(webhooks): show workflow callout on MRF webhook settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the workflow-data callout on response mode alone: the page computes showWorkflowInfobox = (responseMode === Multirespondent) and passes it into WebhooksSection, which renders the callout above the endpoint URL input. No re-check of enable-mrf-webhooks (already guaranteed by the page-level enableWebhooks gate) and no mrf-cutover dependency (that flag governs form-creation defaults, not webhook messaging). Page tests pin the two-quadrant visibility matrix — shown for MRF, hidden for Storage — and an MrfMode story locks the MRF state in Chromatic. Co-Authored-By: Claude Fable 5 --- .../settings/SettingsWebhooksPage.stories.tsx | 24 +++++++++++++++++++ .../settings/SettingsWebhooksPage.test.tsx | 24 +++++++++++++++++++ .../settings/SettingsWebhooksPage.tsx | 5 +++- .../WebhooksSection/WebhooksSection.tsx | 10 +++++++- 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.test.tsx diff --git a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx index 6839e7fea1..3a779d2e51 100644 --- a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx +++ b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx @@ -94,6 +94,30 @@ StorageModeRetryEnabled.parameters = { }, } +const mrfWebhooksOnGrowthBook = new GrowthBook({ + features: { [featureFlags.enableMrfWebhooks]: { defaultValue: true } }, +}) + +export const MrfMode = Template.bind({}) +MrfMode.decorators = [ + (Story) => ( + + + + ), +] +MrfMode.parameters = { + msw: { + handlers: { + default: buildMswRoutes({ + overrides: { + responseMode: FormResponseMode.Multirespondent, + }, + }), + }, + }, +} + export const UnsupportedEmailMode = Template.bind({}) export const UnsupportedMultirespondentMode = Template.bind({}) diff --git a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.test.tsx b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.test.tsx new file mode 100644 index 0000000000..0b8d706af0 --- /dev/null +++ b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.test.tsx @@ -0,0 +1,24 @@ +import { composeStories } from '@storybook/react' +import { render, screen } from '@testing-library/react' + +import * as stories from './SettingsWebhooksPage.stories' + +const { MrfMode, StorageModeEmpty } = composeStories(stories) + +const WORKFLOW_CALLOUT_TEXT = /can use data from all workflow steps/i + +describe('SettingsWebhooksPage workflow callout visibility', () => { + it('shows the callout on an MRF form', async () => { + render() + await screen.findByText('Endpoint URL') + + expect(screen.getByText(WORKFLOW_CALLOUT_TEXT)).toBeInTheDocument() + }) + + it('hides the callout on a Storage form', async () => { + render() + await screen.findByText('Endpoint URL') + + expect(screen.queryByText(WORKFLOW_CALLOUT_TEXT)).not.toBeInTheDocument() + }) +}) diff --git a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.tsx b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.tsx index cf08618b70..02e11ea9a0 100644 --- a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.tsx +++ b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.tsx @@ -31,12 +31,15 @@ export const SettingsWebhooksPage = (): JSX.Element => { ) } + const showWorkflowInfobox = + settings?.responseMode === FormResponseMode.Multirespondent + return ( {t('features.adminForm.settings.webhooks.title')} - + ) } diff --git a/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhooksSection.tsx b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhooksSection.tsx index f47f0fba6b..0a45c25a7d 100644 --- a/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhooksSection.tsx +++ b/apps/frontend/src/features/admin-form/settings/components/WebhooksSection/WebhooksSection.tsx @@ -2,10 +2,18 @@ import { Stack } from '@chakra-ui/react' import { RetryToggle } from './RetryToggle' import { WebhookUrlInput } from './WebhookUrlInput' +import { WebhookWorkflowInfobox } from './WebhookWorkflowInfobox' -export const WebhooksSection = (): JSX.Element => { +interface WebhooksSectionProps { + showWorkflowInfobox: boolean +} + +export const WebhooksSection = ({ + showWorkflowInfobox, +}: WebhooksSectionProps): JSX.Element => { return ( + {showWorkflowInfobox && } From fe0f7ef8452b3a9aa71f99e50b2785c959435666 Mon Sep 17 00:00:00 2001 From: hueyyin Date: Wed, 15 Jul 2026 12:22:41 +0000 Subject: [PATCH 3/4] refactor(webhooks): dedupe GrowthBook story decorators into helper Two stories now stand up identical GrowthBook provider boilerplate to flip a feature flag on. Extract a withGrowthBookFeatures(...flags) helper so each story declares just the flags it needs. No behaviour change. Co-Authored-By: Claude Fable 5 --- .../settings/SettingsWebhooksPage.stories.tsx | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx index 3a779d2e51..c992288ed1 100644 --- a/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx +++ b/apps/frontend/src/features/admin-form/settings/SettingsWebhooksPage.stories.tsx @@ -1,5 +1,5 @@ import { GrowthBook, GrowthBookProvider } from '@growthbook/growthbook-react' -import { Meta, StoryFn } from '@storybook/react' +import { Decorator, Meta, StoryFn } from '@storybook/react' import { featureFlags } from 'formsg-shared/constants' import { FormResponseMode, FormSettings } from 'formsg-shared/types/form' @@ -53,17 +53,22 @@ StorageModeEmpty.parameters = { }, } -const mrfCutoverOnGrowthBook = new GrowthBook({ - features: { [featureFlags.mrfCutover]: { defaultValue: true } }, -}) +const withGrowthBookFeatures = (...flags: string[]): Decorator => { + const growthbook = new GrowthBook({ + features: Object.fromEntries( + flags.map((flag) => [flag, { defaultValue: true }]), + ), + }) + return (Story) => ( + + + + ) +} export const StorageModeMrfCutoverOn = Template.bind({}) StorageModeMrfCutoverOn.decorators = [ - (Story) => ( - - - - ), + withGrowthBookFeatures(featureFlags.mrfCutover), ] StorageModeMrfCutoverOn.parameters = { msw: { @@ -94,18 +99,8 @@ StorageModeRetryEnabled.parameters = { }, } -const mrfWebhooksOnGrowthBook = new GrowthBook({ - features: { [featureFlags.enableMrfWebhooks]: { defaultValue: true } }, -}) - export const MrfMode = Template.bind({}) -MrfMode.decorators = [ - (Story) => ( - - - - ), -] +MrfMode.decorators = [withGrowthBookFeatures(featureFlags.enableMrfWebhooks)] MrfMode.parameters = { msw: { handlers: { From 11d514101ba06d8d3c5a64f89c62fc64bad3a271 Mon Sep 17 00:00:00 2001 From: hueyyin Date: Wed, 15 Jul 2026 12:59:35 +0000 Subject: [PATCH 4/4] docs(webhooks): add PR body and review findings for workflow infobox Working docs assembled by prepare-for-review: the PR body (with before/after Storybook captures) and the multi-axis review findings, kept in-repo so reviewers can see the review disposition alongside the change. Co-Authored-By: Claude Fable 5 --- docs/webhooks-workflow-infobox-pr-body.md | 46 +++++++++++++++++++ ...bhooks-workflow-infobox-review-findings.md | 29 ++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 docs/webhooks-workflow-infobox-pr-body.md create mode 100644 docs/webhooks-workflow-infobox-review-findings.md diff --git a/docs/webhooks-workflow-infobox-pr-body.md b/docs/webhooks-workflow-infobox-pr-body.md new file mode 100644 index 0000000000..a1e51e5f18 --- /dev/null +++ b/docs/webhooks-workflow-infobox-pr-body.md @@ -0,0 +1,46 @@ +# PR body — feat(webhooks): show workflow-data callout on MRF webhook settings + +## Problem + +Admins can connect a webhook to an MRF form to send each submission to another tool in real time, but not every destination can do the same thing with it: only Plumber can use the multi-step workflow data across all steps — any other tool can only use the first step's data. Admins have no way to know this at the moment they configure a webhook, so they may wire up the wrong integration and be surprised when their tool can't act on later steps. + +## Solution + +Adds an always-on informational callout at the top of the webhook settings, above the endpoint URL field, telling admins that if workflows are enabled, only [Plumber](https://plumber.gov.sg/) can use data from all workflow steps while other tools can only use data from the first step, with a "Learn more" link to the simplified-modes FAQ. The callout is a neutral heads-up, not an error, and shows regardless of whether a workflow is configured yet or whether the entered URL is a Plumber link. + +Visibility is gated on response mode alone: `SettingsWebhooksPage` computes `showWorkflowInfobox = responseMode === Multirespondent` and passes a single boolean into `WebhooksSection`. No feature flag is checked at this line — `enable-mrf-webhooks` is already guaranteed true on this code path by the page's existing `enableWebhooks` gate, and `mrf-cutover` governs form-creation defaults, a separate concern from webhook messaging. + +**Alternatives considered** + +- We considered having `WebhooksSection` (or the callout itself) read `useAdminFormSettings()` directly, like its siblings `WebhookUrlInput` and `RetryToggle` do. We kept the visibility decision at the page instead: the page already owns the settings query and the response-mode branching, so the section stays a dumb layout component with no data lookups of its own, and the callout stays a prop-less, non-fetching component that tests in isolation. +- An earlier draft (the `webhooks-workflow-callout` branch) gated the callout on `mrf-cutover` && MRF. We dropped the cutover flag: it couples the callout to the cutover rollout, while the capability distinction the copy describes is true regardless of cutover state. + +**Screenshots** + +Captured from the page stories in Storybook. The MRF "before" is unavailable via Storybook — the MRF story is introduced by this PR; on develop the same MRF form rendered the section without the callout, visually identical to the Storage row. + +| Page | Before | After | +| --- | --- | --- | +| Settings → Webhooks (MRF form, `enable-mrf-webhooks` on) | unavailable — see note above | ![after MRF](https://raw.githubusercontent.com/yin-boop/FormSG/screenshots/.github/screenshots/webhooks-infobox/after-webhook-settings-mrf.png) | +| Settings → Webhooks (Storage form — unchanged) | ![before Storage](https://raw.githubusercontent.com/yin-boop/FormSG/screenshots/.github/screenshots/webhooks-infobox/before-webhook-settings-storage.png) | ![after Storage](https://raw.githubusercontent.com/yin-boop/FormSG/screenshots/.github/screenshots/webhooks-infobox/after-webhook-settings-storage.png) | + +**Breaking Changes** + +No - backwards compatible. + +## Tests + +**TC1: Callout shows on an MRF form** + +- [ ] Enable the `enable-mrf-webhooks` GrowthBook flag for your admin +- [ ] Open Settings → Webhooks on a Multirespondent form +- [ ] An info (not error) callout appears above the Endpoint URL field +- [ ] "Plumber" opens https://plumber.gov.sg/ and "Learn more" opens https://go.gov.sg/formsg-guide-faq-simplified-modes + +**TC2: Callout hidden on a Storage-mode form** + +- [ ] Open Settings → Webhooks on a Storage-mode form — webhook settings render with no callout + +**TC3: Callout is unconditional on workflow/URL** + +- [ ] On the MRF form, confirm the callout shows before any workflow steps exist and does not change when a Plumber URL is entered diff --git a/docs/webhooks-workflow-infobox-review-findings.md b/docs/webhooks-workflow-infobox-review-findings.md new file mode 100644 index 0000000000..e42aa0987e --- /dev/null +++ b/docs/webhooks-workflow-infobox-review-findings.md @@ -0,0 +1,29 @@ +# Review findings — webhooks-workflow-infobox vs develop + +Multi-axis review (`/review`) of the 3-commit diff adding the webhook workflow-data callout, its MRF-only gate, and the two-quadrant visibility tests. Each axis ran as an independent reviewer; Standards/Spec findings were verified against false positives by independent per-finding verifiers (cutoff 70). + +## Standards + +**Clean.** No documented-standard violations — i18n key placement and interpolation match `retry.description` exactly, tests follow the repo's `composeStories` pattern, and the story title prefix matches convention. The most borderline drop (scored 60 on independent verification, below cutoff): + +- The callout takes a new, untagged dependency on `MRF_CUTOVER_FAQ_LINK`, a constant `WorkspacePage.tsx:53` explicitly plans to remove ("Remove this infobox (and MRF_CUTOVER_FAQ_LINK) once the cutover is complete"). The verifier confirmed the maintenance risk is real but the issue spec explicitly directs this link and acknowledges the constant's name is a misnomer while the linked content stays accurate. Cheap defusal if desired: rename the constant (e.g. `SIMPLIFIED_MODES_FAQ_LINK`) or amend the WorkspacePage TODO so cutover cleanup doesn't delete a constant this permanent callout still uses. + +## Spec + +**Clean.** All eight acceptance criteria verified met against the diff: placement above the endpoint URL field, gate is `responseMode === Multirespondent` only (no `mrf-cutover`, no `enableMrfWebhooks` re-check), character-exact copy sourced from the `workflowInfobox` i18n key, correct Plumber/FAQ hrefs, info variant, two-quadrant page tests, component copy/link tests, and an MRF page story. Two spec items confirmed moot due to spec staleness rather than diff defects: + +- `MRF_CUTOVER_FAQ_LINK` already exists in `packages/shared/constants/links.ts` on develop, so no shared-package change was needed. +- `WebhookV1SchemaInfobox` no longer exists anywhere in the repo (removed on develop in `62ea89447`), so the "leave its gate untouched" criterion is satisfied vacuously. + +One informational note dropped at 50 (below cutoff): converting the pre-existing `StorageModeMrfCutoverOn` story to the new `withGrowthBookFeatures` helper is a behaviour-preserving refactor the spec didn't request; the spec's out-of-scope list doesn't forbid it. + +## Architecture / Divergent (merged — both axes flagged the same two lines) + +1. **`showWorkflowInfobox` prop-drilling diverges from the section's own pattern** (`WebhooksSection.tsx:7`; also raised by Standards below threshold). Both siblings (`WebhookUrlInput`, `RetryToggle`) read `useAdminFormSettings()` themselves and React Query dedupes the fetch, so the section could derive the boolean locally, deleting the prop and the page-level computation; the story-based tests would pass unchanged. **Orchestrator note:** the issue spec and PRD explicitly mandate the current shape ("passes a single boolean into `WebhooksSection`, which keeps it a dumb layout component free of its own data lookups"), and a breadcrumb records it — so this finding re-litigates a spec decision. Acting on it is a spec change, not a fix. +2. **`withGrowthBookFeatures` helper deduped into the wrong home** (`SettingsWebhooksPage.stories.tsx:56`; flagged by Architecture, Divergent, and Standards). The identical GrowthBook decorator boilerplate exists in at least three other story files (`CreateFormModal`, `DuplicateFormModal`, `UseTemplateModal` stories), and shared story helpers already live in `src/utils/storybook.tsx`. Hoisting the new helper there costs the same as defining it locally and prevents a fifth copy; migrating the other three files can stay a follow-up. + +All other choices were examined by the Divergent axis and confirmed optimal: always-on callout rather than workflow-presence detection, single i18n key with markdown links and URL interpolation, reuse of `InlineMessage variant="info" useMarkdown`, and `composeStories`-based tests. + +## Summary + +2 surviving findings (both Architecture+Divergent, cross-axis merged); Standards and Spec clean. Worst issue: the helper's placement (the one clearly actionable in-PR change), with the prop-drilling finding gated on whether the spec's structure decision should be revisited. 3 findings dropped below threshold (scores 60, 50, and two sub-threshold Standards duplicates of the judgement findings).