Skip to content

Commit

Permalink
feat: email <> storage mode parity (#7350)
Browse files Browse the repository at this point in the history
* feat: add basic email notifications page

* feat: update storage form types

* feat: update validation rules and add helper fns

* rename email notifications page name

* refactor tab rendering logic

* fix(admin-form): update Joi validation wrt emails and publicKey fields

Fixes #2264 by disallowing publicKey field for email mode forms and
disallowing emails field if not Email or Encrypt mode.

* feat(model): add emails field to encrypt forms

* fix(frontend): fix issue when getValues returns undefined

* refactor: make dataCollationData optional and add common types

* feat(encrypt-submission): update encrypt submissions to send email notifs

* fix: replace deprecated opaque type with tagged

* refactor: move email notifications and show payments tab by default

* fix: update mutations to fix failing validation at form wizard

* fix: remove unused type predicates

* feat(frontend): exclude email notifications from payment forms

* fix: move tabConfig to top level and separate label from path

* refactor: remove redundant useMemo and add EmailNotificationsHeader

* fix: update encrypt form validation to have string[] only

* fix(admin-form): enforce emails to be empty when duplicating to encrypt

* fix(test:backend): add and modify tests for emails field in encrypt form

* fix(test): ensure processDuplicateOverrideProps returns empty emails for encrypt

* fix(test): update ENCRYPT_FORM_DEFAULTS to have emails field

* fix: add exhaustive switch for enums

* fix: move boolean calc out and fix inline message in payments page for test env

* fix: update form encrypt schema

* fix: add PaymentsNotAllowed component

* fix: update comment for /settings controller and remove emails from duplicate dto

* fix(admin-form): validate that payment forms don't have emails and vice versa

* fix: add helper to extract emails from form types

* fix: cleanup

* fix: update use-template mutations

* fix: update payment form and emails to be mutually exclusive

* fix: check payments_field.enabled instead of making stripe call

* fix: check both PaymentChannel and enabled field for payment forms

* feat: move email notifications into a new tab

* feat: reduce opacity at component level and add stories for email notifs

* fix: add common payment fields to fix chromatic tests

* fix: update wrong link and remove padding

* fix: fix lodash merge array bug for chromatic test

* fix: lodash merge should only overwrite if override arr is empty

* fix: fix chromatic reviews

* fix: fix tag input UI

* fix: ensure non-zero admin emails length before sending

* fix: move invalid payment config check to svc instead of controller

* fix: fix incorrect error thrown and add jest test case for invalid payment update

* fix: remove unused import and move jest test

* test: update e2e test for email notification in encrypt form

* fix: fix failing backend jest tests

* fix: check for null + undefined and fix style

* fix: improve rendering of tabs to avoid inserting via idx

* fix: render null if not email or encrypt form

* fix: increase encrypt-submission timeout for checking for emails

* fix(test): fix e2e tests for new email notifications tab
  • Loading branch information
g-tejas committed Jun 19, 2024
1 parent eb8d5ba commit e20e67f
Show file tree
Hide file tree
Showing 44 changed files with 1,285 additions and 277 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
timeout-minutes: 15
- name: Run Playwright tests (encrypt-submission)
run: npx playwright test __tests__/e2e/encrypt-submission.spec.ts
timeout-minutes: 10
timeout-minutes: 15
- uses: actions/upload-artifact@v3
if: always()
with:
Expand Down
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ fi
# Initialise git-secrets configuration
git-secrets --register-aws > /dev/null

echo "Running git-secrets..."
echo "Running git-secrets pre_commit_hook"
# Scans all files that are about to be committed.
git-secrets --pre_commit_hook -- "$@"
39 changes: 27 additions & 12 deletions __tests__/e2e/helpers/createForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ const addSettings = async (
await expect(page).toHaveURL(ADMIN_FORM_PAGE_SETTINGS(formId))

await addGeneralSettings(page, formSettings)
// Encrypt mode forms don't have an email
if (formResponseMode.responseMode === FormResponseMode.Encrypt) {
await addAdminEmails(page, formSettings)
}
await addAuthSettings(page, formSettings)
await addCollaborators(page, formSettings)

Expand Down Expand Up @@ -271,18 +275,6 @@ const addGeneralSettings = async (
.getByLabel('Set message for closed form')
.fill(formSettings.closedFormMessage)
}

if (formSettings.emails) {
const emailInput = page.getByLabel('Emails where responses will be sent')
await emailInput.focus()

// Clear the current admin email
await page.keyboard.press('Backspace')

await emailInput.fill(formSettings.emails.join(', '))

await expectToast(page, /emails successfully updated/i)
}
}

/** Goes to Singpass settings frame and adds auth settings.
Expand Down Expand Up @@ -363,6 +355,29 @@ const addCollaborators = async (
await page.getByRole('button', { name: 'Close' }).click()
}

const addAdminEmails = async (
page: Page,
formSettings: E2eSettingsOptions,
): Promise<void> => {
await page.getByRole('tab', { name: 'Email notifications' }).click()

// Ensure that we are on the email notifications page
await expect(
page.getByRole('heading', { name: 'Email notifications' }),
).toBeVisible()

if (formSettings.emails) {
const emailInput = page.getByLabel('Send an email copy of new responses')
await emailInput.focus()

// Clear the current admin email
await page.keyboard.press('Backspace')

await emailInput.fill(formSettings.emails.join(', '))

await expectToast(page, /emails successfully updated/i)
}
}
const addFieldsAndLogic = async (
page: Page,
{
Expand Down
62 changes: 59 additions & 3 deletions __tests__/e2e/helpers/verifySubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,67 @@ export const verifyEncryptSubmission = async (
page: Page,
{
form,
responseId,
formFields,
formSettings,
secretKey,
}: // responseId,
// formFields,
VerifySubmissionBaseInputs & { secretKey: string },
}: VerifySubmissionBaseInputs & { secretKey: string },
): Promise<void> => {
if (formSettings.emails) {
// (Optional) Step 1: Verify that there's an email notification in maildev
// Get the submission from the email, via the subject.
const submission = await getSubmission(form.title, responseId)

// Verify email metadata
expect(submission.from).toContain(MAIL_FROM)

const emails = formSettings.emails

emails.unshift(ADMIN_EMAIL)

for (const email of emails) {
expect(submission.to).toContain(email)
}

// Subject need not be verified, since we got the email via the subject.

const expectSubmissionContains = expectContains(submission.html)

// Verify form responses in email
for (const field of formFields) {
const responseArray = getResponseArray(field, {
mode: FormResponseMode.Email,
})
if (!responseArray) continue
expectSubmissionContains([
getResponseTitle(field, { mode: FormResponseMode.Email }),
...responseArray,
])
expectAttachment(field, submission.attachments)
}

if (formSettings.authType !== FormAuthType.NIL) {
// Verify that form auth correctly returned NRIC (SPCP/SGID) and UEN (CP)
if (!formSettings.nric) throw new Error('No nric provided!')
switch (formSettings.authType) {
case FormAuthType.SP:
case FormAuthType.MyInfo:
expectSubmissionContains([SPCPFieldTitle.SpNric, formSettings.nric])
break
case FormAuthType.CP:
expectSubmissionContains([SPCPFieldTitle.CpUid, formSettings.nric])
if (!formSettings.uen) throw new Error('No uen provided!')
expectSubmissionContains([SPCPFieldTitle.CpUen, formSettings.uen])
break
case FormAuthType.SGID:
expectSubmissionContains([SgidFieldTitle.SgidNric, formSettings.nric])
break
}
}
}

// Step 2: Download the response using secret key

// Go to the responses summary page and enter the secret key
await page.goto(ADMIN_FORM_PAGE_RESPONSES(form._id))
await page.getByLabel(/Enter or upload Secret Key/).fill(secretKey)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/constants/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const LANDING_PAGE_EXAMPLE_FORMS = [

export const OGP_ALL_PRODUCTS = 'https://www.open.gov.sg/products/overview'
export const OGP_POSTMAN = 'https://postman.gov.sg'
export const OGP_PLUMBER = 'https://plumber.gov.sg/'
export const OGP_SGID = 'https://go.gov.sg/sgid-formsg'

export const OGP_FORMSG_REPO = 'https://github.com/opengovsg/formsg'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Meta, Story } from '@storybook/react'

import { PaymentChannel, PaymentType } from '~shared/types'
import { FormResponseMode, FormSettings, FormStatus } from '~shared/types/form'

import {
getAdminFormSettings,
patchAdminFormSettings,
} from '~/mocks/msw/handlers/admin-form'

import {
getMobileViewParameters,
StoryRouter,
viewports,
} from '~utils/storybook'

import { SettingsEmailsPage } from './SettingsEmailsPage'

const PAYMENTS_ENABLED = {
payments_channel: {
channel: PaymentChannel.Stripe,
target_account_id: 'target-account-id',
publishable_key: 'publishable-key',
payment_methods: [],
},
payments_field: {
enabled: true,
description: 'description',
name: 'name',
amount_cents: 1,
min_amount: 1,
max_amount: 1,
payment_type: PaymentType.Products,
global_min_amount_override: 0,
gst_enabled: true,
products: [],
products_meta: {
multi_product: false,
},
},
}

const PAYMENTS_DISABLED = {
payments_channel: {
channel: PaymentChannel.Unconnected,
target_account_id: '',
publishable_key: '',
payment_methods: [],
},
payments_field: {
enabled: false,
description: '',
name: '',
amount_cents: 0,
min_amount: 0,
max_amount: 0,
payment_type: PaymentType.Products,
global_min_amount_override: 0,
gst_enabled: true,
products: [],
products_meta: {
multi_product: false,
},
},
}

const buildMswRoutes = ({
overrides,
mode,
delay,
}: {
overrides?: Partial<FormSettings>
mode?: FormResponseMode
delay?: number | 'infinite'
} = {}) => [
getAdminFormSettings({ overrides, mode, delay }),
patchAdminFormSettings({ overrides, mode, delay }),
]

export default {
title: 'Pages/AdminFormPage/Settings/Emails',
component: SettingsEmailsPage,
decorators: [StoryRouter({ initialEntries: ['/12345'], path: '/:formId' })],
parameters: {
// Required so skeleton "animation" does not hide content.
chromatic: { pauseAnimationAtEnd: true },
msw: buildMswRoutes(),
},
} as Meta

const Template: Story = () => <SettingsEmailsPage />

export const PrivateStorageForm = Template.bind({})
PrivateStorageForm.parameters = {
msw: buildMswRoutes({
mode: FormResponseMode.Encrypt,
overrides: {
status: FormStatus.Private,
emails: [], // has one email by default
...PAYMENTS_DISABLED,
},
}),
}

export const PrivateEmailForm = Template.bind({})
PrivateEmailForm.parameters = {
msw: buildMswRoutes({
mode: FormResponseMode.Email,
overrides: {
status: FormStatus.Private,
},
}),
}

export const PublicForm = Template.bind({})
PublicForm.parameters = {
msw: buildMswRoutes({
mode: FormResponseMode.Encrypt,
overrides: {
status: FormStatus.Public,
emails: [],
...PAYMENTS_DISABLED,
},
}),
}

export const PaymentForm = Template.bind({})
PaymentForm.parameters = {
msw: buildMswRoutes({
mode: FormResponseMode.Encrypt,
overrides: {
status: FormStatus.Private,
emails: [],
...PAYMENTS_ENABLED,
},
}),
}

export const Loading = Template.bind({})
Loading.parameters = {
msw: buildMswRoutes({ delay: 'infinite' }),
}

export const NoEmailsAddedForm = Template.bind({})
NoEmailsAddedForm.parameters = {
msw: buildMswRoutes({
mode: FormResponseMode.Encrypt,
overrides: {
status: FormStatus.Private,
emails: [],
...PAYMENTS_DISABLED,
},
}),
}

export const Mobile = Template.bind({})
Mobile.parameters = {
...NoEmailsAddedForm,
...getMobileViewParameters(),
}

export const Tablet = Template.bind({})
Tablet.parameters = {
...NoEmailsAddedForm,
viewport: {
defaultViewport: 'tablet',
},
chromatic: { viewports: [viewports.md] },
}
49 changes: 49 additions & 0 deletions frontend/src/features/admin-form/settings/SettingsEmailsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { FormControl, Skeleton } from '@chakra-ui/react'

import { FormResponseMode } from '~shared/types'

import FormLabel from '~components/FormControl/FormLabel'
import { TagInput } from '~components/TagInput'

import { CategoryHeader } from './components/CategoryHeader'
import { EmailFormSection } from './components/EmailFormSection'
import { useAdminFormSettings } from './queries'

const AdminEmailSection = () => {
const { data: settings } = useAdminFormSettings()

if (!settings) {
return <EmailFormSectionSkeleton />
}

const isEmailOrStorageMode =
settings?.responseMode === FormResponseMode.Email ||
settings?.responseMode === FormResponseMode.Encrypt

// should render null
if (!isEmailOrStorageMode) {
return false
}

return <EmailFormSection settings={settings} />
}

const EmailFormSectionSkeleton = (): JSX.Element => {
return (
<FormControl isRequired>
<FormLabel>Send an email copy of new responses</FormLabel>
<Skeleton>
<TagInput placeholder="me@example.com" isDisabled />
</Skeleton>
</FormControl>
)
}

export const SettingsEmailsPage = (): JSX.Element => {
return (
<>
<CategoryHeader>Email notifications</CategoryHeader>
<AdminEmailSection />
</>
)
}
Loading

0 comments on commit e20e67f

Please sign in to comment.