diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e49ec71..9264a6d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# [1.206.0](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.205.0...v1.206.0) (2024-11-07) + +### Features + +- add unsaved changes warning modal to forms ([ccc86e6](https://github.com/bcgov/CONN-CCBC-portal/commit/ccc86e664ef9fcc9acbd812e78b1dabbf49b5911)) + # [1.205.0](https://github.com/bcgov/CONN-CCBC-portal/compare/v1.204.0...v1.205.0) (2024-11-05) ### Features diff --git a/app/components/Admin/AddAnalyst.tsx b/app/components/Admin/AddAnalyst.tsx index c34365ae9..8bb264ce9 100644 --- a/app/components/Admin/AddAnalyst.tsx +++ b/app/components/Admin/AddAnalyst.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import styled from 'styled-components'; import Button from '@button-inc/bcgov-theme/Button'; -import Input from '@button-inc/bcgov-theme/Input'; import { useCreateAnalystMutation } from 'schema/mutations/analyst/createAnalyst'; -import { InputLabel } from '@mui/material'; +import { FormBase } from 'components/Form'; +import { IChangeEvent } from '@rjsf/core'; +import analyst from 'formSchema/admin/analyst'; +import analystUiSchema from 'formSchema/uiSchema/admin/analystUiSchema'; interface Props { relayConnectionId: string; @@ -25,19 +27,11 @@ const StyledButtons = styled.div` } `; -const StyledInputs = styled.div` - display: flex; - margin-bottom: 16px; - - & div:first-child, - & div:nth-child(2) { - margin-right: 16px; - } -`; - const StyledTransition = styled.div` overflow: ${(props) => (props.show ? 'visible' : 'hidden')}; - transition: max-height 0.5s, opacity 0.3s ease-in-out; + transition: + max-height 0.5s, + opacity 0.3s ease-in-out; transition-delay: 0.1s; opacity: ${(props) => (props.show ? 1 : 0)}; @@ -51,21 +45,24 @@ const StyledSection = styled.form` const AddAnalyst: React.FC = ({ relayConnectionId }) => { const [showInputs, setShowInputs] = useState(false); - const [familyName, setFamilyName] = useState(''); - const [givenName, setGivenName] = useState(''); - const [email, setEmail] = useState(''); + const [formData, setFormData] = useState({}); const [createAnalyst] = useCreateAnalystMutation(); + const resetForm = () => { + setFormData({}); + }; + const handleSubmit = (e) => { e.preventDefault(); if (showInputs) { createAnalyst({ variables: { connections: [relayConnectionId], - input: { analyst: { familyName, givenName, email } }, + input: { analyst: formData }, }, onCompleted: () => { setShowInputs(false); + resetForm(); }, }); } else { @@ -77,35 +74,16 @@ const AddAnalyst: React.FC = ({ relayConnectionId }) => {

New analyst

