diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e25978b6..8200ec02e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,32 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [v6.109.0](https://github.com/opengovsg/FormSG/compare/v6.109.0...v6.109.0) + +- revert: "chore(dev): add virus scanner npm install to root install command" [`#7082`](https://github.com/opengovsg/FormSG/pull/7082) + +#### [v6.109.0](https://github.com/opengovsg/FormSG/compare/v6.108.0...v6.109.0) + +> 14 February 2024 + +- chore: add payments by product backend validation [`#7077`](https://github.com/opengovsg/FormSG/pull/7077) +- chore: upgrade mongodb server core to 9.1.6 [`#7052`](https://github.com/opengovsg/FormSG/pull/7052) +- feat(mrf): workflow builder [`#7072`](https://github.com/opengovsg/FormSG/pull/7072) +- chore(dev): add virus scanner npm install to root install command [`#7069`](https://github.com/opengovsg/FormSG/pull/7069) +- feat: update payments thank you page header [`#7020`](https://github.com/opengovsg/FormSG/pull/7020) +- feat: encryption boundary shift in preview mode [`#7071`](https://github.com/opengovsg/FormSG/pull/7071) +- build: merge release v6.108.0 into develop [`#7068`](https://github.com/opengovsg/FormSG/pull/7068) +- build: release v6.108.0 [`#7067`](https://github.com/opengovsg/FormSG/pull/7067) +- chore: bump version to v6.109.0 [`60b2cff`](https://github.com/opengovsg/FormSG/commit/60b2cff1a23fbbca938c2d9eba9110849c624565) + #### [v6.108.0](https://github.com/opengovsg/FormSG/compare/v6.107.0...v6.108.0) +> 6 February 2024 + - feat(payment): webhook with charge information [`#7058`](https://github.com/opengovsg/FormSG/pull/7058) - build: merge release v6.107.0 into develop [`#7064`](https://github.com/opengovsg/FormSG/pull/7064) - build: release v6.107.0 [`#7063`](https://github.com/opengovsg/FormSG/pull/7063) +- chore: bump version to v6.108.0 [`6fb5f4b`](https://github.com/opengovsg/FormSG/commit/6fb5f4bab18df0897dd589d28107fbae8ac15c6b) #### [v6.107.0](https://github.com/opengovsg/FormSG/compare/v6.106.0...v6.107.0) @@ -175,14 +196,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix(deps): bump type-fest from 4.8.2 to 4.8.3 in /shared [`#6943`](https://github.com/opengovsg/FormSG/pull/6943) - build: merge release v6.92.0 into develop [`#6934`](https://github.com/opengovsg/FormSG/pull/6934) - build: release v6.92.0 [`#6932`](https://github.com/opengovsg/FormSG/pull/6932) -- feat: set secret key input to password type on activation modal [`#6933`](https://github.com/opengovsg/FormSG/pull/6933) -- chore: bump version to v6.92.0 [`28a8b9c`](https://github.com/opengovsg/FormSG/commit/28a8b9ca95f456c0f2c95d22813bf6d2ae1509ed) - chore: bump version to v6.93.0 [`f7e9dcf`](https://github.com/opengovsg/FormSG/commit/f7e9dcf49f5104815f11ecad6c48d6e71a1e1bf8) #### [v6.92.0](https://github.com/opengovsg/FormSG/compare/v6.91.1...v6.92.0) > 28 November 2023 +- feat: set secret key input to password type on activation modal [`#6933`](https://github.com/opengovsg/FormSG/pull/6933) - fix: add myinfo errors to error map for storage-mode submissions [`#6931`](https://github.com/opengovsg/FormSG/pull/6931) - feat(FE): set secret key input to password type [`#6930`](https://github.com/opengovsg/FormSG/pull/6930) - feat: add prefills for variable payments [`#6899`](https://github.com/opengovsg/FormSG/pull/6899) @@ -198,7 +218,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: omit isVisible property from webhook response [`#6907`](https://github.com/opengovsg/FormSG/pull/6907) - feat: charts [`#6790`](https://github.com/opengovsg/FormSG/pull/6790) - build: merge release 6.90.0 to develop [`#6914`](https://github.com/opengovsg/FormSG/pull/6914) -- chore: bump version to v6.92.0 [`72fac02`](https://github.com/opengovsg/FormSG/commit/72fac021a92df588be577c25690b49e96796387d) +- chore: bump version to v6.92.0 [`28a8b9c`](https://github.com/opengovsg/FormSG/commit/28a8b9ca95f456c0f2c95d22813bf6d2ae1509ed) #### [v6.91.1](https://github.com/opengovsg/FormSG/compare/v6.91.0...v6.91.1) @@ -230,25 +250,17 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - build: merge release v6.89.1 into develop [`#6905`](https://github.com/opengovsg/FormSG/pull/6905) - chore: bump version to v6.90.0 [`c03692e`](https://github.com/opengovsg/FormSG/commit/c03692e3d9aa64afa8007dffecfd9871542f4759) -#### [v6.89.2](https://github.com/opengovsg/FormSG/compare/v6.89.1...v6.89.2) +#### [v6.89.2](https://github.com/opengovsg/FormSG/compare/v6.89.0...v6.89.2) > 17 November 2023 - fix: hotfix v6.89.1 for proper error handling in encrypt-submission middleware [`#6903`](https://github.com/opengovsg/FormSG/pull/6903) +- build: release v6.89.0 [`#6898`](https://github.com/opengovsg/FormSG/pull/6898) +- fix: remove myinfo child from storage mode [`#6901`](https://github.com/opengovsg/FormSG/pull/6901) - chore: revert commit 6869 [`efab3cf`](https://github.com/opengovsg/FormSG/commit/efab3cf844113573d758b1b2c57147d5d0656a28) - chore: bump version to 6.89.2 [`1f4e9f7`](https://github.com/opengovsg/FormSG/commit/1f4e9f70cd33ac2f015fb902de5243a4227e4981) - fix: remove error block [`fb415fc`](https://github.com/opengovsg/FormSG/commit/fb415fcd7189a90056557c242ed98f1dc10e757e) -#### [v6.89.1](https://github.com/opengovsg/FormSG/compare/v6.89.0...v6.89.1) - -> 16 November 2023 - -- build: release v6.89.0 [`#6898`](https://github.com/opengovsg/FormSG/pull/6898) -- fix: remove myinfo child from storage mode [`#6901`](https://github.com/opengovsg/FormSG/pull/6901) -- chore: bump version to 6.89.1 [`253dd25`](https://github.com/opengovsg/FormSG/commit/253dd2596844d28e5dc3caae298fc775fb1a3f75) -- fix: add error handling [`d6c4985`](https://github.com/opengovsg/FormSG/commit/d6c4985aa8e35dd2278af9b70d00d4e86a48bde1) -- fix: remove email mode from myinfo limit message [`5a45c98`](https://github.com/opengovsg/FormSG/commit/5a45c980dbe3fc8c15eacb3ff1827f3003fcbfc4) - #### [v6.89.0](https://github.com/opengovsg/FormSG/compare/v6.88.0...v6.89.0) > 15 November 2023 @@ -285,14 +297,13 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: add case for SGID MyInfo when field value is missing [`#6874`](https://github.com/opengovsg/FormSG/pull/6874) - build: merge release v6.86.0 into develop [`#6873`](https://github.com/opengovsg/FormSG/pull/6873) - build: release v6.86.0 [`#6866`](https://github.com/opengovsg/FormSG/pull/6866) -- chore: use non-testing branch for font-wqy-zenhei [`#6867`](https://github.com/opengovsg/FormSG/pull/6867) -- chore: bump version to v6.86.0 [`1eec9b6`](https://github.com/opengovsg/FormSG/commit/1eec9b63c914b56b7b10adffd03554e07fde0f3a) - chore: bump version to v6.87.0 [`5054803`](https://github.com/opengovsg/FormSG/commit/50548038804b03f30ce6d23b4d43b7a8cf7d9620) #### [v6.86.0](https://github.com/opengovsg/FormSG/compare/v6.85.1...v6.86.0) > 6 November 2023 +- chore: use non-testing branch for font-wqy-zenhei [`#6867`](https://github.com/opengovsg/FormSG/pull/6867) - chore: update credits and terms of use [`#6865`](https://github.com/opengovsg/FormSG/pull/6865) - fix: add cloudflareinsights as allowable csp [`#6864`](https://github.com/opengovsg/FormSG/pull/6864) - feat: optimise submission query [`#6863`](https://github.com/opengovsg/FormSG/pull/6863) @@ -300,7 +311,7 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). - fix: only render delete button if owner [`#6837`](https://github.com/opengovsg/FormSG/pull/6837) - build: merge release v6.85.1 into develop [`#6861`](https://github.com/opengovsg/FormSG/pull/6861) - fix: hotfix v6.85.1 to prevent creation of SGID_MyInfo storage mode forms [`#6860`](https://github.com/opengovsg/FormSG/pull/6860) -- chore: bump version to v6.86.0 [`1c827cd`](https://github.com/opengovsg/FormSG/commit/1c827cd11844649ca303bb5dc9987db5367637c9) +- chore: bump version to v6.86.0 [`1eec9b6`](https://github.com/opengovsg/FormSG/commit/1eec9b63c914b56b7b10adffd03554e07fde0f3a) #### [v6.85.1](https://github.com/opengovsg/FormSG/compare/v6.85.0...v6.85.1) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7aef4895be..d60934e3b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "form-frontend", - "version": "6.108.0", + "version": "6.109.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "form-frontend", - "version": "6.108.0", + "version": "6.109.0", "hasInstallScript": true, "dependencies": { "@chakra-ui/react": "^1.8.6", diff --git a/frontend/package.json b/frontend/package.json index 6dbdc3eda7..88df8c638e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "form-frontend", - "version": "6.108.0", + "version": "6.109.0", "homepage": ".", "private": true, "dependencies": { diff --git a/frontend/src/assets/icons/MultiParty.tsx b/frontend/src/assets/icons/MultiParty.tsx index dcc99ae003..26a1ba9451 100644 --- a/frontend/src/assets/icons/MultiParty.tsx +++ b/frontend/src/assets/icons/MultiParty.tsx @@ -1,17 +1,11 @@ export const MultiParty = ( props: React.SVGProps, ): JSX.Element => ( - + diff --git a/frontend/src/components/InlineMessage/InlineMessage.tsx b/frontend/src/components/InlineMessage/InlineMessage.tsx index ca3604209c..94d4950416 100644 --- a/frontend/src/components/InlineMessage/InlineMessage.tsx +++ b/frontend/src/components/InlineMessage/InlineMessage.tsx @@ -1,4 +1,10 @@ -import { Flex, FlexProps, Icon, useMultiStyleConfig } from '@chakra-ui/react' +import { + As, + Flex, + FlexProps, + Icon, + useMultiStyleConfig, +} from '@chakra-ui/react' import { BxsErrorCircle, BxsInfoCircle } from '~/assets/icons' import { InlineMessageVariant } from '~/theme/components/InlineMessage' @@ -9,12 +15,14 @@ import { MarkdownText } from '~components/MarkdownText' export interface InlineMessageProps extends FlexProps { variant?: InlineMessageVariant useMarkdown?: boolean + icon?: As } export const InlineMessage = ({ variant = 'info', children, useMarkdown = false, + icon, ...flexProps }: InlineMessageProps): JSX.Element => { const styles = useMultiStyleConfig('InlineMessage', { variant }) @@ -24,7 +32,7 @@ export const InlineMessage = ({ return ( diff --git a/frontend/src/constants/links.ts b/frontend/src/constants/links.ts index ba05b3ed6a..db6038c780 100644 --- a/frontend/src/constants/links.ts +++ b/frontend/src/constants/links.ts @@ -13,6 +13,7 @@ export const GUIDE_WEBHOOKS = 'https://go.gov.sg/formsg-guide-webhooks' export const GUIDE_EMAIL_MODE = 'https://go.gov.sg/formsg-guide-email-mode' export const GUIDE_STORAGE_MODE = 'https://go.gov.sg/formsg-guide-storage-mode' export const GUIDE_FORM_LOGIC = 'https://go.gov.sg/formsg-guide-logic' +export const GUIDE_FORM_MRF = 'https://go.gov.sg/formsg-guide-mrf' export const GUIDE_SPCP_ESRVCID = 'https://go.gov.sg/formsg-guide-singpass-myinfo' export const GUIDE_ENABLE_SPCP = diff --git a/frontend/src/features/admin-form/common/AdminViewFormService.ts b/frontend/src/features/admin-form/common/AdminViewFormService.ts index 565bd17b9c..61d2b11b0c 100644 --- a/frontend/src/features/admin-form/common/AdminViewFormService.ts +++ b/frontend/src/features/admin-form/common/AdminViewFormService.ts @@ -18,13 +18,9 @@ import { } from '~services/ApiService' import { augmentWithMyInfoDisplayValue } from '~features/myinfo/utils' -import { - SubmitEmailFormArgs, - SubmitStorageFormArgs, -} from '~features/public-form/PublicFormService' +import { SubmitEmailFormArgs } from '~features/public-form/PublicFormService' import { createClearSubmissionFormData, - createEncryptedSubmissionData, filterHiddenInputs, } from '~features/public-form/utils' @@ -181,26 +177,15 @@ export const submitEmailModeFormPreview = async ({ * Submit a storage mode form in preview mode */ export const submitStorageModeFormPreview = async ({ - formFields, - formLogics, - formInputs, formId, - publicKey, -}: SubmitStorageFormArgs) => { - const filteredInputs = filterHiddenInputs({ - formFields, - formInputs, - formLogics, - }) - const submissionContent = await createEncryptedSubmissionData({ - formFields, - formInputs: filteredInputs, - publicKey, - }) +}: { + formId: string +}) => { + const emptyFormData = {} return ApiService.post( - `${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/encrypt`, - submissionContent, + `${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/storage`, + emptyFormData, ).then(({ data }) => data) } @@ -242,28 +227,17 @@ export const submitEmailModeFormPreviewWithFetch = async ({ * TODO(#5826): Fallback using Fetch. Remove once network error is resolved */ export const submitStorageModeFormPreviewWithFetch = async ({ - formFields, - formLogics, - formInputs, formId, - publicKey, -}: SubmitStorageFormArgs): Promise => { - const filteredInputs = filterHiddenInputs({ - formFields, - formInputs, - formLogics, - }) - const submissionContent = await createEncryptedSubmissionData({ - formFields, - formInputs: filteredInputs, - publicKey, - }) +}: { + formId: string +}): Promise => { + const emptyFormData = {} const response = await fetch( - `${API_BASE_URL}${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/encrypt`, + `${API_BASE_URL}${ADMIN_FORM_ENDPOINT}/${formId}/preview/submissions/storage`, { method: 'POST', - body: JSON.stringify(submissionContent), + body: JSON.stringify(emptyFormData), headers: { 'Content-Type': 'application/json', Accept: 'application/json', diff --git a/frontend/src/features/admin-form/common/components/PreviewFormBanner/PreviewFormBanner.tsx b/frontend/src/features/admin-form/common/components/PreviewFormBanner/PreviewFormBanner.tsx index b2d027d39a..53991ee8fe 100644 --- a/frontend/src/features/admin-form/common/components/PreviewFormBanner/PreviewFormBanner.tsx +++ b/frontend/src/features/admin-form/common/components/PreviewFormBanner/PreviewFormBanner.tsx @@ -188,8 +188,19 @@ export const PreviewFormBanner = ({ ) : ( - You will not be able to make a test payment in Form Preview mode. - Open your form to make a test payment. + You will not be able to make a test payment, or view submitted + answers or attachments in Form Preview mode. Open your form to + make a test payment or form submission. + + )} + + )} + {!isPaymentEnabled && ( + + {!(secretEnv === 'production') && ( + + You will not be able to view submitted answers or attachments in + Form Preview mode. Open your form to test a form submission. )} diff --git a/frontend/src/features/admin-form/common/mutations.ts b/frontend/src/features/admin-form/common/mutations.ts index 2ac806d2a9..c2d9a1be86 100644 --- a/frontend/src/features/admin-form/common/mutations.ts +++ b/frontend/src/features/admin-form/common/mutations.ts @@ -469,7 +469,7 @@ export const usePreviewFormMutations = (formId: string) => { const submitStorageModeFormMutation = useMutation( (args: Omit) => { - return submitStorageModeFormPreview({ ...args, formId }) + return submitStorageModeFormPreview({ formId }) }, ) @@ -482,7 +482,7 @@ export const usePreviewFormMutations = (formId: string) => { const submitStorageModeFormFetchMutation = useMutation( (args: Omit) => { - return submitStorageModeFormPreviewWithFetch({ ...args, formId }) + return submitStorageModeFormPreviewWithFetch({ formId }) }, ) diff --git a/frontend/src/features/admin-form/create/common/CreatePageContent/CreatePageContent.tsx b/frontend/src/features/admin-form/create/common/CreatePageContent/CreatePageContent.tsx index 3d903ff407..b06020bb1f 100644 --- a/frontend/src/features/admin-form/create/common/CreatePageContent/CreatePageContent.tsx +++ b/frontend/src/features/admin-form/create/common/CreatePageContent/CreatePageContent.tsx @@ -6,6 +6,7 @@ import { import { BuilderAndDesignTab } from '../../builder-and-design/BuilderAndDesignTab' import { EndPageTab } from '../../end-page/EndPageTab' import { CreatePageLogicTab } from '../../logic/CreatePageLogicTab' +import { CreatePageWorkflowTab } from '../../workflow/CreatePageWorkflowTab' export const CreatePageContent = (): JSX.Element => { const { activeTab } = useCreatePageSidebar() @@ -14,6 +15,8 @@ export const CreatePageContent = (): JSX.Element => { return case DrawerTabs.EndPage: return + case DrawerTabs.Workflow: + return default: // builder or design return diff --git a/frontend/src/features/admin-form/create/common/CreatePageSidebar/CreatePageSidebar.tsx b/frontend/src/features/admin-form/create/common/CreatePageSidebar/CreatePageSidebar.tsx index c7feb16274..01c6e8d080 100644 --- a/frontend/src/features/admin-form/create/common/CreatePageSidebar/CreatePageSidebar.tsx +++ b/frontend/src/features/admin-form/create/common/CreatePageSidebar/CreatePageSidebar.tsx @@ -1,8 +1,10 @@ import { useCallback } from 'react' import { BiGitMerge, BiQuestionMark } from 'react-icons/bi' -import { Stack } from '@chakra-ui/react' +import { Divider, Stack } from '@chakra-ui/react' -import { PhHandsClapping } from '~assets/icons' +import { FormResponseMode } from '~shared/types' + +import { MultiParty, PhHandsClapping } from '~assets/icons' import { BxsDockTop } from '~assets/icons/BxsDockTop' import { BxsWidget } from '~assets/icons/BxsWidget' import { FORM_GUIDE } from '~constants/links' @@ -10,6 +12,7 @@ import { useIsMobile } from '~hooks/useIsMobile' import IconButton from '~components/IconButton' import Tooltip from '~components/Tooltip' +import { useAdminForm } from '~features/admin-form/common/queries' import { DrawerTabs, useCreatePageSidebar, @@ -29,6 +32,9 @@ import { DrawerTabIcon } from './DrawerTabIcon' export const CreatePageSidebar = (): JSX.Element | null => { const isMobile = useIsMobile() + + const { data } = useAdminForm() + const setFieldsToInactive = useFieldBuilderStore(setToInactiveSelector) const isDirty = useDirtyFieldStore(isDirtySelector) const { @@ -37,6 +43,7 @@ export const CreatePageSidebar = (): JSX.Element | null => { handleDesignClick, handleLogicClick, handleEndpageClick, + handleWorkflowClick, } = useCreatePageSidebar() const handleDrawerBuilderClick = useCallback(() => { @@ -62,6 +69,11 @@ export const CreatePageSidebar = (): JSX.Element | null => { [handleEndpageClick, isDirty], ) + const handleDrawerWorkflowClick = useCallback( + () => handleWorkflowClick(isDirty), + [handleWorkflowClick, isDirty], + ) + return ( { isActive={activeTab === DrawerTabs.EndPage} id={FEATURE_TOUR[3].id} /> + {data?.responseMode === FormResponseMode.Multirespondent && ( + <> + + } + onClick={handleDrawerWorkflowClick} + isActive={activeTab === DrawerTabs.Workflow} + /> + + )} void handleLogicClick: (shouldBePending: boolean) => void handleEndpageClick: (shouldBePending: boolean) => void + handleWorkflowClick: (shouldBePending: boolean) => void handleClose: (shouldBePending: boolean) => void isDrawerOpen: boolean fieldListTabIndex: FieldListTabIndex @@ -135,6 +137,12 @@ export const useCreatePageSidebarContext = [setActiveOrPendingTab], ) + const handleWorkflowClick = useCallback( + (shouldBePending: boolean) => + setActiveOrPendingTab(DrawerTabs.Workflow, shouldBePending), + [setActiveOrPendingTab], + ) + const handleClose = useCallback( (shouldBePending: boolean) => { setActiveOrPendingTab(null, shouldBePending) @@ -161,6 +169,7 @@ export const useCreatePageSidebarContext = handleDesignClick, handleLogicClick, handleEndpageClick, + handleWorkflowClick, handleClose, fieldListTabIndex, setFieldListTabIndex, diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/SaveActionGroup.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/SaveActionGroup.tsx index cf26af1db7..bb42bfa331 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/SaveActionGroup.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition/SaveActionGroup.tsx @@ -11,6 +11,7 @@ export interface SaveActionGroupProps { handleSubmit: () => void submitButtonLabel?: string isLoading: boolean + ariaLabelName: string } export const SaveActionGroup = ({ @@ -19,6 +20,7 @@ export const SaveActionGroup = ({ handleDelete, handleSubmit, isLoading, + ariaLabelName, }: SaveActionGroupProps): JSX.Element => { const isMobile = useIsMobile() @@ -35,7 +37,7 @@ export const SaveActionGroup = ({ } onClick={handleDelete} isDisabled={isLoading} diff --git a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx index d2d14ab7aa..2e8f737fe2 100644 --- a/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx +++ b/frontend/src/features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditLogicBlock.tsx @@ -155,6 +155,7 @@ export const EditLogicBlock = ({ handleDelete={handleOpenDeleteModal} handleCancel={setToInactive} submitButtonLabel={submitButtonLabel} + ariaLabelName="logic" /> ) diff --git a/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.stories.tsx b/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.stories.tsx new file mode 100644 index 0000000000..fdf8a2b06a --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.stories.tsx @@ -0,0 +1,126 @@ +import { Meta, Story } from '@storybook/react' + +import { AttachmentSize, BasicField, FormFieldDto } from '~shared/types/field' +import { + AdminFormDto, + FormResponseMode, + FormWorkflowStepDto, + WorkflowType, +} from '~shared/types/form' + +import { createFormBuilderMocks } from '~/mocks/msw/handlers/admin-form' + +import { StoryRouter, viewports } from '~utils/storybook' + +import { CreatePageWorkflowTab } from './CreatePageWorkflowTab' + +const buildMswRoutes = ( + overrides?: Partial, + delay: number | 'infinite' = 0, +) => createFormBuilderMocks(overrides, delay) + +export default { + title: 'Pages/AdminFormPage/Create/WorkflowTab', + component: CreatePageWorkflowTab, + decorators: [StoryRouter({ initialEntries: ['/12345'], path: '/:formId' })], + parameters: { + layout: 'fullscreen', + // Required so skeleton "animation" does not hide content. + // Pass a very short delay to avoid bug where Chromatic takes a snapshot before + // the story has loaded + chromatic: { pauseAnimationAtEnd: true, delay: 50 }, + msw: buildMswRoutes({ + responseMode: FormResponseMode.Multirespondent, + workflow: [], + }), + }, +} as Meta + +const form_field_1: FormFieldDto = { + title: 'Do you want to upload an attachment?', + description: '', + required: true, + disabled: false, + fieldType: BasicField.YesNo, + _id: '620115cf3bc125001349f9c3', +} + +const form_field_2: FormFieldDto = { + title: 'Are you really sure you want to upload an attachment?', + description: '', + required: true, + disabled: false, + fieldType: BasicField.YesNo, + _id: '620115cf3bc125001349f9c6', +} + +const form_field_3: FormFieldDto = { + title: 'Upload instructions', + description: '', + required: true, + disabled: false, + fieldType: BasicField.Image, + _id: '6200e1534ad4f00012848d65', + url: 'some-mock-url', + fileMd5Hash: 'wrjH62qBTpg0uIk4GMzOCA==', + name: 'Upload instructions.png', + size: '0.03 MB', +} + +const form_field_4: FormFieldDto = { + title: 'Upload attachment', + description: '', + required: true, + disabled: false, + fieldType: BasicField.Attachment, + _id: '61e6857c9c794b0012f1c6f7', + attachmentSize: AttachmentSize.OneMb, +} + +const workflow_step_1: FormWorkflowStepDto = { + _id: '61e6857c9c794b0012f1c6f8', + workflow_type: WorkflowType.Static, + emails: [], +} + +const workflow_step_2: FormWorkflowStepDto = { + _id: '61e6857c9c794b0012f1c6f9', + workflow_type: WorkflowType.Static, + emails: ['test_1@tech.gov.sg', 'test_2@tech.gov.sg'], +} + +const FORM_WITH_WORKFLOW: Partial = { + responseMode: FormResponseMode.Multirespondent, + form_fields: [form_field_1, form_field_2, form_field_3, form_field_4], + workflow: [workflow_step_1, workflow_step_2], +} + +const Template: Story = () => +export const NoWorkflow = Template.bind({}) + +export const MobileNoWorkflow = Template.bind({}) +MobileNoWorkflow.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, + chromatic: { viewports: [viewports.xs] }, +} + +export const WithWorkflow = Template.bind({}) +WithWorkflow.parameters = { + msw: buildMswRoutes(FORM_WITH_WORKFLOW), +} + +export const MobileWithWorkflow = Template.bind({}) +MobileWithWorkflow.parameters = { + msw: buildMswRoutes(FORM_WITH_WORKFLOW), + viewport: { + defaultViewport: 'mobile1', + }, + chromatic: { viewports: [viewports.xs] }, +} + +export const Loading = Template.bind({}) +Loading.parameters = { + msw: buildMswRoutes({}, 'infinite'), +} diff --git a/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.tsx b/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.tsx new file mode 100644 index 0000000000..46dbde7718 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/CreatePageWorkflowTab.tsx @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { Box, Container } from '@chakra-ui/react' + +import { EmptyWorkflow } from './components/EmptyWorkflow' +import { WorkflowContent } from './components/WorkflowContent' +import { WorkflowSkeleton } from './components/WorkflowSkeleton' +import { useAdminFormWorkflow } from './hooks/useAdminFormWorkflow' +import { useAdminWorkflowStore } from './adminWorkflowStore' + +export const CreatePageWorkflowTab = (): JSX.Element => { + const { createOrEditData, reset } = useAdminWorkflowStore( + useCallback((state) => { + return { + createOrEditData: state.createOrEditData, + setToCreating: state.setToCreating, + reset: state.reset, + } + }, []), + ) + const { isLoading, formWorkflow } = useAdminFormWorkflow() + + const isEmptyWorkflow = useMemo( + () => formWorkflow?.length === 0 && !createOrEditData, + [createOrEditData, formWorkflow?.length], + ) + + useEffect(() => reset, [reset]) + + if (isLoading) return + + return ( + + + {isEmptyWorkflow ? : } + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/FormWorkflowService.ts b/frontend/src/features/admin-form/create/workflow/FormWorkflowService.ts new file mode 100644 index 0000000000..29349c3143 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/FormWorkflowService.ts @@ -0,0 +1,48 @@ +import { + FormSettings, + FormWorkflow, + FormWorkflowDto, + FormWorkflowStep, + MultirespondentFormSettings, +} from '~shared/types/form' + +import { ApiService } from '~services/ApiService' + +import { ADMIN_FORM_ENDPOINT } from '~features/admin-form/common/AdminViewFormService' + +export const updateFormWorkflow = async ( + formId: string, + newWorkflowSettings: MultirespondentFormSettings['workflow'], +) => { + return ApiService.patch( + `${ADMIN_FORM_ENDPOINT}/${formId}/settings`, + { workflow: newWorkflowSettings }, + ).then(({ data }) => data) +} + +export const createWorkflowStep = ( + formId: string, + formWorkflow: FormWorkflow, + createStepBody: FormWorkflowStep, + // @ts-expect-error Argument of type 'FormWorkflow' is not assignable to parameter of type 'FormWorkflowDto'. +) => updateFormWorkflow(formId, [...formWorkflow, createStepBody]) + +export const deleteWorkflowStep = ( + formId: string, + formWorkflow: FormWorkflowDto, + stepNumber: number, +) => { + formWorkflow.splice(stepNumber, 1) + return updateFormWorkflow(formId, formWorkflow) +} + +export const updateWorkflowStep = ( + formId: string, + formWorkflow: FormWorkflowDto, + stepNumber: number, + updateStepBody: FormWorkflowStep, +) => { + // @ts-expect-error Argument of type 'FormWorkflow' is not assignable to parameter of type 'FormWorkflowDto'. + formWorkflow.splice(stepNumber, 1, updateStepBody) + return updateFormWorkflow(formId, formWorkflow) +} diff --git a/frontend/src/features/admin-form/create/workflow/adminWorkflowStore.ts b/frontend/src/features/admin-form/create/workflow/adminWorkflowStore.ts new file mode 100644 index 0000000000..e11dc013a1 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/adminWorkflowStore.ts @@ -0,0 +1,62 @@ +import create from 'zustand' +import { devtools } from 'zustand/middleware' + +import { AdminEditWorkflowState } from './types' + +type AdminWorkflowStore = { + setToCreating: () => void + setToEditing: (stepNumber: number) => void + setToInactive: () => void + reset: () => void + createOrEditData: + | { state: AdminEditWorkflowState.CreatingStep } + | { state: AdminEditWorkflowState.EditingStep; stepNumber: number } + | null +} + +const INITIAL_STATE = { + createOrEditData: null, +} + +export const isCreatingStateSelector = (state: AdminWorkflowStore) => + state.createOrEditData?.state === AdminEditWorkflowState.CreatingStep + +export const createOrEditDataSelector = (state: AdminWorkflowStore) => + state.createOrEditData + +export const editDataSelector = (state: AdminWorkflowStore) => { + const createOrEditData = createOrEditDataSelector(state) + return createOrEditData?.state === AdminEditWorkflowState.EditingStep + ? createOrEditData + : null +} + +export const setToCreatingSelector = (state: AdminWorkflowStore) => + state.setToCreating + +export const setToEditingSelector = (state: AdminWorkflowStore) => + state.setToEditing + +export const setToInactiveSelector = (state: AdminWorkflowStore) => + state.setToInactive + +export const useAdminWorkflowStore = create()( + devtools((set) => ({ + createOrEditData: null, + setToCreating: () => + set({ + createOrEditData: { + state: AdminEditWorkflowState.CreatingStep, + }, + }), + setToEditing: (stepNumber) => + set({ + createOrEditData: { + state: AdminEditWorkflowState.EditingStep, + stepNumber, + }, + }), + setToInactive: () => set({ createOrEditData: null }), + reset: () => set(INITIAL_STATE), + })), +) diff --git a/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/DeleteStepModal.tsx b/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/DeleteStepModal.tsx new file mode 100644 index 0000000000..e85fffe4f9 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/DeleteStepModal.tsx @@ -0,0 +1,94 @@ +import { useCallback } from 'react' +import { + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Stack, + Text, + useBreakpointValue, +} from '@chakra-ui/react' + +import Button from '~components/Button' +import { ModalCloseButton } from '~components/Modal' + +import { + setToInactiveSelector, + useAdminWorkflowStore, +} from '../../adminWorkflowStore' +import { useWorkflowMutations } from '../../mutations' + +interface DeleteStepModalProps { + onClose: () => void + isOpen: boolean + stepNumber: number +} + +export const DeleteStepModal = ({ + onClose, + isOpen, + stepNumber, +}: DeleteStepModalProps): JSX.Element => { + const setToInactive = useAdminWorkflowStore(setToInactiveSelector) + const { deleteStepMutation } = useWorkflowMutations() + const modalSize = useBreakpointValue({ + base: 'mobile', + xs: 'mobile', + md: 'md', + }) + + const handleDelete = useCallback(() => { + // Cannot be put in onSuccess since this component will be unmounted by then. + // No big deal even if we set to inactive here. + setToInactive() + return deleteStepMutation.mutate(stepNumber, { + onSuccess: onClose, + }) + }, [setToInactive, deleteStepMutation, stepNumber, onClose]) + + return ( + + + + + Delete step + + + Are you sure you want to delete this step? This action is not + reversible. + + + + + + + + + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/index.ts b/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/index.ts new file mode 100644 index 0000000000..1b2acf7418 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/DeleteStepModal/index.ts @@ -0,0 +1 @@ +export { DeleteStepModal } from './DeleteStepModal' diff --git a/frontend/src/features/admin-form/create/workflow/components/EmptyWorkflow.tsx b/frontend/src/features/admin-form/create/workflow/components/EmptyWorkflow.tsx new file mode 100644 index 0000000000..7a96abde9e --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/EmptyWorkflow.tsx @@ -0,0 +1,46 @@ +import { BiPlus } from 'react-icons/bi' +import { Flex, Text } from '@chakra-ui/react' + +import { GUIDE_FORM_MRF } from '~constants/links' +import Button from '~components/Button' +import Link from '~components/Link' + +import { + setToCreatingSelector, + useAdminWorkflowStore, +} from '../adminWorkflowStore' + +import { WorkflowSvgr } from './WorkflowSvgr' + +export const EmptyWorkflow = (): JSX.Element => { + const setToCreating = useAdminWorkflowStore(setToCreatingSelector) + + return ( + + + Create a workflow to collect responses from multiple respondents + + + Assign respondents to specific steps, and control which fields they can + see and fill.{' '} + + Learn how to create a workflow + + + } + onClick={setToCreating} + > + Create workflow + + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx new file mode 100644 index 0000000000..6611d75d20 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/ActiveStepBlock.tsx @@ -0,0 +1,53 @@ +import { useCallback } from 'react' + +import { FormWorkflowStepDto, WorkflowType } from '~shared/types' + +import { + setToInactiveSelector, + useAdminWorkflowStore, +} from '../../../adminWorkflowStore' +import { useWorkflowMutations } from '../../../mutations' +import { EditStepInputs } from '../../../types' +import { EditStepBlock } from '../EditStepBlock' + +export interface ActiveStepBlockProps { + stepNumber: number + step: FormWorkflowStepDto + handleOpenDeleteModal: () => void +} + +export const ActiveStepBlock = ({ + stepNumber, + step, + handleOpenDeleteModal, +}: ActiveStepBlockProps): JSX.Element => { + const { updateStepMutation } = useWorkflowMutations() + const setToInactive = useAdminWorkflowStore(setToInactiveSelector) + const handleSubmit = useCallback( + (inputs: EditStepInputs) => + updateStepMutation.mutate( + { + stepNumber, + updateStepBody: { + workflow_type: WorkflowType.Static, + emails: inputs.email ? [inputs.email] : [], + }, + }, + { + onSuccess: () => setToInactive(), + }, + ), + [updateStepMutation, stepNumber, setToInactive], + ) + + return ( + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/index.ts new file mode 100644 index 0000000000..a4712605a9 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/ActiveStepBlock/index.ts @@ -0,0 +1 @@ +export * from './ActiveStepBlock' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx new file mode 100644 index 0000000000..f104601f76 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/EditStepBlock.tsx @@ -0,0 +1,96 @@ +import { useLayoutEffect, useRef } from 'react' +import { useForm } from 'react-hook-form' +import { Box, Stack } from '@chakra-ui/react' +import { merge } from 'lodash' + +import { SaveActionGroup } from '~features/admin-form/create/logic/components/LogicContent/EditLogicBlock/EditCondition' + +import { + setToInactiveSelector, + useAdminWorkflowStore, +} from '../../../adminWorkflowStore' +import { EditStepInputs } from '../../../types' +import { StepLabel } from '../StepLabel' +import { isFirstStepByStepNumber } from '../utils/isFirstStepByStepNumber' + +import { RespondentBlock } from './RespondentBlock' + +export interface EditLogicBlockProps { + /** Sets default values of inputs if this is provided */ + defaultValues?: Partial + onSubmit: (inputs: EditStepInputs) => void + + stepNumber: number + submitButtonLabel: string + handleOpenDeleteModal?: () => void + isLoading: boolean +} + +export const EditStepBlock = ({ + stepNumber, + onSubmit, + defaultValues, + isLoading, + submitButtonLabel, + handleOpenDeleteModal, +}: EditLogicBlockProps) => { + const setToInactive = useAdminWorkflowStore(setToInactiveSelector) + + const formMethods = useForm({ + defaultValues: merge({ emails: [] }, defaultValues), + shouldUnregister: true, + }) + + const wrapperRef = useRef(null) + + useLayoutEffect(() => { + if (wrapperRef.current) { + wrapperRef.current.scrollIntoView({ + behavior: 'smooth', + // Block required so parent (with overflow:hidden) will not be scrolled + // and causing unscrollable white space. + // See https://stackoverflow.com/questions/48634459/scrollintoview-block-vs-inline/48635751#48635751 + block: 'nearest', + }) + } + }, []) + + const isFirstStep = isFirstStepByStepNumber(stepNumber) + + const handleSubmit = formMethods.handleSubmit((inputs) => onSubmit(inputs)) + + return ( + + + + + + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx new file mode 100644 index 0000000000..7131b1378f --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/RespondentBlock.tsx @@ -0,0 +1,66 @@ +import { Controller, UseFormReturn } from 'react-hook-form' +import { FormControl, Stack, Text } from '@chakra-ui/react' +import isEmail from 'validator/lib/isEmail' + +import FormErrorMessage from '~components/FormControl/FormErrorMessage' +import Input from '~components/Input' + +import { EditStepInputs } from '~features/admin-form/create/workflow/types' + +import { isFirstStepByStepNumber } from '../utils/isFirstStepByStepNumber' + +interface RespondentBlockProps { + stepNumber: number + isLoading: boolean + formMethods: UseFormReturn +} + +export const RespondentBlock = ({ + stepNumber, + isLoading, + formMethods, +}: RespondentBlockProps): JSX.Element => { + const { + formState: { errors }, + control, + } = formMethods + + const isFirstStep = isFirstStepByStepNumber(stepNumber) + + return ( + + Respondent in this step + {isFirstStep ? ( + Anyone you share the form link with + ) : ( + + + !email || isEmail(email) || 'Please enter a valid email', + }, + }} + render={({ field }) => ( + + )} + /> + {errors.email?.message} + + )} + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/index.ts new file mode 100644 index 0000000000..647670267e --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/EditStepBlock/index.ts @@ -0,0 +1 @@ +export * from './EditStepBlock' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/HeaderBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/HeaderBlock.tsx new file mode 100644 index 0000000000..4cc401f99a --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/HeaderBlock.tsx @@ -0,0 +1,18 @@ +import { Flex, Text } from '@chakra-ui/react' + +import { MultiParty } from '~assets/icons' +import InlineMessage from '~components/InlineMessage' + +export const HeaderBlock = (): JSX.Element => { + return ( + + + Create a workflow for your form + + Add multiple steps, and for each of them, assign who should respond to + it, and select fields that they can see and fill. + + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx new file mode 100644 index 0000000000..0d0f21cee1 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/InactiveStepBlock.tsx @@ -0,0 +1,106 @@ +import { useCallback, useMemo } from 'react' +import { BiTrash } from 'react-icons/bi' +import { Box, chakra, Flex, Stack, Text } from '@chakra-ui/react' + +import { FormWorkflowStepDto } from '~shared/types/form' + +import IconButton from '~components/IconButton' + +import { LogicBadge } from '~features/admin-form/create/logic/components/LogicContent/InactiveLogicBlock/LogicBadge' + +import { + createOrEditDataSelector, + setToEditingSelector, + useAdminWorkflowStore, +} from '../../../adminWorkflowStore' +import { StepLabel } from '../StepLabel' +import { isFirstStepByStepNumber } from '../utils/isFirstStepByStepNumber' + +interface InactiveStepBlockProps { + stepNumber: number + step: FormWorkflowStepDto + handleOpenDeleteModal: () => void +} + +export const InactiveStepBlock = ({ + stepNumber, + step, + handleOpenDeleteModal, +}: InactiveStepBlockProps): JSX.Element | null => { + const setToEditing = useAdminWorkflowStore(setToEditingSelector) + const stateData = useAdminWorkflowStore(createOrEditDataSelector) + + // Prevent editing step if some other step is being edited. + const isPreventEdit = useMemo(() => !!stateData, [stateData]) + + const handleClick = useCallback(() => { + if (isPreventEdit) { + return + } + setToEditing(stepNumber) + }, [isPreventEdit, stepNumber, setToEditing]) + + const isFirstStep = isFirstStepByStepNumber(stepNumber) + + return ( + + + + + + Respondent in this step + {isFirstStep ? ( + Anyone you share the form link with + ) : ( + + {step.emails.map((email) => ( + {email} + ))} + + )} + + + + {!isFirstStep && ( + } + /> + )} + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/index.ts new file mode 100644 index 0000000000..69a0c8b8fb --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/InactiveStepBlock/index.ts @@ -0,0 +1 @@ +export { InactiveStepBlock } from './InactiveStepBlock' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/NewStepBlock.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/NewStepBlock.tsx new file mode 100644 index 0000000000..6cf875910a --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/NewStepBlock.tsx @@ -0,0 +1,55 @@ +import { useCallback } from 'react' +import { BiPlus } from 'react-icons/bi' +import { Button } from '@chakra-ui/react' + +import { WorkflowType } from '~shared/types' + +import { + isCreatingStateSelector, + setToCreatingSelector, + setToInactiveSelector, + useAdminWorkflowStore, +} from '../../../adminWorkflowStore' +import { useAdminFormWorkflow } from '../../../hooks/useAdminFormWorkflow' +import { useWorkflowMutations } from '../../../mutations' +import { EditStepInputs } from '../../../types' +import { EditStepBlock } from '../EditStepBlock' + +export const NewStepBlock = () => { + const { formWorkflow } = useAdminFormWorkflow() + const { createStepMutation } = useWorkflowMutations() + const { isCreatingState, setToInactive, setToCreating } = + useAdminWorkflowStore((state) => ({ + isCreatingState: isCreatingStateSelector(state), + setToInactive: setToInactiveSelector(state), + setToCreating: setToCreatingSelector(state), + })) + const handleSubmit = useCallback( + (inputs: EditStepInputs) => + createStepMutation.mutate( + { + workflow_type: WorkflowType.Static, + emails: inputs.email ? [inputs.email] : [], + }, + { + onSuccess: () => setToInactive(), + }, + ), + [createStepMutation, setToInactive], + ) + + if (!formWorkflow) return null + + return isCreatingState ? ( + + ) : ( + }> + Add step + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/index.ts new file mode 100644 index 0000000000..8cb5bf8d15 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/NewStepBlock/index.ts @@ -0,0 +1 @@ +export { NewStepBlock } from './NewStepBlock' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/StepLabel.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/StepLabel.tsx new file mode 100644 index 0000000000..6b0a3c2715 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/StepLabel.tsx @@ -0,0 +1,25 @@ +import { Stack, Text } from '@chakra-ui/react' + +type StepLabelProps = { + stepNumber: number +} + +export const StepLabel = ({ stepNumber }: StepLabelProps) => ( + + + {stepNumber + 1} + + Step {stepNumber + 1} + +) diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/WorkflowBlockFactory.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/WorkflowBlockFactory.tsx new file mode 100644 index 0000000000..657f18621d --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/WorkflowBlockFactory.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react' +import { useDisclosure } from '@chakra-ui/react' + +import { FormWorkflowStepDto } from '~shared/types' + +import { + editDataSelector, + useAdminWorkflowStore, +} from '../../../adminWorkflowStore' +import { DeleteStepModal } from '../../DeleteStepModal' +import { ActiveStepBlock } from '../ActiveStepBlock' +import { InactiveStepBlock } from '../InactiveStepBlock' + +export interface WorkflowBlockFactoryProps { + stepNumber: number + step: FormWorkflowStepDto +} + +export const WorkflowBlockFactory = ({ + stepNumber, + step, +}: WorkflowBlockFactoryProps): JSX.Element => { + const editState = useAdminWorkflowStore(editDataSelector) + const { + isOpen: isDeleteModalOpen, + onClose: onDeleteModalClose, + onOpen: onDeleteModalOpen, + } = useDisclosure() + + const isActiveState = useMemo( + () => editState?.stepNumber === stepNumber, + [editState?.stepNumber, stepNumber], + ) + + return ( + <> + + {isActiveState ? ( + + ) : ( + + )} + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/index.ts new file mode 100644 index 0000000000..643ce42eb6 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowBlockFactory/index.ts @@ -0,0 +1 @@ +export { WorkflowBlockFactory } from './WorkflowBlockFactory' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowContent.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowContent.tsx new file mode 100644 index 0000000000..2242888f8a --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/WorkflowContent.tsx @@ -0,0 +1,47 @@ +import { Box, Divider, Stack } from '@chakra-ui/react' + +import { BxsChevronDown } from '~assets/icons/BxsChevronDown' + +import { useAdminFormWorkflow } from '../../hooks/useAdminFormWorkflow' + +import { HeaderBlock } from './HeaderBlock' +import { NewStepBlock } from './NewStepBlock' +import { WorkflowBlockFactory } from './WorkflowBlockFactory' + +export const WorkflowContent = (): JSX.Element | null => { + const { formWorkflow, isLoading } = useAdminFormWorkflow() + + if (isLoading) return null + + return ( + + + }> + {formWorkflow?.map((step, i) => ( + + ))} + + + + ) +} + +const WorkflowStepBlockDivider = () => ( + + + + + +) diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/index.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/index.ts new file mode 100644 index 0000000000..fd8821670a --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/index.ts @@ -0,0 +1 @@ +export { WorkflowContent } from './WorkflowContent' diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/utils/isFirstStepByStepNumber.ts b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/utils/isFirstStepByStepNumber.ts new file mode 100644 index 0000000000..1c8cbe7092 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowContent/utils/isFirstStepByStepNumber.ts @@ -0,0 +1,2 @@ +export const isFirstStepByStepNumber = (stepNumber: number): stepNumber is 0 => + stepNumber === 0 diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowSkeleton.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowSkeleton.tsx new file mode 100644 index 0000000000..45fbaf8475 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowSkeleton.tsx @@ -0,0 +1,23 @@ +import { Box, Container, Flex, Skeleton } from '@chakra-ui/react' + +export const WorkflowSkeleton = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/features/admin-form/create/workflow/components/WorkflowSvgr.tsx b/frontend/src/features/admin-form/create/workflow/components/WorkflowSvgr.tsx new file mode 100644 index 0000000000..06a0be1e12 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/components/WorkflowSvgr.tsx @@ -0,0 +1,313 @@ +import { forwardRef, memo, SVGProps } from 'react' +import { chakra } from '@chakra-ui/react' + +const MemoWorkflowSvgr = memo( + forwardRef>((props, ref) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )), +) + +export const WorkflowSvgr = chakra(MemoWorkflowSvgr) diff --git a/frontend/src/features/admin-form/create/workflow/constants.ts b/frontend/src/features/admin-form/create/workflow/constants.ts new file mode 100644 index 0000000000..07e6fb46de --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/constants.ts @@ -0,0 +1,7 @@ +import { ALLOWED_LOGIC_FIELDS_ARRAY } from '~features/logic/constants' + +import { BASICFIELD_TO_DRAWER_META } from '../constants' + +export const ALLOWED_FIELDS_META = ALLOWED_LOGIC_FIELDS_ARRAY.map( + (fieldType) => BASICFIELD_TO_DRAWER_META[fieldType], +) diff --git a/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts b/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts new file mode 100644 index 0000000000..db0246a1ec --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/hooks/useAdminFormWorkflow.ts @@ -0,0 +1,16 @@ +import { FormResponseMode } from '~shared/types' + +import { useAdminForm } from '~features/admin-form/common/queries' + +export const useAdminFormWorkflow = () => { + const { data: form, isLoading } = useAdminForm() + + return { + isLoading, + formFields: form?.form_fields, + formWorkflow: + form?.responseMode !== FormResponseMode.Multirespondent + ? undefined + : form?.workflow, + } +} diff --git a/frontend/src/features/admin-form/create/workflow/mutations.ts b/frontend/src/features/admin-form/create/workflow/mutations.ts new file mode 100644 index 0000000000..21a27482cd --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/mutations.ts @@ -0,0 +1,128 @@ +import { useCallback } from 'react' +import { useMutation, useQueryClient } from 'react-query' +import { useParams } from 'react-router-dom' + +import { + AdminFormDto, + FormResponseMode, + FormWorkflowStep, +} from '~shared/types/form' + +import { useToast } from '~hooks/useToast' + +import { adminFormKeys } from '~features/admin-form/common/queries' + +import { useAdminFormWorkflow } from './hooks/useAdminFormWorkflow' +import { + createWorkflowStep, + deleteWorkflowStep, + updateWorkflowStep, +} from './FormWorkflowService' + +export const useWorkflowMutations = () => { + const { formId } = useParams() + if (!formId) throw new Error('No formId provided') + + const { formWorkflow } = useAdminFormWorkflow() + if (!formWorkflow) throw new Error('No form workflow found') + + const queryClient = useQueryClient() + const adminFormKey = adminFormKeys.id(formId) + const toast = useToast({ status: 'success', isClosable: true }) + + const handleError = useCallback( + (error: Error) => { + toast.closeAll() + toast({ + description: error.message, + status: 'danger', + }) + }, + [toast], + ) + + const createStepMutation = useMutation( + (createStepBody: FormWorkflowStep) => + createWorkflowStep(formId, formWorkflow, createStepBody), + { + onSuccess: (newSettings) => { + toast.closeAll() + queryClient.setQueryData(adminFormKey, (prev) => { + // Should not happen, should not be able to update field if there is no + // existing data. + if (!prev) throw new Error('Query should have been set') + if ( + prev.responseMode !== FormResponseMode.Multirespondent || + newSettings.responseMode !== FormResponseMode.Multirespondent + ) { + throw new Error('Invalid response mode') + } + return { ...prev, workflow: newSettings.workflow } + }) + toast({ + description: 'The step was successfully created.', + }) + }, + onError: handleError, + }, + ) + + const deleteStepMutation = useMutation( + (stepNumber: number) => + deleteWorkflowStep(formId, formWorkflow, stepNumber), + { + onSuccess: (newSettings) => { + toast.closeAll() + queryClient.setQueryData(adminFormKey, (prev) => { + // Should not happen, should not be able to update field if there is no + // existing data. + if (!prev) throw new Error('Query should have been set') + if ( + prev.responseMode !== FormResponseMode.Multirespondent || + newSettings.responseMode !== FormResponseMode.Multirespondent + ) { + throw new Error('Invalid response mode') + } + return { ...prev, workflow: newSettings.workflow } + }) + toast({ + description: 'The step was successfully deleted.', + }) + }, + onError: handleError, + }, + ) + + const updateStepMutation = useMutation( + ({ + stepNumber, + updateStepBody, + }: { + stepNumber: number + updateStepBody: FormWorkflowStep + }) => updateWorkflowStep(formId, formWorkflow, stepNumber, updateStepBody), + { + onSuccess: (newSettings) => { + toast.closeAll() + queryClient.setQueryData(adminFormKey, (prev) => { + // Should not happen, should not be able to update field if there is no + // existing data. + if (!prev) throw new Error('Query should have been set') + if ( + prev.responseMode !== FormResponseMode.Multirespondent || + newSettings.responseMode !== FormResponseMode.Multirespondent + ) { + throw new Error('Invalid response mode') + } + return { ...prev, workflow: newSettings.workflow } + }) + toast({ + description: 'The step was successfully updated.', + }) + }, + onError: handleError, + }, + ) + + return { createStepMutation, deleteStepMutation, updateStepMutation } +} diff --git a/frontend/src/features/admin-form/create/workflow/types.ts b/frontend/src/features/admin-form/create/workflow/types.ts new file mode 100644 index 0000000000..045d86c258 --- /dev/null +++ b/frontend/src/features/admin-form/create/workflow/types.ts @@ -0,0 +1,8 @@ +export enum AdminEditWorkflowState { + CreatingStep, + EditingStep, +} + +export type EditStepInputs = { + email?: string +} diff --git a/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts b/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts index 47c59dbc7a..d8d020e505 100644 --- a/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts +++ b/frontend/src/features/admin-form/responses/IndividualResponsePage/queries.ts @@ -1,9 +1,11 @@ import { useQuery } from 'react-query' import { useParams } from 'react-router-dom' +import { FormResponseMode } from '~shared/types' + import { useToast } from '~hooks/useToast' -import { useUser } from '~features/user/queries' +import { useAdminForm } from '~features/admin-form/common/queries' import { getDecryptedSubmissionById } from '../AdminSubmissionsService' import { adminFormResponsesKeys } from '../queries' @@ -18,8 +20,7 @@ export const useIndividualSubmission = () => { }) const { formId, submissionId } = useParams() - const { user } = useUser() - const displayWorkflow = user?.betaFlags?.mrf + const { data: { responseMode } = {} } = useAdminForm() if (!formId || !submissionId) { throw new Error('No formId or submissionId provided') @@ -31,8 +32,12 @@ export const useIndividualSubmission = () => { adminFormResponsesKeys.individual(formId, submissionId), () => getDecryptedSubmissionById({ formId, submissionId, secretKey }), { - // For users with MRF enabled, will always fetch the response. Otherwise, response Will never update once fetched. - staleTime: displayWorkflow ? 0 : Infinity, + staleTime: + responseMode === FormResponseMode.Multirespondent + ? // For MRFs, will always fetch the response. + 0 + : // Otherwise, response Will never update once fetched. + Infinity, enabled: !!secretKey, onError: (e) => { toast({ diff --git a/frontend/src/features/admin-form/settings/SettingsPage.tsx b/frontend/src/features/admin-form/settings/SettingsPage.tsx index 1ed27731c1..a0b974075f 100644 --- a/frontend/src/features/admin-form/settings/SettingsPage.tsx +++ b/frontend/src/features/admin-form/settings/SettingsPage.tsx @@ -13,7 +13,6 @@ import { import { featureFlags } from '~shared/constants' -import { MultiParty } from '~assets/icons/MultiParty' import { ADMINFORM_RESULTS_SUBROUTE, ADMINFORM_ROUTE } from '~constants/routes' import { useDraggable } from '~hooks/useDraggable' @@ -28,7 +27,6 @@ import { SettingsGeneralPage } from './SettingsGeneralPage' import { SettingsPaymentsPage } from './SettingsPaymentsPage' import { SettingsTwilioPage } from './SettingsTwilioPage' import { SettingsWebhooksPage } from './SettingsWebhooksPage' -import { SettingsWorkflowPage } from './SettingsWorkflowPage' const settingsTabsOrder = ['general', 'singpass', 'twilio', 'webhooks'] @@ -54,8 +52,6 @@ export const SettingsPage = (): JSX.Element => { const displayPayments = user?.betaFlags?.payment || flags?.has(featureFlags.payment) - const displayWorkflow = user?.betaFlags?.mrf - const [tabIndex, setTabIndex] = useState( settingsTabsOrder.indexOf(settingsTab ?? ''), ) @@ -69,11 +65,7 @@ export const SettingsPage = (): JSX.Element => { settingsTabsOrder.push('payments') setTabIndex(settingsTabsOrder.indexOf(settingsTab ?? '')) } - if (displayWorkflow) { - settingsTabsOrder.push('workflow') - setTabIndex(settingsTabsOrder.indexOf(settingsTab ?? '')) - } - }, [displayWorkflow, displayPayments, settingsTab]) + }, [displayPayments, settingsTab]) const handleTabChange = (index: number) => { setTabIndex(index) @@ -128,9 +120,6 @@ export const SettingsPage = (): JSX.Element => { {displayPayments && ( )} - {displayWorkflow && ( - - )} { )} - {displayWorkflow && ( - - - - )} diff --git a/frontend/src/features/admin-form/settings/SettingsService.ts b/frontend/src/features/admin-form/settings/SettingsService.ts index 0592b17614..bb1691c9e8 100644 --- a/frontend/src/features/admin-form/settings/SettingsService.ts +++ b/frontend/src/features/admin-form/settings/SettingsService.ts @@ -3,7 +3,6 @@ import Stripe from 'stripe' import { EmailFormSettings, FormSettings, - MultirespondentFormSettings, SettingsUpdateDto, StorageFormSettings, } from '~shared/types/form/form' @@ -23,12 +22,6 @@ type UpdateStorageFormFn = ( settingsToUpdate: StorageFormSettings[T], ) => Promise -type UpdateMultirespondentFormFn = - ( - formId: string, - settingsToUpdate: MultirespondentFormSettings[T], - ) => Promise - type UpdateFormFn = ( formId: string, settingsToUpdate: FormSettings[T], @@ -143,14 +136,6 @@ export const updateGstEnabledFlag = async ( }) } -export const updateWorkflowSettings: UpdateMultirespondentFormFn< - 'workflow' -> = async ( - formId, - newWorkflowSettings: MultirespondentFormSettings['workflow'], -) => { - return updateFormSettings(formId, { workflow: newWorkflowSettings }) -} /** * Internal function that calls the PATCH API. * @param formId the id of the form to update diff --git a/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx b/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx deleted file mode 100644 index 2b167225c3..0000000000 --- a/frontend/src/features/admin-form/settings/SettingsWorkflowPage.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Link, Text, Wrap } from '@chakra-ui/react' - -import { FormResponseMode } from '~shared/types' - -import { GUIDE_TWILIO } from '~constants/links' - -import { CategoryHeader } from './components/CategoryHeader' -import { WorkflowDetailsInput } from './components/WorkflowSettingsSection/WorkflowDetailsInput' -import { WorkflowUnsupportedMsg } from './components/WorkflowSettingsSection/WorkflowUnsupportedMsg' -import { useAdminFormSettings } from './queries' - -export const SettingsWorkflowPage = (): JSX.Element => { - const { data: settings, isLoading } = useAdminFormSettings() - if (!settings || isLoading) return - - // Workflow is only supported in multirespondent mode; show message if form response mode is not multirespondent - if (settings.responseMode !== FormResponseMode.Multirespondent) { - return - } - - return ( - <> - - - Workflow - - - - Create a workflow to collect responses from multiple respondents. We - currently support up to three respondents.  - - Learn more about setting up a workflow - - - - - ) -} diff --git a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx deleted file mode 100644 index b12f4028f1..0000000000 --- a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowDetailsInput.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { KeyboardEventHandler, useCallback, useMemo, useRef } from 'react' -import { useForm } from 'react-hook-form' -import { FormControl, FormErrorMessage, Stack } from '@chakra-ui/react' -import isEmail from 'validator/lib/isEmail' - -import { MultirespondentFormSettings, WorkflowType } from '~shared/types' - -import { INVALID_EMAIL_ERROR } from '~constants/validation' -import FormLabel from '~components/FormControl/FormLabel' -import Input from '~components/Input' - -import { useMutateFormSettings } from '../../mutations' - -export const WorkflowDetailsInput = ({ - settings, -}: { - settings: MultirespondentFormSettings -}): JSX.Element => { - const { mutateWorkflowSettings } = useMutateFormSettings() - - const existingSecondRespEmail = useMemo( - () => settings.workflow && settings.workflow[1]?.emails[0], - [settings], - ) - - const existingThirdRespEmail = useMemo( - () => settings.workflow && settings.workflow[2]?.emails[0], - [settings], - ) - - const existingWorkflow = useMemo( - () => - settings.workflow && settings.workflow.length > 0 - ? [...settings.workflow] - : Array(3).fill({ - emails: [], - workflow_type: WorkflowType.Static, - }), - [settings], - ) - - const validateEmail = useCallback((value: string) => { - if (!value) return true - return isEmail(value.trim()) || INVALID_EMAIL_ERROR - }, []) - - const { - register, - formState: { errors, isValid }, - getValues, - } = useForm<{ - secondRespondent: string - thirdRespondent: string - }>({ - mode: 'onChange', - defaultValues: { - secondRespondent: existingSecondRespEmail ?? '', - thirdRespondent: existingThirdRespEmail ?? '', - }, - }) - - const handleUpdateEmail = useCallback(() => { - const nextSecondEmail = getValues('secondRespondent') - const nextThirdEmail = getValues('thirdRespondent') - - const newWorkflow = [...existingWorkflow] - newWorkflow[1].emails = [nextSecondEmail] - newWorkflow[2].emails = [nextThirdEmail] - mutateWorkflowSettings.mutate(newWorkflow) - }, [getValues, existingWorkflow, mutateWorkflowSettings]) - - const handleEmailInputKeyDown: KeyboardEventHandler = useCallback( - (e) => { - if (!isValid || e.key !== 'Enter') return - return inputRef.current?.blur() - }, - [isValid], - ) - - const handleEmailInputBlur = useCallback(() => { - if (isValid) { - return handleUpdateEmail() - } - return - }, [isValid, handleUpdateEmail]) - - const secondEmailRegister = register('secondRespondent', { - onBlur: handleEmailInputBlur, - validate: validateEmail, - }) - - const thirdEmailRegister = register('thirdRespondent', { - onBlur: handleEmailInputBlur, - validate: validateEmail, - }) - - const inputRef = useRef(null) - - return ( - - - - First respondent - - - - - - Second respondent - - - {errors.secondRespondent && ( - {errors.secondRespondent.message} - )} - - - - Third respondent - - - {errors.thirdRespondent && ( - {errors.thirdRespondent.message} - )} - - - ) -} diff --git a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx b/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx deleted file mode 100644 index 1c7871039a..0000000000 --- a/frontend/src/features/admin-form/settings/components/WorkflowSettingsSection/WorkflowUnsupportedMsg.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Flex, Text } from '@chakra-ui/react' - -import { SettingsUnsupportedSvgr } from '~features/admin-form/settings/svgrs/SettingsUnsupportedSvgr' - -export const WorkflowUnsupportedMsg = (): JSX.Element => { - return ( - - - Workflow is only available in multirespondent mode - - - - - ) -} diff --git a/frontend/src/features/admin-form/settings/mutations.ts b/frontend/src/features/admin-form/settings/mutations.ts index 8090383fea..72c8d79ab4 100644 --- a/frontend/src/features/admin-form/settings/mutations.ts +++ b/frontend/src/features/admin-form/settings/mutations.ts @@ -9,7 +9,6 @@ import { FormResponseMode, FormSettings, FormStatus, - MultirespondentFormSettings, StorageFormSettings, } from '~shared/types/form/form' import { TwilioCredentials } from '~shared/types/twilio' @@ -41,7 +40,6 @@ import { updateFormWebhookUrl, updateGstEnabledFlag, updateTwilioCredentials, - updateWorkflowSettings, } from './SettingsService' export const useMutateFormSettings = () => { @@ -337,19 +335,6 @@ export const useMutateFormSettings = () => { }, ) - const mutateWorkflowSettings = useMutation( - (workflowSettings: MultirespondentFormSettings['workflow']) => - updateWorkflowSettings(formId, workflowSettings), - { - onSuccess: (newData) => { - handleSuccess({ - newData, - toastDescription: `Workflow settings have been updated.`, - }) - }, - onError: handleError, - }, - ) return { mutateWebhookRetries, mutateFormWebhookUrl, @@ -364,7 +349,6 @@ export const useMutateFormSettings = () => { mutateFormEsrvcId, mutateFormBusiness, mutateGST, - mutateWorkflowSettings, } } diff --git a/frontend/src/features/public-form/PublicFormPage.tsx b/frontend/src/features/public-form/PublicFormPage.tsx index d50d52ade5..a37128e497 100644 --- a/frontend/src/features/public-form/PublicFormPage.tsx +++ b/frontend/src/features/public-form/PublicFormPage.tsx @@ -39,7 +39,6 @@ export const PublicFormPage = (): JSX.Element => { - diff --git a/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentPage.tsx b/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentPage.tsx index 149fb344f3..6de651ceaa 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentPage.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/FormPaymentPage.tsx @@ -1,17 +1,15 @@ import { Suspense } from 'react' import { useParams } from 'react-router-dom' -import { Box, Container, Flex, Skeleton, Text } from '@chakra-ui/react' +import { Flex, Skeleton, Text } from '@chakra-ui/react' import { fillMinHeightCss } from '~utils/fillHeightCss' import { FormBanner } from '~features/public-form/components/FormBanner' import { FormSectionsProvider } from '~features/public-form/components/FormFields/FormSectionsContext' import { FormFooter } from '~features/public-form/components/FormFooter' -import { PublicFormLogo } from '~features/public-form/components/FormLogo' -import FormStartPage from '~features/public-form/components/FormStartPage' -import { PublicFormWrapper } from '~features/public-form/components/PublicFormWrapper' import { PublicFormProvider } from '~features/public-form/PublicFormProvider' +import { PaymentFormBannerLogo } from './components/PaymentFormBannerLogo' import StripePaymentElement from './stripe/StripePaymentElement' export const FormPaymentPage = () => { @@ -25,26 +23,19 @@ export const FormPaymentPage = () => { - - - - - - - - Loading Payment Information - - - } - > - - - - - - + + + + Loading Payment Information + + + } + > + + + diff --git a/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentFormBannerLogo.tsx b/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentFormBannerLogo.tsx new file mode 100644 index 0000000000..0cc0f586e6 --- /dev/null +++ b/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentFormBannerLogo.tsx @@ -0,0 +1,31 @@ +import { FormLogoState } from '~shared/types' + +import { useEnv } from '~features/env/queries' +import { usePublicFormContext } from '~features/public-form/PublicFormContext' + +import { FormBannerLogo, useFormBannerLogo } from '../../FormLogo' + +export const PaymentFormBannerLogo = (): JSX.Element => { + const { form, spcpSession, handleLogout, isLoading } = usePublicFormContext() + + const { data: { logoBucketUrl } = {} } = useEnv( + form?.startPage.logo.state === FormLogoState.Custom, + ) + + const formBannerLogoProps = useFormBannerLogo({ + logoBucketUrl, + logo: form?.startPage.logo, + agency: form?.admin.agency, + showDefaultLogoIfNoLogo: true, + colorTheme: form?.startPage.colorTheme, + }) + + return ( + + ) +} diff --git a/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentHeader.tsx b/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentHeader.tsx new file mode 100644 index 0000000000..9d81b29014 --- /dev/null +++ b/frontend/src/features/public-form/components/FormPaymentPage/components/PaymentHeader.tsx @@ -0,0 +1,24 @@ +import { usePublicFormContext } from '~features/public-form/PublicFormContext' + +import { FormHeader } from '../../FormStartPage/FormHeader' +import { useFormHeader } from '../../FormStartPage/useFormHeader' + +export const PaymentHeader = (): JSX.Element => { + const { form, spcpSession, miniHeaderRef, onMobileDrawerOpen, handleLogout } = + usePublicFormContext() + + const formHeaderProps = useFormHeader({ startPage: form?.startPage }) + + return ( + + ) +} diff --git a/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripePaymentElement.tsx b/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripePaymentElement.tsx index b7121b6e72..1787e2e167 100644 --- a/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripePaymentElement.tsx +++ b/frontend/src/features/public-form/components/FormPaymentPage/stripe/StripePaymentElement.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react' import { useParams } from 'react-router-dom' -import { Flex } from '@chakra-ui/react' +import { Box, Center, Container } from '@chakra-ui/react' import { Elements, useStripe } from '@stripe/react-stripe-js' import { loadStripe } from '@stripe/stripe-js' @@ -10,11 +10,13 @@ import InlineMessage from '~components/InlineMessage' import { useEnv } from '~features/env/queries' +import { PublicFormWrapper } from '../../PublicFormWrapper' import { CreatePaymentIntentFailureBlock, PaymentStack, PaymentSuccessSvgr, } from '../components' +import { PaymentHeader } from '../components/PaymentHeader' import { useGetPaymentInfo } from '../queries' import { GenericMessageBlock, StripePaymentBlock } from './components' @@ -22,6 +24,18 @@ import { useGetPaymentStatusFromStripe } from './queries' import { StripeReceiptContainer } from './StripeReceiptContainer' import { getPaymentViewStates, PaymentViewStates } from './utils' +const PaymentFormWrapper = ({ children }: { children: React.ReactNode }) => { + return ( + + + + {children} + + + + ) +} + const StripePaymentElement = ({ paymentId }: { paymentId: string }) => { const { data: paymentInfoData } = useGetPaymentInfo(paymentId) @@ -37,9 +51,7 @@ const StripePaymentElement = ({ paymentId }: { paymentId: string }) => { stripe={stripePromise} options={{ clientSecret: paymentInfoData.client_secret }} > - - - + ) } @@ -78,69 +90,91 @@ const StripePaymentContainer = ({ switch (viewStates) { case PaymentViewStates.Invalid: return ( - - - + <> + + + + + + + ) case PaymentViewStates.Canceled: return ( - - - + <> + + + + + + + ) case PaymentViewStates.PendingPayment: { // The item name is passed over to Stripe as PaymentIntent.description const itemName = stripePaymentStatusResponse?.paymentIntent?.description return ( <> - {secretEnv === 'production' ? null : ( - - Use '4242 4242 4242 4242' as your card number to test payments - on this form. Payments made on this form will only show in test - mode in Stripe. - - )} - - setRefetchKey(Date.now())} - paymentAmount={ - stripePaymentStatusResponse?.paymentIntent?.amount ?? 0 - } - paymentItemName={itemName} - /> - + + + {secretEnv === 'production' ? null : ( + + Use '4242 4242 4242 4242' as your card number to test payments + on this form. Payments made on this form will only show in + test mode in Stripe. + + )} + + setRefetchKey(Date.now())} + paymentAmount={ + stripePaymentStatusResponse?.paymentIntent?.amount ?? 0 + } + paymentItemName={itemName} + /> + + ) } case PaymentViewStates.Processing: return ( - - - + <> + + + + + + + ) case PaymentViewStates.Succeeded: return ( <> - - + +
+ +
+ + ) default: { diff --git a/frontend/src/features/public-form/components/FormStartPage/FormStartPage.tsx b/frontend/src/features/public-form/components/FormStartPage/FormStartPage.tsx index bfe405b80f..eab78ea300 100644 --- a/frontend/src/features/public-form/components/FormStartPage/FormStartPage.tsx +++ b/frontend/src/features/public-form/components/FormStartPage/FormStartPage.tsx @@ -20,13 +20,12 @@ export const FormStartPage = ({ miniHeaderRef, onMobileDrawerOpen, handleLogout, - isPaymentEnabled, } = usePublicFormContext() const { activeSectionId } = useFormSections() const showHeaderAndMiniHeader = useMemo( - () => !submissionData || isPaymentEnabled, - [submissionData, isPaymentEnabled], + () => !submissionData, + [submissionData], ) const formHeaderProps = useFormHeader({ startPage: form?.startPage }) diff --git a/package-lock.json b/package-lock.json index 96e181efc1..a49aed02de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "FormSG", - "version": "6.108.0", + "version": "6.109.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "FormSG", - "version": "6.108.0", + "version": "6.109.0", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-cloudwatch-logs": "^3.347.1", @@ -75,7 +75,7 @@ "libphonenumber-js": "^1.10.48", "lodash": "^4.17.21", "moment-timezone": "0.5.41", - "mongodb-memory-server-core": "^7.6.3", + "mongodb-memory-server-core": "^9.1.6", "mongodb-uri": "^0.9.7", "mongoose": "^6.12.0", "multiparty": ">=4.2.3", @@ -6761,16 +6761,6 @@ "tar-stream": "^3.1.5" } }, - "node_modules/@puppeteer/browsers/node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/@sentry-internal/tracing": { "version": "7.51.2", "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.51.2.tgz", @@ -8750,15 +8740,6 @@ "@types/node": "*" } }, - "node_modules/@types/bson": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.2.0.tgz", - "integrity": "sha512-ELCPqAdroMdcuxqwMgUpifQyRoTpyYCNr1V9xKyF40VsBobsj+BbWNRvwGchMgBPGqkw655ypkjj2MEF5ywVwg==", - "deprecated": "This is a stub types definition. bson provides its own type definitions, so you do not need this installed.", - "dependencies": { - "bson": "*" - } - }, "node_modules/@types/busboy": { "version": "1.5.0", "dev": true, @@ -9067,15 +9048,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mongodb": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz", - "integrity": "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==", - "dependencies": { - "@types/bson": "*", - "@types/node": "*" - } - }, "node_modules/@types/mongodb-uri": { "version": "0.9.1", "dev": true, @@ -9244,10 +9216,6 @@ "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.22.tgz", "integrity": "sha512-7yQiX6MWSFSvc/1wW5smJMZTZ4fHOd+hqLr3qr/HONDxHEa2bnYAsOcGBOEqFIjd4yetwMOdEDdeW+udRAQnHA==" }, - "node_modules/@types/tmp": { - "version": "0.2.3", - "license": "MIT" - }, "node_modules/@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -10525,11 +10493,11 @@ } }, "node_modules/async-mutex": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", - "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", "dependencies": { - "tslib": "^2.3.1" + "tslib": "^2.4.0" } }, "node_modules/async-mutex/node_modules/tslib": { @@ -11235,14 +11203,6 @@ "file-uri-to-path": "1.0.0" } }, - "node_modules/bl": { - "version": "2.2.1", - "license": "MIT", - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, "node_modules/bluebird": { "version": "3.7.2", "license": "MIT" @@ -13587,13 +13547,6 @@ "version": "1.0.0", "license": "MIT" }, - "node_modules/denque": { - "version": "1.4.1", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/depd": { "version": "2.0.0", "license": "MIT", @@ -17014,10 +16967,6 @@ "readable-stream": "^2.0.0" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -17357,16 +17306,6 @@ "node": ">=8.0.0" } }, - "node_modules/get-port": { - "version": "5.1.1", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-stdin": { "version": "4.0.1", "dev": true, @@ -23033,16 +22972,6 @@ "escape-string-regexp": "^1.0.5" } }, - "node_modules/md5-file": { - "version": "5.0.0", - "license": "MIT", - "bin": { - "md5-file": "cli.js" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/md5.js": { "version": "1.3.5", "dev": true, @@ -23360,27 +23289,32 @@ } }, "node_modules/mongodb": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", - "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", - "dependencies": { - "bl": "^2.2.1", - "bson": "^1.1.4", - "denque": "^1.4.1", - "optional-require": "^1.1.8", - "safe-buffer": "^5.1.2" + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "dependencies": { + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" }, "engines": { - "node": ">=4" + "node": ">=14.20.1" }, "optionalDependencies": { - "saslprep": "^1.0.0" + "@mongodb-js/saslprep": "^1.1.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.0.0", + "kerberos": "^1.0.0 || ^2.0.0", + "mongodb-client-encryption": ">=2.3.0 <3", + "snappy": "^7.2.2" }, "peerDependenciesMeta": { - "aws4": { + "@aws-sdk/credential-providers": { "optional": true }, - "bson-ext": { + "@mongodb-js/zstd": { "optional": true }, "kerberos": { @@ -23389,9 +23323,6 @@ "mongodb-client-encryption": { "optional": true }, - "mongodb-extjson": { - "optional": true - }, "snappy": { "optional": true } @@ -23407,40 +23338,36 @@ } }, "node_modules/mongodb-memory-server-core": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-7.6.3.tgz", - "integrity": "sha512-5rv79YlPoPvguRfFv1fvR78z69/QohGD+65f9UYWDfD70ykXpf6tAXPpWJ4ww/ues7FIVepkFCr3aiUvu6lA+A==", - "dependencies": { - "@types/mongodb": "^3.6.20", - "@types/tmp": "^0.2.2", - "async-mutex": "^0.3.2", - "camelcase": "^6.1.0", - "debug": "^4.2.0", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.6.tgz", + "integrity": "sha512-3H/dq5II+XcSbK80hicMw4zFlDxcpjt4oWJq76RlOVuLoaf3AFqVheR6Vqx9ymlIqER4Jni58FMCIIRbesia1A==", + "dependencies": { + "async-mutex": "^0.4.0", + "camelcase": "^6.3.0", + "debug": "^4.3.4", "find-cache-dir": "^3.3.2", - "get-port": "^5.1.1", - "https-proxy-agent": "^5.0.0", - "md5-file": "^5.0.0", - "mkdirp": "^1.0.4", - "mongodb": "^3.7.3", - "new-find-package-json": "^1.1.0", - "semver": "^7.3.5", - "tar-stream": "^2.1.4", - "tmp": "^0.2.1", - "tslib": "^2.3.0", - "uuid": "^8.3.1", + "follow-redirects": "^1.15.3", + "https-proxy-agent": "^7.0.2", + "mongodb": "^5.9.1", + "new-find-package-json": "^2.0.0", + "semver": "^7.5.4", + "tar-stream": "^3.0.0", + "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "engines": { - "node": ">=12.22.0" + "node": ">=14.20.1" } }, - "node_modules/mongodb-memory-server-core/node_modules/bl": { - "version": "4.1.0", - "license": "MIT", + "node_modules/mongodb-memory-server-core/node_modules/agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" } }, "node_modules/mongodb-memory-server-core/node_modules/camelcase": { @@ -23468,6 +23395,18 @@ "url": "https://github.com/avajs/find-cache-dir?sponsor=1" } }, + "node_modules/mongodb-memory-server-core/node_modules/https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/mongodb-memory-server-core/node_modules/make-dir": { "version": "3.1.0", "license": "MIT", @@ -23488,16 +23427,6 @@ "semver": "bin/semver.js" } }, - "node_modules/mongodb-memory-server-core/node_modules/mkdirp": { - "version": "1.0.4", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mongodb-memory-server-core/node_modules/pkg-dir": { "version": "4.2.0", "license": "MIT", @@ -23508,21 +23437,10 @@ "node": ">=8" } }, - "node_modules/mongodb-memory-server-core/node_modules/readable-stream": { - "version": "3.6.0", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/mongodb-memory-server-core/node_modules/semver": { - "version": "7.3.8", - "license": "ISC", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -23533,32 +23451,11 @@ "node": ">=10" } }, - "node_modules/mongodb-memory-server-core/node_modules/tar-stream": { - "version": "2.2.0", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/mongodb-memory-server-core/node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/mongodb-memory-server-core/node_modules/uuid": { - "version": "8.3.2", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/mongodb-uri": { "version": "0.9.7", "license": "MIT", @@ -23567,20 +23464,11 @@ } }, "node_modules/mongodb/node_modules/bson": { - "version": "1.1.6", - "license": "Apache-2.0", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==", "engines": { - "node": ">=0.6.19" - } - }, - "node_modules/mongodb/node_modules/optional-require": { - "version": "1.1.8", - "license": "Apache-2.0", - "dependencies": { - "require-at": "^1.0.6" - }, - "engines": { - "node": ">=4" + "node": ">=14.20.1" } }, "node_modules/mongoose": { @@ -23830,22 +23718,16 @@ "integrity": "sha512-kPZKRs4VkdloCGQXPoP84q4sT/1Z+lYM61AXyV8wWa2hnuo5KpPBF2S3crSFnMrOgUISmEBP8Vo/ngGZX60NhA==" }, "node_modules/new-find-package-json": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-1.2.0.tgz", - "integrity": "sha512-Z4v/wBxApGh1cCGEhNmq4p8wjDvM6R6vEuYzlAhzOlXBKLJfjyMvwd+ZHR9fyYKVvXfEn4Z3YX6MD470PxpVbQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", "dependencies": { - "debug": "^4.3.4", - "tslib": "^2.4.0" + "debug": "^4.3.4" }, "engines": { "node": ">=12.22.0" } }, - "node_modules/new-find-package-json/node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, "node_modules/ngrok": { "version": "4.3.3", "dev": true, @@ -26543,13 +26425,6 @@ "uuid": "bin/uuid" } }, - "node_modules/require-at": { - "version": "1.0.6", - "license": "Apache-2.0", - "engines": { - "node": ">=4" - } - }, "node_modules/require-directory": { "version": "2.1.1", "license": "MIT", @@ -26891,17 +26766,6 @@ "truncate-utf8-bytes": "^1.0.0" } }, - "node_modules/saslprep": { - "version": "1.0.3", - "license": "MIT", - "optional": true, - "dependencies": { - "sparse-bitfield": "^3.0.3" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", @@ -28190,6 +28054,16 @@ "node": ">= 10" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/tar/node_modules/chownr": { "version": "2.0.0", "license": "ISC", @@ -29208,16 +29082,6 @@ "node": ">=0.6.0" } }, - "node_modules/tmp": { - "version": "0.2.1", - "license": "MIT", - "dependencies": { - "rimraf": "^3.0.0" - }, - "engines": { - "node": ">=8.17.0" - } - }, "node_modules/tmp-promise": { "version": "1.1.0", "dev": true, @@ -29249,20 +29113,6 @@ "node": ">=6" } }, - "node_modules/tmp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -36896,16 +36746,6 @@ "pump": "^3.0.0", "tar-stream": "^3.1.5" } - }, - "tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "requires": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } } } }, @@ -38602,14 +38442,6 @@ "@types/node": "*" } }, - "@types/bson": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/bson/-/bson-4.2.0.tgz", - "integrity": "sha512-ELCPqAdroMdcuxqwMgUpifQyRoTpyYCNr1V9xKyF40VsBobsj+BbWNRvwGchMgBPGqkw655ypkjj2MEF5ywVwg==", - "requires": { - "bson": "*" - } - }, "@types/busboy": { "version": "1.5.0", "dev": true, @@ -38885,15 +38717,6 @@ "version": "3.0.5", "dev": true }, - "@types/mongodb": { - "version": "3.6.20", - "resolved": "https://registry.npmjs.org/@types/mongodb/-/mongodb-3.6.20.tgz", - "integrity": "sha512-WcdpPJCakFzcWWD9juKoZbRtQxKIMYF/JIAM4JrNHrMcnJL6/a2NWjXxW7fo9hxboxxkg+icff8d7+WIEvKgYQ==", - "requires": { - "@types/bson": "*", - "@types/node": "*" - } - }, "@types/mongodb-uri": { "version": "0.9.1", "dev": true @@ -39045,9 +38868,6 @@ "resolved": "https://registry.npmjs.org/@types/swagger-schema-official/-/swagger-schema-official-2.0.22.tgz", "integrity": "sha512-7yQiX6MWSFSvc/1wW5smJMZTZ4fHOd+hqLr3qr/HONDxHEa2bnYAsOcGBOEqFIjd4yetwMOdEDdeW+udRAQnHA==" }, - "@types/tmp": { - "version": "0.2.3" - }, "@types/tough-cookie": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", @@ -39899,11 +39719,11 @@ "dev": true }, "async-mutex": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.3.2.tgz", - "integrity": "sha512-HuTK7E7MT7jZEh1P9GtRW9+aTWiDWWi9InbZ5hjxrnRa39KS4BW04+xLBhYNS2aXhHUIKZSw3gj4Pn1pj+qGAA==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.1.tgz", + "integrity": "sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA==", "requires": { - "tslib": "^2.3.1" + "tslib": "^2.4.0" }, "dependencies": { "tslib": { @@ -40386,13 +40206,6 @@ "file-uri-to-path": "1.0.0" } }, - "bl": { - "version": "2.2.1", - "requires": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, "bluebird": { "version": "3.7.2" }, @@ -42044,9 +41857,6 @@ "delegates": { "version": "1.0.0" }, - "denque": { - "version": "1.4.1" - }, "depd": { "version": "2.0.0" }, @@ -44394,9 +44204,6 @@ "readable-stream": "^2.0.0" } }, - "fs-constants": { - "version": "1.0.0" - }, "fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -44613,9 +44420,6 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, - "get-port": { - "version": "5.1.1" - }, "get-stdin": { "version": "4.0.1", "dev": true @@ -48594,9 +48398,6 @@ "escape-string-regexp": "^1.0.5" } }, - "md5-file": { - "version": "5.0.0" - }, "md5.js": { "version": "1.3.5", "dev": true, @@ -48819,26 +48620,20 @@ } }, "mongodb": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz", - "integrity": "sha512-K5q8aBqEXMwWdVNh94UQTwZ6BejVbFhh1uB6c5FKtPE9eUMZPUO3sRZdgIEcHSrAWmxzpG/FeODDKL388sqRmw==", - "requires": { - "bl": "^2.2.1", - "bson": "^1.1.4", - "denque": "^1.4.1", - "optional-require": "^1.1.8", - "safe-buffer": "^5.1.2", - "saslprep": "^1.0.0" + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", + "requires": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^5.5.0", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" }, "dependencies": { "bson": { - "version": "1.1.6" - }, - "optional-require": { - "version": "1.1.8", - "requires": { - "require-at": "^1.0.6" - } + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-5.5.1.tgz", + "integrity": "sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g==" } } }, @@ -48852,36 +48647,30 @@ } }, "mongodb-memory-server-core": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-7.6.3.tgz", - "integrity": "sha512-5rv79YlPoPvguRfFv1fvR78z69/QohGD+65f9UYWDfD70ykXpf6tAXPpWJ4ww/ues7FIVepkFCr3aiUvu6lA+A==", - "requires": { - "@types/mongodb": "^3.6.20", - "@types/tmp": "^0.2.2", - "async-mutex": "^0.3.2", - "camelcase": "^6.1.0", - "debug": "^4.2.0", + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/mongodb-memory-server-core/-/mongodb-memory-server-core-9.1.6.tgz", + "integrity": "sha512-3H/dq5II+XcSbK80hicMw4zFlDxcpjt4oWJq76RlOVuLoaf3AFqVheR6Vqx9ymlIqER4Jni58FMCIIRbesia1A==", + "requires": { + "async-mutex": "^0.4.0", + "camelcase": "^6.3.0", + "debug": "^4.3.4", "find-cache-dir": "^3.3.2", - "get-port": "^5.1.1", - "https-proxy-agent": "^5.0.0", - "md5-file": "^5.0.0", - "mkdirp": "^1.0.4", - "mongodb": "^3.7.3", - "new-find-package-json": "^1.1.0", - "semver": "^7.3.5", - "tar-stream": "^2.1.4", - "tmp": "^0.2.1", - "tslib": "^2.3.0", - "uuid": "^8.3.1", + "follow-redirects": "^1.15.3", + "https-proxy-agent": "^7.0.2", + "mongodb": "^5.9.1", + "new-find-package-json": "^2.0.0", + "semver": "^7.5.4", + "tar-stream": "^3.0.0", + "tslib": "^2.6.2", "yauzl": "^2.10.0" }, "dependencies": { - "bl": { - "version": "4.1.0", + "agent-base": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", + "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", "requires": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "debug": "^4.3.4" } }, "camelcase": { @@ -48895,6 +48684,15 @@ "pkg-dir": "^4.1.0" } }, + "https-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz", + "integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==", + "requires": { + "agent-base": "^7.0.2", + "debug": "4" + } + }, "make-dir": { "version": "3.1.0", "requires": { @@ -48906,46 +48704,24 @@ } } }, - "mkdirp": { - "version": "1.0.4" - }, "pkg-dir": { "version": "4.2.0", "requires": { "find-up": "^4.0.0" } }, - "readable-stream": { - "version": "3.6.0", - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, "semver": { - "version": "7.3.8", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "requires": { "lru-cache": "^6.0.0" } }, - "tar-stream": { - "version": "2.2.0", - "requires": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - } - }, "tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - }, - "uuid": { - "version": "8.3.2" } } }, @@ -49139,19 +48915,11 @@ "integrity": "sha512-kPZKRs4VkdloCGQXPoP84q4sT/1Z+lYM61AXyV8wWa2hnuo5KpPBF2S3crSFnMrOgUISmEBP8Vo/ngGZX60NhA==" }, "new-find-package-json": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-1.2.0.tgz", - "integrity": "sha512-Z4v/wBxApGh1cCGEhNmq4p8wjDvM6R6vEuYzlAhzOlXBKLJfjyMvwd+ZHR9fyYKVvXfEn4Z3YX6MD470PxpVbQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/new-find-package-json/-/new-find-package-json-2.0.0.tgz", + "integrity": "sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==", "requires": { - "debug": "^4.3.4", - "tslib": "^2.4.0" - }, - "dependencies": { - "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" - } + "debug": "^4.3.4" } }, "ngrok": { @@ -51059,9 +50827,6 @@ } } }, - "require-at": { - "version": "1.0.6" - }, "require-directory": { "version": "2.1.1" }, @@ -51296,13 +51061,6 @@ "truncate-utf8-bytes": "^1.0.0" } }, - "saslprep": { - "version": "1.0.3", - "optional": true, - "requires": { - "sparse-bitfield": "^3.0.3" - } - }, "sax": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", @@ -52251,6 +52009,16 @@ } } }, + "tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "requires": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "terser": { "version": "4.8.0", "dev": true, @@ -52940,22 +52708,6 @@ "setimmediate": "^1.0.4" } }, - "tmp": { - "version": "0.2.1", - "requires": { - "rimraf": "^3.0.0" - }, - "dependencies": { - "rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "requires": { - "glob": "^7.1.3" - } - } - } - }, "tmp-promise": { "version": "1.1.0", "dev": true, diff --git a/package.json b/package.json index c8e3aeca7b..7ddbb4035b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "FormSG", "description": "Form Manager for Government", - "version": "6.108.0", + "version": "6.109.0", "homepage": "https://form.gov.sg", "authors": [ "FormSG " @@ -121,7 +121,7 @@ "libphonenumber-js": "^1.10.48", "lodash": "^4.17.21", "moment-timezone": "0.5.41", - "mongodb-memory-server-core": "^7.6.3", + "mongodb-memory-server-core": "^9.1.6", "mongodb-uri": "^0.9.7", "mongoose": "^6.12.0", "multiparty": ">=4.2.3", diff --git a/shared/types/form/__tests__/product.spec.ts b/shared/types/form/__tests__/product.spec.ts new file mode 100644 index 0000000000..9b304c302a --- /dev/null +++ b/shared/types/form/__tests__/product.spec.ts @@ -0,0 +1,126 @@ +import { ObjectId } from 'bson' +import { isPaymentsProducts } from '../product' +import { Product } from '../product' + +describe('Product validation', () => { + it('should return false if products is not an array', () => { + // Arrange + const mockProductNonArray = 'some thing' + + // Assert + expect(isPaymentsProducts(mockProductNonArray)).toBe(false) + }) + + it('should return true if products is an empty array', () => { + // Arrange + const mockProductEmptyArray: Product[] = [] + + // Assert + expect(isPaymentsProducts(mockProductEmptyArray)).toBe(false) + }) + + it('should return false if product has invalid object id', () => { + // Arrange + const mockProductInvalidId: any = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: 'some id', + }, + ] + + // Assert + expect(isPaymentsProducts(mockProductInvalidId)).toBe(false) + }) + + it('should return false if product has no name', () => { + // Arrange + const mockProductWrongName = [ + { + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductWrongName)).toBe(false) + }) + + it('should return false if there are multiple products and at least one has no name', () => { + // Arrange + const mockMultipleProductOneNoName = [ + { + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + { + name: 'has name', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockMultipleProductOneNoName)).toBe(false) + }) + + it('should return true if product has valid object id and name', () => { + // Arrange + const mockProductsCorrectShape = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductsCorrectShape)).toBe(true) + }) + + it('should return true if multiple products have valid object id and name', () => { + // Arrange + const mockProductsCorrectShapeMultiple = [ + { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + { + name: 'another name', + description: 'another description', + multi_qty: true, + min_qty: 1, + max_qty: 1, + amount_cents: 1, + _id: new ObjectId(), + }, + ] as unknown as Product[] + + // Assert + expect(isPaymentsProducts(mockProductsCorrectShapeMultiple)).toBe(true) + }) +}) diff --git a/shared/types/form/form.ts b/shared/types/form/form.ts index 8703e67e8a..605e7771bc 100644 --- a/shared/types/form/form.ts +++ b/shared/types/form/form.ts @@ -16,6 +16,7 @@ import { DateString } from '../generic' import { FormLogic, LogicDto } from './form_logic' import { PaymentChannel, PaymentMethodType, PaymentType } from '../payment' import { Product } from './product' +import { FormWorkflow, FormWorkflowDto } from './workflow' export type FormId = Opaque @@ -126,17 +127,6 @@ export type FormBusinessField = { gstRegNo?: string } -export enum WorkflowType { - Static = 'static', - Dynamic = 'dynamic', -} - -export type FormWorkflowSettings = Array<{ - _id?: string - workflow_type: WorkflowType - emails: string[] -}> - export interface FormBase { title: string admin: UserDto['_id'] @@ -185,7 +175,7 @@ export interface StorageFormBase extends FormBase { export interface MultirespondentFormBase extends FormBase { responseMode: FormResponseMode.Multirespondent publicKey: string - workflow?: FormWorkflowSettings + workflow: FormWorkflow } /** @@ -203,7 +193,10 @@ export type StorageFormDto = Merge export type EmailFormDto = Merge -export type MultirespondentFormDto = Merge +export type MultirespondentFormDto = Merge< + MultirespondentFormBase, + FormDtoBase & { workflow: FormWorkflowDto } +> export type FormDto = StorageFormDto | EmailFormDto | MultirespondentFormDto @@ -365,7 +358,7 @@ export type FormPermissionsDto = FormPermission[] export type PermissionsUpdateDto = FormPermission[] export type PaymentsUpdateDto = FormPaymentsField export type BusinessUpdateDto = FormBusinessField -export type WorkflowUpdateDto = FormWorkflowSettings +export type WorkflowUpdateDto = FormWorkflowDto export type PaymentsProductUpdateDto = ProductsPaymentField['products'] export type SendFormOtpResponseDto = { diff --git a/shared/types/form/index.ts b/shared/types/form/index.ts index 2e0893c107..cd1acace80 100644 --- a/shared/types/form/index.ts +++ b/shared/types/form/index.ts @@ -6,3 +6,4 @@ export * from './form_logic' export * from './form_logo' export * from './product' export * from './form_issue' +export * from './workflow' diff --git a/shared/types/form/product.ts b/shared/types/form/product.ts index 3220c66e81..533e5d195c 100644 --- a/shared/types/form/product.ts +++ b/shared/types/form/product.ts @@ -16,3 +16,23 @@ export type ProductItem = { selected: boolean quantity: number } + +// Typeguard for Product +export const isPaymentsProducts = ( + products: unknown, +): products is Product[] => { + if (!Array.isArray(products)) { + return false + } + return ( + products.length > 0 && + products.every((product) => { + return ( + product._id && + String(product._id).match(/^[0-9a-fA-F]{24}$/) && + product.name && + typeof product.name === 'string' + ) + }) + ) +} diff --git a/shared/types/form/workflow.ts b/shared/types/form/workflow.ts new file mode 100644 index 0000000000..bf9549be2c --- /dev/null +++ b/shared/types/form/workflow.ts @@ -0,0 +1,17 @@ +export enum WorkflowType { + Static = 'static', + Dynamic = 'dynamic', +} + +export type FormWorkflowStep = { + workflow_type: WorkflowType + emails: string[] +} + +export type FormWorkflow = Array + +// Additional props to be added for DTOs + +export type FormWorkflowStepDto = FormWorkflowStep & { _id: string } + +export type FormWorkflowDto = Array diff --git a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts index fa1eeeb10b..606fd5806f 100644 --- a/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts +++ b/src/app/modules/form/admin-form/__tests__/admin-form.controller.spec.ts @@ -27,7 +27,6 @@ import IncomingEncryptSubmission from 'src/app/modules/submission/encrypt-submis import { AttachmentTooLargeError, ConflictError, - InvalidEncodingError, InvalidFileExtensionError, ProcessingError, ResponseModeError, @@ -6508,29 +6507,10 @@ describe('admin-form.controller', () => { expect( MockEncryptSubmissionService.checkFormIsEncryptMode, ).toHaveBeenCalledWith(MOCK_FORM) - expect(IncomingEncryptSubmission.init).toHaveBeenCalledWith( - MOCK_FORM, - MOCK_RESPONSES, - MOCK_ENCRYPTED_CONTENT, - ) - expect( - MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, - ).toHaveBeenCalledWith({ - form: MOCK_FORM, - encryptedContent: MOCK_ENCRYPTED_CONTENT, - verifiedContent: '', - version: MOCK_VERSION, - }) - expect(MockSubmissionService.sendEmailConfirmations).toHaveBeenCalledWith( - { - form: MOCK_FORM, - submission: MOCK_SUBMISSION, - recipientData: [], - }, - ) + expect(mockRes.json).toHaveBeenCalledWith({ message: 'Form submission successful.', - submissionId: MOCK_SUBMISSION_ID, + submissionId: expect.stringMatching(/^[0-9a-fA-F]{24}$/), // to validate that fake submission ID generated is a hexstring }) }) @@ -6874,112 +6854,6 @@ describe('admin-form.controller', () => { }) }) - it('should return 400 when encrypted content encoding is invalid', async () => { - mockIncomingEncryptSubmissionInit.mockReturnValueOnce( - err(new InvalidEncodingError()), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, - body: MOCK_SUBMISSION_BODY, - session: { - user: { - _id: MOCK_USER_ID, - }, - }, - }) - const mockRes = expressHandler.mockResponse() - - await AdminFormController.submitEncryptPreview( - mockReq, - mockRes, - jest.fn(), - ) - - expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( - MOCK_USER_ID, - ) - expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( - { - user: MOCK_USER, - formId: MOCK_FORM_ID, - level: PermissionLevel.Read, - }, - ) - expect( - MockEncryptSubmissionService.checkFormIsEncryptMode, - ).toHaveBeenCalledWith(MOCK_FORM) - expect(IncomingEncryptSubmission.init).toHaveBeenCalledWith( - MOCK_FORM, - MOCK_RESPONSES, - MOCK_ENCRYPTED_CONTENT, - ) - expect( - MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, - ).not.toHaveBeenCalled() - expect( - MockSubmissionService.sendEmailConfirmations, - ).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expect.any(String), - }) - }) - - it('should return 400 when responses cannot be processed', async () => { - mockIncomingEncryptSubmissionInit.mockReturnValueOnce( - err(new ProcessingError()), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, - body: MOCK_SUBMISSION_BODY, - session: { - user: { - _id: MOCK_USER_ID, - }, - }, - }) - const mockRes = expressHandler.mockResponse() - - await AdminFormController.submitEncryptPreview( - mockReq, - mockRes, - jest.fn(), - ) - - expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( - MOCK_USER_ID, - ) - expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( - { - user: MOCK_USER, - formId: MOCK_FORM_ID, - level: PermissionLevel.Read, - }, - ) - expect( - MockEncryptSubmissionService.checkFormIsEncryptMode, - ).toHaveBeenCalledWith(MOCK_FORM) - expect(IncomingEncryptSubmission.init).toHaveBeenCalledWith( - MOCK_FORM, - MOCK_RESPONSES, - MOCK_ENCRYPTED_CONTENT, - ) - expect( - MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, - ).not.toHaveBeenCalled() - expect( - MockSubmissionService.sendEmailConfirmations, - ).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expect.any(String), - }) - }) - it('should return 409 when form fields submitted are not updated', async () => { mockIncomingEncryptSubmissionInit.mockReturnValueOnce( err(new ConflictError('')), @@ -7016,74 +6890,12 @@ describe('admin-form.controller', () => { expect( MockEncryptSubmissionService.checkFormIsEncryptMode, ).toHaveBeenCalledWith(MOCK_FORM) - expect(IncomingEncryptSubmission.init).toHaveBeenCalledWith( - MOCK_FORM, - MOCK_RESPONSES, - MOCK_ENCRYPTED_CONTENT, - ) - expect( - MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, - ).not.toHaveBeenCalled() - expect( - MockSubmissionService.sendEmailConfirmations, - ).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(409) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expect.any(String), - }) - }) - - it('should return 400 when responses cannot be validated', async () => { - mockIncomingEncryptSubmissionInit.mockReturnValueOnce( - err(new ValidateFieldError()), - ) - const mockReq = expressHandler.mockRequest({ - params: { - formId: MOCK_FORM_ID, - }, - body: MOCK_SUBMISSION_BODY, - session: { - user: { - _id: MOCK_USER_ID, - }, - }, - }) - const mockRes = expressHandler.mockResponse() - - await AdminFormController.submitEncryptPreview( - mockReq, - mockRes, - jest.fn(), - ) - - expect(MockUserService.getPopulatedUserById).toHaveBeenCalledWith( - MOCK_USER_ID, - ) - expect(MockAuthService.getFormAfterPermissionChecks).toHaveBeenCalledWith( - { - user: MOCK_USER, - formId: MOCK_FORM_ID, - level: PermissionLevel.Read, - }, - ) - expect( - MockEncryptSubmissionService.checkFormIsEncryptMode, - ).toHaveBeenCalledWith(MOCK_FORM) - expect(IncomingEncryptSubmission.init).toHaveBeenCalledWith( - MOCK_FORM, - MOCK_RESPONSES, - MOCK_ENCRYPTED_CONTENT, - ) expect( MockEncryptSubmissionService.createEncryptSubmissionWithoutSave, ).not.toHaveBeenCalled() expect( MockSubmissionService.sendEmailConfirmations, ).not.toHaveBeenCalled() - expect(mockRes.status).toHaveBeenCalledWith(400) - expect(mockRes.json).toHaveBeenCalledWith({ - message: expect.any(String), - }) }) }) diff --git a/src/app/modules/form/admin-form/admin-form.controller.ts b/src/app/modules/form/admin-form/admin-form.controller.ts index 2df6337ffe..999f918008 100644 --- a/src/app/modules/form/admin-form/admin-form.controller.ts +++ b/src/app/modules/form/admin-form/admin-form.controller.ts @@ -72,9 +72,7 @@ import { mapRouteError as mapEmailSubmissionError, SubmissionEmailObj, } from '../../submission/email-submission/email-submission.util' -import * as EncryptSubmissionMiddleware from '../../submission/encrypt-submission/encrypt-submission.middleware' import * as EncryptSubmissionService from '../../submission/encrypt-submission/encrypt-submission.service' -import IncomingEncryptSubmission from '../../submission/encrypt-submission/IncomingEncryptSubmission.class' import ParsedResponsesObject from '../../submission/ParsedResponsesObject.class' import * as ReceiverMiddleware from '../../submission/receiver/receiver.middleware' import * as SubmissionService from '../../submission/submission.service' @@ -1692,7 +1690,6 @@ export const submitEncryptPreview: ControllerHandler< const { formId } = req.params const sessionUserId = (req.session as AuthedSessionData).user._id // No need to process attachments as we don't do anything with them - const { encryptedContent, responses, version } = req.body const logMeta = { action: 'submitEncryptPreview', formId, @@ -1718,41 +1715,12 @@ export const submitEncryptPreview: ControllerHandler< return error }), ) - .andThen((form) => - IncomingEncryptSubmission.init(form, responses, encryptedContent) - .map((incomingSubmission) => ({ incomingSubmission, form })) - .mapErr((error) => { - logger.error({ - message: 'Error while processing incoming preview submission.', - meta: logMeta, - error, - }) - return error - }), - ) - .map(({ incomingSubmission, form }) => { - const submission = - EncryptSubmissionService.createEncryptSubmissionWithoutSave({ - form, - encryptedContent: incomingSubmission.encryptedContent, - // Don't bother encrypting and signing mock variables for previews - verifiedContent: '', - version, - }) - - void SubmissionService.sendEmailConfirmations({ - form, - submission, - recipientData: extractEmailConfirmationData( - incomingSubmission.responses, - form.form_fields, - ), - }) - + .map(() => { + const fakeSubmissionId = new ObjectId().toString() // Return the reply early to the submitter return res.json({ message: 'Form submission successful.', - submissionId: submission._id, + submissionId: fakeSubmissionId, }) }) .mapErr((error) => { @@ -1762,7 +1730,6 @@ export const submitEncryptPreview: ControllerHandler< } export const handleEncryptPreviewSubmission = [ - EncryptSubmissionMiddleware.validateEncryptSubmissionParams, submitEncryptPreview, ] as ControllerHandler[] diff --git a/src/app/modules/payments/__tests__/payments.service.spec.ts b/src/app/modules/payments/__tests__/payments.service.spec.ts index e9113b8d96..dc08076e33 100644 --- a/src/app/modules/payments/__tests__/payments.service.spec.ts +++ b/src/app/modules/payments/__tests__/payments.service.spec.ts @@ -2,13 +2,14 @@ import dbHandler from '__tests__/unit/backend/helpers/jest-db' import { ObjectId } from 'bson' import moment from 'moment-timezone' import mongoose, { Query } from 'mongoose' -import { PaymentStatus } from 'shared/types' +import { PaymentStatus, Product, ProductId, ProductItem } from 'shared/types' import getAgencyModel from 'src/app/models/agency.server.model' import getPaymentModel from 'src/app/models/payment.server.model' import { InvalidDomainError } from '../../auth/auth.errors' import { DatabaseError } from '../../core/core.errors' +import { InvalidPaymentProductsError } from '../payments.errors' import * as PaymentsService from '../payments.service' const Payment = getPaymentModel(mongoose) @@ -73,6 +74,357 @@ describe('payments.service', () => { }) }) + describe('validatePaymentProducts', () => { + const mockValidProduct = { + name: 'some name', + description: 'some description', + multi_qty: false, + min_qty: 1, + max_qty: 1, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockValidProductsDefinition = [mockValidProduct] + + const mockValidProductSubmission: ProductItem[] = [ + { data: mockValidProduct, quantity: 1, selected: true }, + ] + + it('should return without error if payment products are valid', () => { + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockValidProductSubmission, + ) + + // Assert + expect(result.isOk()).toBeTrue() + }) + + it('should return with error if there are duplicate payment products', () => { + // Arrange + const mockDuplicatedProductSubmission: ProductItem[] = [ + { data: mockValidProduct, quantity: 1, selected: true }, + { data: mockValidProduct, quantity: 1, selected: true }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockDuplicatedProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'You have selected a duplicate product.', + ) + }) + + it('should return with error if the payment product id cannot be found', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + _id: new ObjectId() as unknown as ProductId, + }, + quantity: 1, + selected: true, + }, + { + data: mockValidProduct, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the description has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + description: 'some other description', + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the name has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + name: 'some other name', + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if multi_qty has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + multi_qty: true, + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if the max_qty has changed', () => { + // Arrange + const mockInvalidProductSubmission: ProductItem[] = [ + { + data: { + ...mockValidProduct, + max_qty: 5, + }, + quantity: 1, + selected: true, + }, + ] + + // Act + const result = PaymentsService.validatePaymentProducts( + mockValidProductsDefinition, + mockInvalidProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available.', + ) + }) + + it('should return with error if more than 1 quantity selected when multi_qty is disabled', () => { + // Arrange + const mockSingleQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: false, + min_qty: 1, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockSingleQuantityProduct, quantity: 2, selected: true }, + ] + + const mockProductDefinition = [mockSingleQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected more than 1 quantity when it is not allowed', + ) + }) + + it('should return with error if less than min quantity selected when multi_qty is enabled', () => { + // Arrange + const mockMultiQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockMultiQuantityProduct, quantity: 1, selected: true }, + ] + + const mockProductDefinition = [mockMultiQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected an invalid quantity below the limit', + ) + }) + + it('should return with error if more than max quantity selected when multi_qty is enabled', () => { + // Arrange + const mockMultiQuantityProduct = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductSubmission = [ + { data: mockMultiQuantityProduct, quantity: 10, selected: true }, + ] + + const mockProductDefinition = [mockMultiQuantityProduct] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmission, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'Selected an invalid quantity above the limit', + ) + }) + + it('should return with error if submitted price is not the same as in form definition', () => { + // Arrange + const mockProductWithCorrectPrice = { + name: 'some name', + description: 'some description', + multi_qty: true, + min_qty: 3, + max_qty: 5, + amount_cents: 1000, + _id: new ObjectId(), + } as unknown as Product + + const mockProductWithIncorrectPrice = { + ...mockProductWithCorrectPrice, + amount_cents: 500, + } + + const mockProductSubmissionWithIncorrectPrice = [ + { + data: mockProductWithIncorrectPrice, + quantity: 3, + selected: true, + }, + ] + + const mockProductDefinition = [mockProductWithCorrectPrice] + + // Act + + const result = PaymentsService.validatePaymentProducts( + mockProductDefinition, + mockProductSubmissionWithIncorrectPrice, + ) + + // Assert + expect(result.isErr()).toBeTrue() + expect(result._unsafeUnwrapErr()).toBeInstanceOf( + InvalidPaymentProductsError, + ) + expect(result._unsafeUnwrapErr().message).toContain( + 'There has been a change in the products available', + ) + }) + }) + describe('findLatestSuccessfulPaymentByEmailAndFormId', () => { const expectedObjectId = new ObjectId() const email = 'someone@mail.com' diff --git a/src/app/modules/payments/payments.errors.ts b/src/app/modules/payments/payments.errors.ts index eec9479fac..c943365faa 100644 --- a/src/app/modules/payments/payments.errors.ts +++ b/src/app/modules/payments/payments.errors.ts @@ -29,3 +29,9 @@ export class PaymentAccountInformationError extends ApplicationError { super(message) } } + +export class InvalidPaymentProductsError extends ApplicationError { + constructor(message = 'Invalid payment submission') { + super(message) + } +} diff --git a/src/app/modules/payments/payments.service.ts b/src/app/modules/payments/payments.service.ts index 68fad2e851..3ed2630f3d 100644 --- a/src/app/modules/payments/payments.service.ts +++ b/src/app/modules/payments/payments.service.ts @@ -1,8 +1,9 @@ +import { isEqual, omit } from 'lodash' import moment from 'moment-timezone' import mongoose, { Types } from 'mongoose' -import { errAsync, okAsync, ResultAsync } from 'neverthrow' +import { err, errAsync, ok, okAsync, Result, ResultAsync } from 'neverthrow' -import { PaymentStatus } from '../../../../shared/types' +import { PaymentStatus, Product, ProductItem } from '../../../../shared/types' import { IPaymentSchema } from '../../../types' import { createLoggerWithLabel } from '../../config/logger' import getPaymentModel from '../../models/payment.server.model' @@ -25,6 +26,7 @@ import { findSubmissionById } from '../submission/submission.service' import { ConfirmedPaymentNotFoundError, + InvalidPaymentProductsError, PaymentAlreadyConfirmedError, PaymentNotFoundError, } from './payments.errors' @@ -368,3 +370,106 @@ export const sendOnboardingEmailIfEligible = ( MailService.sendPaymentOnboardingEmail({ email }), ) } + +/** + * Validates that payment by product is valid + */ +export const validatePaymentProducts = ( + formProductsDefinition: Product[], + submittedPaymentProducts: ProductItem[], +): Result => { + const logMeta = { + action: 'validatePayments', + } + + // Check that no duplicate payment products (by id) are selected + const selectedProducts = submittedPaymentProducts.filter( + (product) => product.selected, + ) + + const selectedProductIds = new Set( + selectedProducts.map((product) => product.data._id), + ) + + if (selectedProductIds.size !== selectedProducts.length) { + logger.error({ + message: 'Duplicate payment products selected', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + 'You have selected a duplicate product. Please refresh and try again.', + ), + ) + } + + for (const product of submittedPaymentProducts) { + // Check that every selected product matches the form definition + + const productIdSubmitted = product.data._id + const productDefinition = formProductsDefinition.find( + (product) => String(product._id) === String(productIdSubmitted), + ) + + if ( + !productDefinition || + !isEqual(omit(productDefinition, '_id'), omit(product.data, '_id')) + ) { + logger.error({ + message: 'Invalid payment product selected.', + meta: logMeta, + }) + return err( + new InvalidPaymentProductsError( + 'There has been a change in the products available. Please refresh and try again.', + ), + ) + } + + // Check that the quantity of the product is valid + + if (!productDefinition.multi_qty && product.quantity > 1) { + logger.error({ + message: 'Invalid payment product quantity', + meta: logMeta, + }) + return err( + new InvalidPaymentProductsError( + 'Selected more than 1 quantity when it is not allowed. Please refresh and try again.', + ), + ) + } + + if (productDefinition.multi_qty) { + if (product.quantity < productDefinition.min_qty) { + logger.error({ + message: + 'Selected an invalid payment product quantity below the limit', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + `Selected an invalid quantity below the limit. Please refresh and try again.`, + ), + ) + } + if (product.quantity > productDefinition.max_qty) { + logger.error({ + message: + 'Selected an invalid payment product quantity above the limit.', + meta: logMeta, + }) + + return err( + new InvalidPaymentProductsError( + `Selected an invalid quantity above the limit. Please refresh and try again.`, + ), + ) + } + } + } + + return ok(true) +} diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts index 084c0353f8..7bcc1a5a5d 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.controller.ts @@ -630,6 +630,7 @@ export const handleStorageSubmission = [ EncryptSubmissionMiddleware.createFormsgAndRetrieveForm, EncryptSubmissionMiddleware.scanAndRetrieveAttachments, EncryptSubmissionMiddleware.validateStorageSubmission, + EncryptSubmissionMiddleware.validatePaymentSubmission, EncryptSubmissionMiddleware.encryptSubmission, submitEncryptModeForm, ] as ControllerHandler[] diff --git a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts index d9b9bacb93..a5a3550572 100644 --- a/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts +++ b/src/app/modules/submission/encrypt-submission/encrypt-submission.middleware.ts @@ -7,7 +7,9 @@ import { BasicField, FormAuthType, FormResponseMode, + isPaymentsProducts, } from '../../../../../shared/types' +import { IPopulatedForm } from '../../../../types' import { EncryptAttachmentResponse, EncryptFormFieldResponse, @@ -24,6 +26,7 @@ import { JoiPaymentProduct } from '../../form/admin-form/admin-form.payments.con import * as FormService from '../../form/form.service' import { MyInfoService } from '../../myinfo/myinfo.service' import { extractMyInfoLoginJwt } from '../../myinfo/myinfo.util' +import * as PaymentsService from '../../payments/payments.service' import { IPopulatedStorageFormWithResponsesAndHash } from '../email-submission/email-submission.types' import ParsedResponsesObject from '../ParsedResponsesObject.class' import { sharedSubmissionParams } from '../submission.constants' @@ -274,6 +277,60 @@ export const scanAndRetrieveAttachments = async ( return next() } +/** + * Middleware to validate payment content + */ +export const validatePaymentSubmission = async ( + req: ValidateSubmissionMiddlewareHandlerRequest, + res: Parameters[1], + next: NextFunction, +) => { + const formDefDoc = req.formsg.formDef as IPopulatedForm + + const formDef = formDefDoc.toObject() // Convert to POJO + + const logMeta = { + action: 'validatePaymentSubmission', + formId: String(formDef._id), + ...createReqMeta(req), + } + + const formDefProducts = formDef?.payments_field?.products + const submittedPaymentProducts = req.body.paymentProducts + if (submittedPaymentProducts) { + if (!isPaymentsProducts(formDefProducts)) { + // Payment definition does not allow for payment by product + + logger.error({ + message: 'Invalid form definition for payment by product', + meta: logMeta, + }) + + return res.status(StatusCodes.BAD_REQUEST).json({ + message: + 'The payment settings in this form have been updated. Please refresh and try again.', + }) + } + return PaymentsService.validatePaymentProducts( + formDefProducts, + submittedPaymentProducts, + ) + .map(() => next()) + .mapErr((error) => { + logger.error({ + message: 'Error validating payment submission', + meta: logMeta, + error, + }) + const { statusCode, errorMessage } = mapRouteError(error) + return res.status(statusCode).json({ + message: errorMessage, + }) + }) + } + return next() +} + /** * Validates storage submissions to the new endpoint (/api/v3/forms/:formId/submissions/storage). * This uses the same validators as email mode submissions. diff --git a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts index bdcb9a9f48..9ce56102c8 100644 --- a/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -5,7 +5,7 @@ import { errAsync } from 'neverthrow' import { ErrorDto, FormAuthType, - FormWorkflowSettings, + FormWorkflowDto, MultirespondentSubmissionDto, SubmissionType, } from '../../../../../shared/types' @@ -272,7 +272,7 @@ const runMultirespondentWorkflow = async ({ submissionId, }: { nextWorkflowStep: number - formWorkflow: FormWorkflowSettings + formWorkflow: FormWorkflowDto formTitle: string responseUrl: string formId: string diff --git a/src/app/modules/submission/submission.utils.ts b/src/app/modules/submission/submission.utils.ts index 871aa4b3d1..dcf7f104f8 100644 --- a/src/app/modules/submission/submission.utils.ts +++ b/src/app/modules/submission/submission.utils.ts @@ -90,7 +90,10 @@ import { } from '../myinfo/myinfo.errors' import { MyInfoKey } from '../myinfo/myinfo.types' import { getMyInfoChildHashKey } from '../myinfo/myinfo.util' -import { PaymentNotFoundError } from '../payments/payments.errors' +import { + InvalidPaymentProductsError, + PaymentNotFoundError, +} from '../payments/payments.errors' import { SgidInvalidJwtError, SgidMissingJwtError, @@ -207,6 +210,7 @@ const errorMapper: MapRouteError = ( errorMessage: error.message, } case ResponseModeError: + case InvalidPaymentProductsError: return { statusCode: StatusCodes.BAD_REQUEST, errorMessage: error.message, diff --git a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts index 44f998e846..9b28fa9508 100644 --- a/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts +++ b/src/app/routes/api/v3/admin/forms/__tests__/admin-forms.preview.routes.spec.ts @@ -4,7 +4,6 @@ import { logoutSession, } from '__tests__/integration/helpers/express-auth' import { setupApp } from '__tests__/integration/helpers/express-setup' -import { buildCelebrateError } from '__tests__/unit/backend/helpers/celebrate' import { generateDefaultField, generateUnprocessedSingleAnswerResponse, @@ -642,7 +641,7 @@ describe('admin-form.preview.routes', () => { }) }) - describe('POST /admin/forms/:formId/preview/submissions/encrypt', () => { + describe('POST /admin/forms/:formId/preview/submissions/storage', () => { const MOCK_FIELD_ID = new ObjectId().toHexString() const MOCK_ATTACHMENT_FIELD_ID = new ObjectId().toHexString() const MOCK_RESPONSE = omit( @@ -685,7 +684,7 @@ describe('admin-form.preview.routes', () => { it('should return 200 with submission ID when request is valid', async () => { const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .post(`/admin/forms/${mockForm._id}/preview/submissions/storage`) .send(MOCK_SUBMISSION_BODY) expect(response.body.message).toBe('Form submission successful.') @@ -697,276 +696,11 @@ describe('admin-form.preview.routes', () => { await logoutSession(request) const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) + .post(`/admin/forms/${mockForm._id}/preview/submissions/storage`) .send(MOCK_SUBMISSION_BODY) expect(response.status).toBe(401) expect(response.body).toEqual({ message: 'User is unauthorized.' }) }) - - it('should return 400 when responses are not provided in body', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send(omit(MOCK_SUBMISSION_BODY, 'responses')) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ body: { key: 'responses' } }), - ) - }) - - it('should return 400 when responses are missing _id field', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - responses: [omit(MOCK_RESPONSE, '_id')], - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'responses.0._id', - message: '"responses[0]._id" is required', - }, - }), - ) - }) - - it('should return 400 when responses are missing answer field', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - responses: [omit(MOCK_RESPONSE, 'answer')], - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'responses.0.answer', - message: '"responses[0].answer" is required', - }, - }), - ) - }) - - it('should return 400 when responses are missing fieldType', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - responses: [omit(MOCK_RESPONSE, 'fieldType')], - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'responses.0.fieldType', - message: '"responses[0].fieldType" is required', - }, - }), - ) - }) - - it('should return 400 when a fieldType is malformed', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - responses: [{ ...MOCK_RESPONSE, fieldType: 'malformed' }], - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'responses.0.fieldType', - message: expect.stringContaining( - '"responses[0].fieldType" must be one of ', - ), - }, - }), - ) - }) - - it('should return 400 when encryptedContent is not provided in body', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send(omit(MOCK_SUBMISSION_BODY, 'encryptedContent')) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'encryptedContent', - }, - }), - ) - }) - - it('should return 400 when version is not provided in body', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send(omit(MOCK_SUBMISSION_BODY, 'version')) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'version', - }, - }), - ) - }) - - it('should return 400 when encryptedContent is malformed', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ ...MOCK_SUBMISSION_BODY, encryptedContent: 'abc' }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: 'encryptedContent', - message: 'Invalid encryptedContent.', - }, - }), - ) - }) - - it('should return 400 when attachment field ID is malformed', async () => { - const invalidKey = 'invalidFieldId' - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - attachments: { - [invalidKey]: { - encryptedFile: { - binary: '10101', - nonce: 'mockNonce', - submissionPublicKey: 'mockPublicKey', - }, - }, - }, - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: `attachments.${invalidKey}`, - message: `"attachments.${invalidKey}" is not allowed`, - }, - }), - ) - }) - - it('should return 400 when attachment is missing encryptedFile key', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - attachments: { - [MOCK_ATTACHMENT_FIELD_ID]: {}, - }, - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile`, - message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile" is required`, - }, - }), - ) - }) - - it('should return 400 when attachment is missing binary', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - attachments: { - [MOCK_ATTACHMENT_FIELD_ID]: { - encryptedFile: { - // binary is missing - nonce: 'mockNonce', - submissionPublicKey: 'mockPublicKey', - }, - }, - }, - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary`, - message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.binary" is required`, - }, - }), - ) - }) - - it('should return 400 when attachment is missing nonce', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - attachments: { - [MOCK_ATTACHMENT_FIELD_ID]: { - encryptedFile: { - binary: '10101', - // nonce is missing - submissionPublicKey: 'mockPublicKey', - }, - }, - }, - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce`, - message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.nonce" is required`, - }, - }), - ) - }) - - it('should return 400 when attachment is missing public key', async () => { - const response = await request - .post(`/admin/forms/${mockForm._id}/preview/submissions/encrypt`) - .send({ - ...MOCK_SUBMISSION_BODY, - attachments: { - [MOCK_ATTACHMENT_FIELD_ID]: { - encryptedFile: { - binary: '10101', - nonce: 'mockNonce', - // missing public key - }, - }, - }, - }) - - expect(response.status).toBe(400) - expect(response.body).toEqual( - buildCelebrateError({ - body: { - key: `attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey`, - message: `"attachments.${MOCK_ATTACHMENT_FIELD_ID}.encryptedFile.submissionPublicKey" is required`, - }, - }), - ) - }) }) }) diff --git a/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts index c2e489f75b..8416575cc4 100644 --- a/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts +++ b/src/app/routes/api/v3/admin/forms/admin-forms.preview.routes.ts @@ -42,10 +42,9 @@ AdminFormsPreviewRouter.post( /** * Submit an encrypt mode form in preview mode - * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt + * @route POST api/v3/admin/forms/:formId([a-fA-F0-9]{24})/preview/submissions/storage * @security session - * - * @returns 200 if submission was valid + * @returns 400 when error occurs while processing submission or submission is invalid * @returns 403 when user does not have read permissions for form * @returns 404 when form cannot be found @@ -54,7 +53,7 @@ AdminFormsPreviewRouter.post( * @returns 500 when database error occurs */ AdminFormsPreviewRouter.post( - '/:formId([a-fA-F0-9]{24})/preview/submissions/encrypt', + '/:formId([a-fA-F0-9]{24})/preview/submissions/storage', AdminFormController.handleEncryptPreviewSubmission, ) diff --git a/src/types/form.ts b/src/types/form.ts index 43c393359c..32ee30b052 100644 --- a/src/types/form.ts +++ b/src/types/form.ts @@ -22,7 +22,7 @@ import { FormSettings, FormStartPage, FormWebhookResponseModeSettings, - FormWorkflowSettings, + FormWorkflowDto, LogicDto, MyInfoAttribute, PublicFormDto, @@ -312,7 +312,7 @@ export type IPopulatedEmailForm = IPopulatedForm & IEmailForm export interface IMultirespondentForm extends IForm { publicKey: string emails?: never - workflow?: FormWorkflowSettings + workflow: FormWorkflowDto } export type IMultirespondentFormSchema = IMultirespondentForm & IFormSchema