- -
- Given Name - setGivenName(e.target.value)} - /> -
-
- Family Name - setFamilyName(e.target.value)} - /> -
-
- Email - setEmail(e.target.value)} - /> -
-
+ setFormData({ ...e.formData })} + schema={analyst} + uiSchema={analystUiSchema as any} + onSubmit={handleSubmit} + // Pass children to hide submit button + // eslint-disable-next-line react/no-children-prop + children + />
{showInputs && ( - )} diff --git a/app/components/Analyst/Assessments/AssessmentsForm.tsx b/app/components/Analyst/Assessments/AssessmentsForm.tsx index b664305a1..953fbb422 100644 --- a/app/components/Analyst/Assessments/AssessmentsForm.tsx +++ b/app/components/Analyst/Assessments/AssessmentsForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import styled from 'styled-components'; import { FormBase } from 'components/Form'; import Button from '@button-inc/bcgov-theme/Button'; @@ -10,6 +10,7 @@ import assessmentsUiSchema from 'formSchema/uiSchema/analyst/assessmentsUiSchema import { RJSFSchema } from '@rjsf/utils'; import * as Sentry from '@sentry/nextjs'; import { useToast } from 'components/AppProvider'; +import { FormBaseRef } from 'components/Form/FormBase'; interface Props { addedContext?: any; @@ -67,6 +68,7 @@ const AssessmentsForm: React.FC = ({ const [emailStatus, setEmailStatus] = useState< 'idle' | 'inProgress' | 'sent' >('idle'); + const formRef = useRef(null); const handleSubmit = async (e: IChangeEvent) => { if (!isFormSaved) { @@ -81,6 +83,7 @@ const AssessmentsForm: React.FC = ({ }, onCompleted: () => { setIsFormSaved(true); + formRef.current?.resetFormState(e.formData); }, optimisticResponse: { jsonData: e.formData, @@ -135,6 +138,7 @@ const AssessmentsForm: React.FC = ({ return ( = ({ ...rest }) => { const stopPropagation = (e) => e.stopPropagation(); + const formRef = useRef(null); return ( = ({ id={`${title.toLowerCase().split(' ').join('-')}-save-button`} size="small" disabled={saveBtnDisabled} - onClick={onSubmit} + onClick={(e) => { + onSubmit(e); + formRef.current?.resetFormState(e.formData); + }} > {saveBtnText || 'Save'} @@ -111,8 +117,9 @@ const ProjectForm: React.FC = ({ variant="secondary" disabled={cancelBtnDisabled} onClick={() => { - setFormData(); + setFormData({}); setIsFormEditMode(false); + formRef.current?.resetFormState({}); }} > Cancel @@ -158,6 +165,7 @@ const ProjectForm: React.FC = ({ {formHeader} = ({ handleChange={(e) => { setHasFormSaved(false); if (!isChangeRequest && !e.formData.hasFundingAgreementBeenSigned) { - setFormData({ - hasFundingAgreementBeenSigned: false, - }); + setFormData({}); } else { setFormData({ ...e.formData }); } diff --git a/app/components/Analyst/RFI/RfiForm.tsx b/app/components/Analyst/RFI/RfiForm.tsx index 29a2e96d3..54c58bce6 100644 --- a/app/components/Analyst/RFI/RfiForm.tsx +++ b/app/components/Analyst/RFI/RfiForm.tsx @@ -11,6 +11,7 @@ import { graphql, useFragment } from 'react-relay'; import { RfiForm_RfiData$key } from '__generated__/RfiForm_RfiData.graphql'; import { useUpdateWithTrackingRfiMutation } from 'schema/mutations/application/updateWithTrackingRfiMutation'; import removeFalseyValuesFromObject from 'utils/removeFalseValuesFromObject'; +import { useState } from 'react'; import RfiTheme from './RfiTheme'; const StyledCancel = styled(Button)` @@ -44,9 +45,10 @@ const RfiForm = ({ rfiDataKey }: RfiFormProps) => { const rfiUrl = `/analyst/application/${applicationId}/rfi`; const [createRfi] = useCreateRfiMutation(); const [updateRfi] = useUpdateWithTrackingRfiMutation(); + const [formData, setFormData] = useState(rfiFormData?.jsonData ?? {}); const handleSubmit = (e: IChangeEvent) => { - const formData = { + const newFormData = { ...e.formData, rfiAdditionalFiles: { // Remove fields with false values from object to prevent unintended bugs when @@ -60,7 +62,7 @@ const RfiForm = ({ rfiDataKey }: RfiFormProps) => { variables: { input: { applicationRowId: parseInt(applicationId as string, 10), - jsonData: formData, + jsonData: newFormData, }, }, onCompleted: () => { @@ -75,7 +77,7 @@ const RfiForm = ({ rfiDataKey }: RfiFormProps) => { updateRfi({ variables: { input: { - jsonData: formData, + jsonData: newFormData, rfiRowId: parseInt(rfiId as string, 10), }, }, @@ -98,12 +100,13 @@ const RfiForm = ({ rfiDataKey }: RfiFormProps) => { schema={rfiSchema} uiSchema={rfiUiSchema} omitExtraData={false} - formData={rfiFormData?.jsonData ?? {}} + formData={formData} + onChange={(e) => setFormData(e.formData)} onSubmit={handleSubmit} noValidate > - + Cancel diff --git a/app/components/AppProvider.tsx b/app/components/AppProvider.tsx index 91ccd6d17..673134571 100644 --- a/app/components/AppProvider.tsx +++ b/app/components/AppProvider.tsx @@ -7,6 +7,7 @@ import React, { ReactNode, } from 'react'; import Toast, { ToastType } from 'components/Toast'; +import UnsavedChangesProvider from 'components/UnsavedChangesProvider'; type AppContextType = { showToast?: (message: ReactNode, type?: ToastType, timeout?: number) => void; @@ -52,12 +53,14 @@ export const AppProvider = ({ children }) => { return ( - {children} - {toast?.visible && ( - - {toast.message} - - )} + + {children} + {toast?.visible && ( + + {toast.message} + + )} + ); }; diff --git a/app/components/ButtonLink.tsx b/app/components/ButtonLink.tsx index 4200052cb..755112bc8 100644 --- a/app/components/ButtonLink.tsx +++ b/app/components/ButtonLink.tsx @@ -8,8 +8,14 @@ type Props = { disabled?: boolean; }; -const ButtonLink = ({ children, href, onClick, disabled = false }: Props) => ( - +const ButtonLink = ({ + children, + href, + onClick, + disabled = false, + ...rest +}: Props) => ( + diff --git a/app/components/Form/ApplicationForm.tsx b/app/components/Form/ApplicationForm.tsx index 60151fa47..9b4b687a1 100644 --- a/app/components/Form/ApplicationForm.tsx +++ b/app/components/Form/ApplicationForm.tsx @@ -328,6 +328,7 @@ const ApplicationForm: React.FC = ({ isProjectAreaSelected, intakeNumber, isRollingIntake, + skipUnsavedWarning: true, }; }, [ openIntake, diff --git a/app/components/Form/FormBase.tsx b/app/components/Form/FormBase.tsx index 91404742c..c16628459 100644 --- a/app/components/Form/FormBase.tsx +++ b/app/components/Form/FormBase.tsx @@ -1,13 +1,22 @@ import defaultTheme from 'lib/theme/DefaultTheme'; -import { useMemo } from 'react'; -import { FormProps, ThemeProps, withTheme } from '@rjsf/core'; -import { customizeValidator } from '@rjsf/validator-ajv8'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { FormProps, IChangeEvent, ThemeProps, withTheme } from '@rjsf/core'; +import Ajv8Validator, { customizeValidator } from '@rjsf/validator-ajv8'; import customTransformErrors from 'lib/theme/customTransformErrors'; import { customFormats, customFormatsErrorMessages, } from 'data/jsonSchemaForm/customFormats'; import { RJSFValidationError, ValidatorType } from '@rjsf/utils'; +import { useUnsavedChanges } from 'components/UnsavedChangesProvider'; +import getFormState from 'utils/getFormState'; interface FormPropsWithTheme extends Omit, 'validator'> { theme?: ThemeProps; @@ -15,30 +24,76 @@ interface FormPropsWithTheme extends Omit, 'validator'> { validator?: ValidatorType; } -const FormBase: React.FC> = (props) => { - const { theme, formData, omitExtraData, transformErrors, validator } = props; - const ThemedForm = useMemo(() => withTheme(theme ?? defaultTheme), [theme]); - - const customTransform = (errors: RJSFValidationError[]) => { - return customTransformErrors(errors, customFormatsErrorMessages); - }; - - const customValidator = customizeValidator({ customFormats }); - - return ( - - ); -}; +export interface FormBaseRef { + resetFormState: (formData: any) => void; +} + +const FormBase = forwardRef>( + (props, ref) => { + const { + theme, + formData, + omitExtraData, + transformErrors, + validator, + formContext, + schema, + onChange = () => {}, + } = props; + + const ThemedForm = useMemo(() => withTheme(theme ?? defaultTheme), [theme]); + const [isFormTouched, setIsFormTouched] = useState(false); + const initialFormData = useRef( + getFormState(schema, formData, Ajv8Validator) + ); + + const { updateDirtyState } = useUnsavedChanges(); + const customValidator = customizeValidator({ customFormats }); + const skipUnsavedWarning = formContext?.skipUnsavedWarning ?? false; + + useEffect(() => { + updateDirtyState( + JSON.stringify(formData) !== JSON.stringify(initialFormData.current) && + !skipUnsavedWarning && + isFormTouched + ); + }, [formData, isFormTouched, skipUnsavedWarning, updateDirtyState]); + + const handleOnChange = (e: IChangeEvent) => { + onChange(e); + setIsFormTouched(true); + }; + + const resetFormState = (data = {}) => { + updateDirtyState(false); + setIsFormTouched(false); + initialFormData.current = getFormState(schema, data, Ajv8Validator); + }; + + useImperativeHandle(ref, () => ({ resetFormState })); + + const customTransform = (errors: RJSFValidationError[]) => { + return customTransformErrors(errors, customFormatsErrorMessages); + }; + + return ( + + ); + } +); + +FormBase.displayName = 'FormBase'; export default FormBase; diff --git a/app/components/Modal/UnsavedChangesWarningModal.tsx b/app/components/Modal/UnsavedChangesWarningModal.tsx new file mode 100644 index 000000000..badfca71d --- /dev/null +++ b/app/components/Modal/UnsavedChangesWarningModal.tsx @@ -0,0 +1,40 @@ +import Modal from 'components/Modal'; + +type UnsavedChangesWarningModalProps = { + isOpen: boolean; + onDiscard: () => void; + onDismiss: () => void; +}; + +const UnsavedChangesWarningModal = ({ + isOpen, + onDiscard, + onDismiss, +}: UnsavedChangesWarningModalProps) => { + return ( + +

+ Changes not saved. Please confirm if you want to exit without saving. +

+
+ ); +}; + +export default UnsavedChangesWarningModal; diff --git a/app/components/NavLoginForm.tsx b/app/components/NavLoginForm.tsx index 9dc4af370..1554a7b72 100644 --- a/app/components/NavLoginForm.tsx +++ b/app/components/NavLoginForm.tsx @@ -27,6 +27,7 @@ const LoginForm: React.FC = ({ linkText, action }) => ( sessionStorage.removeItem('dashboard_scroll_position')} > {linkText} diff --git a/app/components/UnsavedChangesProvider.tsx b/app/components/UnsavedChangesProvider.tsx new file mode 100644 index 000000000..0c7cadb2e --- /dev/null +++ b/app/components/UnsavedChangesProvider.tsx @@ -0,0 +1,147 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type PropsWithChildren, +} from 'react'; +import { useRouter } from 'next/router'; +import { useFeature } from '@growthbook/growthbook-react'; +import UnsavedChangesWarningModal from './Modal/UnsavedChangesWarningModal'; + +type IUnsavedChangesContext = { + updateDirtyState: (dirty: boolean) => void; +}; + +const UnsavedChangesContext = createContext( + undefined +); + +const UnsavedChangesProvider: React.FC = ({ children }) => { + const router = useRouter(); + const [leavingPage, setLeavingPage] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const confirmLeavePage = useRef<() => void>(() => {}); + const enableUnsavedChangesWarning = useFeature( + 'enable_unsaved_changes_warning' + ).value; + + const updateDirtyState = useCallback((dirty: boolean) => { + setIsDirty(dirty); + }, []); + + const handleLogoutAction = useCallback( + (event: SubmitEvent | MouseEvent, target: HTMLElement) => { + event.preventDefault(); + confirmLeavePage.current = () => { + sessionStorage.removeItem('dashboard_scroll_position'); + (target as HTMLButtonElement).form?.submit(); + }; + setLeavingPage(true); + }, + [] + ); + + const handleLinkNavigation = useCallback( + (event: MouseEvent | SubmitEvent, target: HTMLElement) => { + event.preventDefault(); + confirmLeavePage.current = () => { + router.push((target as HTMLAnchorElement).href); + }; + setLeavingPage(true); + }, + [router] + ); + + useEffect(() => { + const handleClick = (event: MouseEvent | SubmitEvent) => { + if (!isDirty || !enableUnsavedChangesWarning) return; + + let target: HTMLElement | null = null; + if (event instanceof MouseEvent) { + target = event.target as HTMLElement; + while ( + target && + target.tagName !== 'A' && + target.tagName !== 'BUTTON' + ) { + target = target.parentElement as HTMLElement; + } + } else if (event instanceof SubmitEvent) { + target = (event.target as HTMLFormElement).querySelector( + '[data-button-id="Logout-button"]' + ); + } + + if (target?.getAttribute('data-button-id') === 'Logout-button') + handleLogoutAction(event, target); + else if ( + target?.tagName === 'A' && + !target.hasAttribute('data-skip-unsaved-warning') && + (target as HTMLAnchorElement).href + ) + handleLinkNavigation(event, target); + }; + + // Set listeners for clicks and logout form submission + document.querySelectorAll('a, form').forEach((element) => { + element.addEventListener('click', handleClick); + element.addEventListener('submit', handleClick); + }); + + // Cleanup listeners + return () => { + document.querySelectorAll('a, form').forEach((element) => { + element.removeEventListener('click', handleClick); + element.removeEventListener('submit', handleClick); + }); + }; + }, [ + enableUnsavedChangesWarning, + handleLinkNavigation, + handleLogoutAction, + isDirty, + router, + ]); + + const handleModalCallback = () => { + setLeavingPage(false); + confirmLeavePage.current = () => {}; + }; + + const context = useMemo( + () => ({ updateDirtyState }), + [updateDirtyState] + ); + + return ( + + {children} + { + confirmLeavePage.current(); + setIsDirty(false); + handleModalCallback(); + }} + /> + + ); +}; + +export function useUnsavedChanges() { + const context = useContext(UnsavedChangesContext); + + if (context === undefined) { + throw new Error( + 'useUnsavedChanges must be called within ' + ); + } + return context; +} + +export default UnsavedChangesProvider; diff --git a/app/formSchema/admin/analyst.ts b/app/formSchema/admin/analyst.ts new file mode 100644 index 000000000..e15bc11b6 --- /dev/null +++ b/app/formSchema/admin/analyst.ts @@ -0,0 +1,23 @@ +import { RJSFSchema } from '@rjsf/utils'; + +const analyst: RJSFSchema = { + description: '', + type: 'object', + required: ['givenName', 'familyName', 'email'], + properties: { + givenName: { + title: 'Given Name', + type: 'string', + }, + familyName: { + title: 'Family Name', + type: 'string', + }, + email: { + title: 'Email', + type: 'string', + }, + }, +}; + +export default analyst; diff --git a/app/formSchema/uiSchema/admin/analystUiSchema.ts b/app/formSchema/uiSchema/admin/analystUiSchema.ts new file mode 100644 index 000000000..35b613db7 --- /dev/null +++ b/app/formSchema/uiSchema/admin/analystUiSchema.ts @@ -0,0 +1,21 @@ +const analystUiSchema = { + givenName: { + 'ui:widget': 'TextWidget', + }, + familyName: { + 'ui:widget': 'TextWidget', + }, + email: { + 'ui:widget': 'TextWidget', + }, + 'ui:inline': [ + { + columns: 3, + givenName: 1, + familyName: 2, + email: 3, + }, + ], +}; + +export default analystUiSchema; diff --git a/app/pages/analyst/cbc/[cbcId].tsx b/app/pages/analyst/cbc/[cbcId].tsx index 67456ac54..e2f2a480e 100644 --- a/app/pages/analyst/cbc/[cbcId].tsx +++ b/app/pages/analyst/cbc/[cbcId].tsx @@ -110,7 +110,11 @@ const Cbc = ({ const hiddenSubmitRef = useRef(null); const { rowId } = query.cbcByRowId; - const [formData, setFormData] = useState({} as any); + const [formData, setFormData] = useState({ + locations: { + communitySourceData: [], + }, + } as any); const [baseFormData, setBaseFormData] = useState({} as any); const [addedCommunities, setAddedCommunities] = useState([]); const [removedCommunities, setRemovedCommunities] = useState([]); diff --git a/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx b/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx index 311fafcda..0ebcd7296 100644 --- a/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx +++ b/app/pages/analyst/cbc/[cbcId]/edit/[section].tsx @@ -82,7 +82,9 @@ const EditCbcSection = ({ const section = router.query.section as string; const [updateFormData] = useUpdateCbcDataAndInsertChangeRequest(); const [changeReason, setChangeReason] = useState(null); - const [formData, setFormData] = useState(null); + const [formData, setFormData] = useState( + section === 'locations' ? { locations: { communitySourceData: [] } } : null + ); const [addedCommunities, setAddedCommunities] = useState([]); const [removedCommunities, setRemovedCommunities] = useState([]); diff --git a/app/pages/analyst/gis/coverages.tsx b/app/pages/analyst/gis/coverages.tsx index cd0ddb73a..44a0cdd34 100644 --- a/app/pages/analyst/gis/coverages.tsx +++ b/app/pages/analyst/gis/coverages.tsx @@ -13,6 +13,7 @@ import { faCircleXmark } from '@fortawesome/free-solid-svg-icons'; import * as Sentry from '@sentry/nextjs'; import Tabs from 'components/Analyst/GIS/Tabs'; import checkFileType from 'utils/checkFileType'; +import { useUnsavedChanges } from 'components/UnsavedChangesProvider'; import config from '../../../config'; const getCoveragesQuery = graphql` @@ -95,6 +96,7 @@ const CoveragesTab = () => { const [selectedFile, setSelectedFile] = useState(); const [isUploading, setIsUploading] = useState(false); const [uploadSuccess, setUploadSuccess] = useState(false); + const { updateDirtyState } = useUnsavedChanges(); const fileComponentValue = [ { id: '', @@ -118,6 +120,7 @@ const CoveragesTab = () => { } setError(''); setSelectedFile(file); + updateDirtyState(true); }; const handleUpload = async () => { @@ -134,6 +137,7 @@ const CoveragesTab = () => { setSelectedFile(null); setIsUploading(false); setUploadSuccess(true); + updateDirtyState(false); } } catch (e) { setIsUploading(false); @@ -161,7 +165,10 @@ const CoveragesTab = () => { label="ZIP of CCBC Application Coverages" id="coverages-upload" onChange={changeHandler} - handleDelete={() => setSelectedFile(null)} + handleDelete={() => { + setSelectedFile(null); + updateDirtyState(false); + }} hideFailedUpload={false} value={selectedFile ? fileComponentValue : []} allowDragAndDrop @@ -183,6 +190,7 @@ const CoveragesTab = () => { onClick={handleUpload} href="#" disabled={isUploading || !selectedFile} + data-skip-unsaved-warning > {isUploading ? 'Uploading' : 'Upload'} diff --git a/app/pages/analyst/gis/index.tsx b/app/pages/analyst/gis/index.tsx index 1211c2ce8..6baaf7b17 100644 --- a/app/pages/analyst/gis/index.tsx +++ b/app/pages/analyst/gis/index.tsx @@ -14,6 +14,7 @@ import { faCircleXmark } from '@fortawesome/free-solid-svg-icons'; import * as Sentry from '@sentry/nextjs'; import Tabs from 'components/Analyst/GIS/Tabs'; import checkFileType from 'utils/checkFileType'; +import { useUnsavedChanges } from 'components/UnsavedChangesProvider'; const getUploadedJsonQuery = graphql` query gisUploadedJsonQuery { @@ -79,6 +80,7 @@ const validateFile = (file: globalThis.File) => { const GisTab = () => { const router = useRouter(); const [selectedFile, setSelectedFile] = useState(); + const { updateDirtyState } = useUnsavedChanges(); const fileComponentValue = [ { @@ -94,7 +96,7 @@ const GisTab = () => { const changeHandler = (event) => { const file: File = event.target.files?.[0]; - + updateDirtyState(true); const { isValid, error: newError } = validateFile(file); if (!isValid) { setError(newError); @@ -143,7 +145,10 @@ const GisTab = () => { label="JSON of GIS analysis" id="json-upload" onChange={changeHandler} - handleDelete={() => setSelectedFile(null)} + handleDelete={() => { + setSelectedFile(null); + updateDirtyState(false); + }} hideFailedUpload={false} value={selectedFile ? fileComponentValue : []} allowDragAndDrop @@ -181,7 +186,7 @@ const GisTab = () => { )} - + Continue diff --git a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx index d6aaf9317..a9ab1116f 100644 --- a/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx +++ b/app/pages/applicantportal/form/[id]/rfi/[applicantRfiId].tsx @@ -236,7 +236,7 @@ const ApplicantRfiPage = ({ onChange={handleChange} onSubmit={handleSubmit} noValidate - formContext={{ setTemplateData }} + formContext={{ setTemplateData, skipUnsavedWarning: true }} > diff --git a/app/tests/components/Form/UnsavedChangesWarning.test.tsx b/app/tests/components/Form/UnsavedChangesWarning.test.tsx new file mode 100644 index 000000000..5151e8f44 --- /dev/null +++ b/app/tests/components/Form/UnsavedChangesWarning.test.tsx @@ -0,0 +1,176 @@ +import { RJSFSchema } from '@rjsf/utils'; +import { + render, + screen, + fireEvent, + act, + waitFor, +} from '@testing-library/react'; +import * as moduleApi from '@growthbook/growthbook-react'; +import FormTestRenderer from '../../utils/formTestRenderer'; + +const schema = { + title: 'Text widget test', + type: 'object', + properties: { + stringTestField: { type: 'string', title: 'String test field' }, + emailTestField: { type: 'string', title: 'String test field' }, + }, +}; + +const uiSchema = { + stringTestField: { + 'ui:help': 'maximum 200 characters', + 'ui:options': { + maxLength: 9, + }, + }, + emailTestField: { + 'ui:options': { + inputType: 'email', + }, + }, +}; + +const mockEnableUnsavedChangesWarning = ( + value: boolean +): moduleApi.FeatureResult => ({ + value, + source: 'defaultValue', + on: null, + off: null, + ruleId: 'enable_unsaved_changes_warning', +}); + +const renderStaticLayout = ( + rjsfSchema: RJSFSchema, + rjsfUiSchema: RJSFSchema +) => { + return render( + <> + + Navigate + + ); +}; + +describe('Unsaved Changes Handling', () => { + beforeEach(() => { + jest + .spyOn(moduleApi, 'useFeature') + .mockReturnValue(mockEnableUnsavedChangesWarning(true)); + renderStaticLayout( + { + ...schema, + properties: { stringTestField: schema.properties.stringTestField }, + } as RJSFSchema, + { stringTestField: uiSchema.stringTestField } as RJSFSchema + ); + }); + + it('should allow leaving page with no changes', async () => { + const navigateLink = screen.getByText('Navigate'); + expect(navigateLink).toBeInTheDocument(); + act(() => { + fireEvent.click(navigateLink); + }); + + await waitFor(() => { + expect(screen.queryByText(/Changes not saved/)).not.toBeInTheDocument(); + }); + }); + + it('should show confirmation modal when attempting to leave with unsaved changes', async () => { + const input = screen.getByTestId('root_stringTestField'); + fireEvent.change(input, { target: { value: 'test string' } }); + expect(screen.getByDisplayValue('test string')).toBeInTheDocument(); + + const navigateLink = screen.getByText('Navigate'); + expect(navigateLink).toBeInTheDocument(); + act(() => { + fireEvent.click(navigateLink); + }); + + const confirmationModal = await screen.findByText(/Changes not saved/); + expect(confirmationModal).toBeInTheDocument(); + }); + + it('should allow leaving the page after confirming unsaved changes', async () => { + const input = screen.getByTestId('root_stringTestField'); + fireEvent.change(input, { target: { value: 'test string' } }); + expect(screen.getByDisplayValue('test string')).toBeInTheDocument(); + + // Attempt to logout + const navigateLink = screen.getByText('Navigate'); + expect(navigateLink).toBeInTheDocument(); + act(() => { + fireEvent.click(navigateLink); + }); + + // Expect the confirmation modal to appear + const confirmationModal = await screen.findByText(/Changes not saved/); + expect(confirmationModal).toBeInTheDocument(); + + // Confirm to proceed + const yesButton = screen.getByText('Yes, Discard Changes'); + act(() => { + fireEvent.click(yesButton); + }); + + // Verify that the form no longer tracks unsaved changes + await waitFor(() => { + expect(screen.queryByText(/Changes not saved/)).not.toBeInTheDocument(); + }); + }); + + it('should allow leaving the page without confirmation modal after saving changes', async () => { + const input = screen.getByTestId('root_stringTestField'); + fireEvent.change(input, { target: { value: 'test string' } }); + expect(screen.getByDisplayValue('test string')).toBeInTheDocument(); + + // Simulate saving changes + const saveButton = screen.getByRole('button', { name: /Submit/i }); + act(() => { + fireEvent.click(saveButton); + }); + + // Attempt to navigate away + const navigateLink = screen.getByText('Navigate'); + act(() => { + fireEvent.click(navigateLink); + }); + + // Ensure the confirmation modal does not appear after saving + await waitFor(() => { + expect(screen.queryByText(/Changes not saved/)).not.toBeInTheDocument(); + }); + }); + + it('should allow leaving the page without confirmation modal after cancelling changes', async () => { + const input = screen.getByTestId('root_stringTestField'); + fireEvent.change(input, { target: { value: 'test string' } }); + expect(screen.getByDisplayValue('test string')).toBeInTheDocument(); + + // Simulate saving changes + const saveButton = screen.getByRole('button', { name: /Cancel/i }); + act(() => { + fireEvent.click(saveButton); + }); + + // Attempt to navigate away + const navigateLink = screen.getByText('Navigate'); + act(() => { + fireEvent.click(navigateLink); + }); + + // Ensure the confirmation modal does not appear after saving + await waitFor(() => { + expect(screen.queryByText(/Changes not saved/)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/tests/jest-setup.ts b/app/tests/jest-setup.ts index 730acee1a..a0f9f9b48 100644 --- a/app/tests/jest-setup.ts +++ b/app/tests/jest-setup.ts @@ -10,5 +10,16 @@ if (global.self) { }); } +if (typeof SubmitEvent === 'undefined') { + global.SubmitEvent = class SubmitEvent extends Event { + submitter: HTMLElement | null; + + constructor() { + super('submit'); + this.submitter = null; + } + }; +} + Settings.defaultLocale = 'en-CA'; Settings.defaultZone = 'America/Vancouver'; diff --git a/app/tests/pages/analyst/admin/list-of-analyst.test.tsx b/app/tests/pages/analyst/admin/list-of-analyst.test.tsx index b31efc516..52677f5ff 100644 --- a/app/tests/pages/analyst/admin/list-of-analyst.test.tsx +++ b/app/tests/pages/analyst/admin/list-of-analyst.test.tsx @@ -94,9 +94,9 @@ describe('The Download attachments admin page', () => { fireEvent.click(addAnalyst); }); - const givenName = screen.getByRole('textbox', { name: 'givenName' }); - const familyName = screen.getByRole('textbox', { name: 'familyName' }); - const email = screen.getByRole('textbox', { name: 'email' }); + const givenName = screen.getByTestId('root_givenName'); + const familyName = screen.getByTestId('root_familyName'); + const email = screen.getByTestId('root_email'); expect(givenName).toBeInTheDocument(); expect(familyName).toBeInTheDocument(); diff --git a/app/tests/utils/componentTestingHelper.tsx b/app/tests/utils/componentTestingHelper.tsx index df545a65a..0512617e4 100644 --- a/app/tests/utils/componentTestingHelper.tsx +++ b/app/tests/utils/componentTestingHelper.tsx @@ -11,6 +11,7 @@ import GlobalTheme from 'styles/GlobalTheme'; import GlobalStyle from 'styles/GobalStyles'; import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; import { AppProvider } from 'components/AppProvider'; +import UnsavedChangesProvider from 'components/UnsavedChangesProvider'; import TestingHelper from './TestingHelper'; interface ComponentTestingHelperOptions { @@ -87,10 +88,12 @@ class ComponentTestingHelper< - + + + diff --git a/app/tests/utils/formTestRenderer.tsx b/app/tests/utils/formTestRenderer.tsx index fb03ba481..efef490ee 100644 --- a/app/tests/utils/formTestRenderer.tsx +++ b/app/tests/utils/formTestRenderer.tsx @@ -5,6 +5,12 @@ import { getClientEnvironment } from 'lib/relay/client'; import defaultTheme from 'lib/theme/DefaultTheme'; import GlobalTheme from 'styles/GlobalTheme'; import { RJSFSchema } from '@rjsf/utils'; +import UnsavedChangesProvider from 'components/UnsavedChangesProvider'; +import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; +import { useRef, useState } from 'react'; +import { IChangeEvent } from '@rjsf/core'; +import { FormBaseRef } from 'components/Form/FormBase'; +import createMockRouter from './mockNextRouter'; type Props = { formData: any; @@ -28,18 +34,36 @@ const FormTestRenderer: React.FC = ({ }) => { const relayProps = getRelayProps({}, initialPreloadedQuery); const env = relayProps.preloadedQuery?.environment ?? clientEnv!; + const [formState, setFormState] = useState(formData); + const formRef = useRef(null); + + const handleSubmit = () => { + onSubmit(); + formRef.current?.resetFormState({}); + }; return ( - + + + setFormState(e.formData)} + > + + + + + ); diff --git a/app/tests/utils/pageTestingHelper.tsx b/app/tests/utils/pageTestingHelper.tsx index 0fc9d94a6..b8d892b90 100644 --- a/app/tests/utils/pageTestingHelper.tsx +++ b/app/tests/utils/pageTestingHelper.tsx @@ -11,6 +11,7 @@ import { MockResolvers } from 'relay-test-utils/lib/RelayMockPayloadGenerator'; import GlobalTheme from 'styles/GlobalTheme'; import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; import { AppProvider } from 'components/AppProvider'; +import UnsavedChangesProvider from 'components/UnsavedChangesProvider'; import TestingHelper from './TestingHelper'; interface PageTestingHelperOptions { @@ -68,10 +69,12 @@ class PageTestingHelper extends TestingHelper { - + + + diff --git a/app/utils/getFormState.ts b/app/utils/getFormState.ts new file mode 100644 index 000000000..b2f0ec29f --- /dev/null +++ b/app/utils/getFormState.ts @@ -0,0 +1,10 @@ +import { getDefaultFormState } from '@rjsf/utils'; + +const getFormState = (schema, formData, validator) => { + return getDefaultFormState(validator, schema, formData, {}, false, { + emptyObjectFields: 'populateRequiredDefaults', + arrayMinItems: { populate: 'requiredOnly' }, + }); +}; + +export default getFormState; diff --git a/db/sqitch.plan b/db/sqitch.plan index 318354683..e5e0a0156 100644 --- a/db/sqitch.plan +++ b/db/sqitch.plan @@ -724,3 +724,4 @@ mutations/archive_application_sow [mutations/archive_application_sow@1.201.0] 20 grant_read_access_to_service_account 2024-10-04T18:45:20Z Anthony Bushara # Add read access to service account @1.204.0 2024-11-05T20:41:06Z CCBC Service Account # release v1.204.0 @1.205.0 2024-11-05T20:57:02Z CCBC Service Account # release v1.205.0 +@1.206.0 2024-11-07T16:34:36Z CCBC Service Account # release v1.206.0 diff --git a/package.json b/package.json index 178c24419..640a4ec8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CONN-CCBC-portal", - "version": "1.205.0", + "version": "1.206.0", "main": "index.js", "repository": "https://github.com/bcgov/CONN-CCBC-portal.git", "author": "Romer, Meherzad CITZ:EX ",