diff --git a/.github/workflows/pr-review-client-deploy.yml b/.github/workflows/pr-review-client-deploy.yml index e8b1c9b833..d7d89d4459 100644 --- a/.github/workflows/pr-review-client-deploy.yml +++ b/.github/workflows/pr-review-client-deploy.yml @@ -43,7 +43,7 @@ jobs: with: filters: | migrations: - - 'prisma/migrations/**' + - 'packages/database/prisma/migrations/**' build-and-push-container: needs: [run-check] diff --git a/.gitignore b/.gitignore index 0d21c07c0c..e60a46ca16 100644 --- a/.gitignore +++ b/.gitignore @@ -118,4 +118,5 @@ packages/database/src/generated tests/.auth/*.json eslint_report.html +/otel next-env.d.ts diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/PreviewFormWrapper.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/PreviewFormWrapper.tsx index 257902dc8d..7fba681819 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/PreviewFormWrapper.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/preview/PreviewFormWrapper.tsx @@ -27,7 +27,7 @@ export const PreviewFormWrapper = ({ setSent: React.Dispatch>; }) => { const { status } = useSession(); - const { saveSessionProgress, currentGroup } = useGCFormsContext(); + const { currentGroup } = useGCFormsContext(); const { translationLanguagePriority, getLocalizationAttribute } = useTemplateStore((s) => ({ translationLanguagePriority: s.translationLanguagePriority, @@ -43,7 +43,6 @@ export const PreviewFormWrapper = ({ return (
{ originalToast(toastContent(message, "default"), { containerId }); }, + // Dismiss toasts. If `containerId` is provided, dismiss only that container. + dismiss: (containerId?: string) => { + originalToast.dismiss(containerId); + }, }; diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/clientSide.tsx b/app/(gcforms)/[locale]/(form filler)/id/[...props]/clientSide.tsx index d991aedd13..9d34d94975 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/clientSide.tsx +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/clientSide.tsx @@ -5,20 +5,18 @@ import { useTranslation } from "@i18n/client"; import { FormRecord, TypeOmit } from "@lib/types"; import { Form } from "@clientComponents/forms/Form/Form"; import { Language } from "@lib/types/form-builder-types"; -import React, { useEffect, useMemo, useState } from "react"; +import React, { useMemo, useState } from "react"; import { useGCFormsContext } from "@lib/hooks/useGCFormContext"; -import { restoreSessionProgress, removeProgressStorage } from "@lib/utils/saveSessionProgress"; + import { getRenderedForm } from "@lib/formBuilder"; -import { toast } from "@formBuilder/components/shared/Toast"; + import { ToastContainer } from "@formBuilder/components/shared/Toast"; import { TextPage } from "@clientComponents/forms"; import { showReviewPage } from "@root/lib/utils/form-builder/showReviewPage"; import { useUpdateHeadTitle } from "@root/lib/hooks/useUpdateHeadTitle"; import { getLocalizedProperty } from "@root/lib/utils"; import { LOCKED_GROUPS } from "@formBuilder/components/shared/right-panel/headless-treeview/constants"; -import { flattenStructureToValues, stripExcludedKeys } from "./lib/client/helpers"; -import { FormRestoredWarning } from "@clientComponents/forms/ResumeForm/FormRestoredWarning"; - +import { useResponsesCache } from "@root/lib/hooks/useResponseCache"; export const FormWrapper = ({ formRecord, header, @@ -34,7 +32,6 @@ export const FormWrapper = ({ i18n: { language }, } = useTranslation(["common", "confirmation", "form-closed", "review"]); const { - saveSessionProgress, setSubmissionId, submissionId, submissionDate, @@ -43,7 +40,9 @@ export const FormWrapper = ({ getGroupTitle, } = useGCFormsContext(); const [captchaFail, setCaptchaFail] = useState(false); + const { cachedSession } = useResponsesCache(formRecord.id, formRecord.form); const captchaToken = React.useRef(""); + // TODO : If the formRecord contains file inputs Save and Resume is not available const saveAndResume = formRecord?.saveAndResume; // Generate form elements on the client to ensure Formik context is available @@ -51,25 +50,8 @@ export const FormWrapper = ({ return getRenderedForm(formRecord, language as Language); }, [formRecord, language]); - const formRestoredMessage = t("saveAndResume.formRestored"); const router = useRouter(); - const savedValues = useMemo(() => { - const result = restoreSessionProgress({ - id: formRecord.id, - form: formRecord.form, - language: language as Language, - }); - - if (!result) { - return false; - } - - return result; - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [language, formRecord.id]); - // For multi-page forms update the sub page head title or review page title // Single-page forms will be skipped since since the title set in page.tsx is sufficient // Updating the confirmation page title is handled in the TextPage component @@ -90,36 +72,6 @@ export const FormWrapper = ({ const isMultiPageForm = showReviewPage(formRecord.form); useUpdateHeadTitle(getPageTitle(), isMultiPageForm); - const isEmptyForm = useMemo(() => { - try { - if (!savedValues) { - return false; - } - const elements = stripExcludedKeys(savedValues.values || {}); - const elementValues = flattenStructureToValues(elements); - return elementValues.join("") === ""; - } catch (e) { - return true; - } - }, [savedValues]); - - useEffect(() => { - // Clear session storage after values are restored - if (savedValues) { - removeProgressStorage(); - - if (savedValues.language === language && !isEmptyForm) { - if (savedValues.sourceFormId && savedValues.sourceFormId !== formRecord.id) { - toast.notice(, "public-facing-form-wide"); - } else { - toast.success(formRestoredMessage, "public-facing-form"); - } - } - } - }, [savedValues, language, isEmptyForm, formRecord.id, formRestoredMessage]); - - const initialValues = savedValues ? savedValues.values : undefined; - // Show confirmation page if submissionId is present if (submissionId && submissionDate) { return ( @@ -136,8 +88,9 @@ export const FormWrapper = ({ return ( <> {header} + { @@ -153,7 +106,6 @@ export const FormWrapper = ({ } }} t={t} - saveSessionProgress={saveSessionProgress} saveAndResumeEnabled={saveAndResume} renderSubmit={({ validateForm, fallBack }) => { return ( diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader.ts b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader.ts index 5b7c57b279..40485f9da9 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader.ts +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader.ts @@ -1,72 +1,10 @@ import { PresignedPost } from "@aws-sdk/s3-presigned-post"; -import { v4 as uuid } from "uuid"; + import axios, { AxiosError, AxiosProgressEvent } from "axios"; -import { Responses, FileInputResponse, FileInput } from "@gcforms/types"; +import { FileInput } from "@gcforms/types"; import { FileUploadError } from "../client/exceptions"; import { isMimeTypeValid } from "@gcforms/core"; -const isFileInput = (response: unknown): response is FileInput => { - return ( - response !== null && - typeof response === "object" && - "name" in response && - "size" in response && - "content" in response && - response.name !== null && - response.size !== null && - response.content !== null - ); -}; - -export const isFileInputResponse = (response: unknown): response is FileInputResponse => { - return ( - response !== null && - typeof response === "object" && - "name" in response && - "size" in response && - "content" in response - ); -}; - -export const copyObjectExcludingFileContent = ( - originalObject: Responses, - fileObjsRef: Record = {} -) => { - const formValuesWithoutFileContent: Responses = {}; - const filterFileContent = (originalState: T, filteredState: Record): T => { - if (originalState === null || typeof originalState !== "object") { - return originalState; - } - - if (Array.isArray(originalState)) { - return originalState.map((item) => filterFileContent(item, {})) as unknown as T; - } - - if (isFileInputResponse(originalState)) { - const id = originalState.content !== null ? uuid() : null; - - // Collect the file reference if there is content - if (id && isFileInput(originalState)) { - fileObjsRef[id] = originalState; - } - // Return a shallow copy without content - return { - id, - name: originalState.name, - size: originalState.size, - } as unknown as T; - } - - Object.keys(originalState).forEach((key) => { - filteredState[key] = filterFileContent((originalState as Record)[key], {}); - }); - return filteredState as unknown as T; - }; - filterFileContent(originalObject, formValuesWithoutFileContent); - - return { formValuesWithoutFileContent, fileObjsRef }; -}; - export const uploadFile = async ( file: FileInput, preSigned: PresignedPost, diff --git a/app/(gcforms)/[locale]/(form filler)/id/[...props]/page.tsx b/app/(gcforms)/[locale]/(form filler)/id/[...props]/page.tsx index 1773ccd70a..54fe4e53bb 100644 --- a/app/(gcforms)/[locale]/(form filler)/id/[...props]/page.tsx +++ b/app/(gcforms)/[locale]/(form filler)/id/[...props]/page.tsx @@ -10,6 +10,7 @@ import { allowGrouping } from "@lib/groups/utils/allowGrouping"; import { serverTranslation } from "@i18n"; import { headers } from "next/headers"; import { Footer } from "@serverComponents/globals/Footer"; +import { Suspense } from "react"; export async function generateMetadata(props0: { params: Promise<{ locale: string; props: string[] }>; @@ -38,7 +39,6 @@ export async function generateMetadata(props0: { export default async function Page(props0: { params: Promise<{ locale: string; props: string[] }>; }) { - const nonce = (await headers()).get("x-nonce") ?? ""; const pathname = (await headers()).get("x-path") ?? ""; const params = await props0.params; @@ -74,27 +74,29 @@ export default async function Page(props0: { ); return ( - - - - - + + + + + + + ); } diff --git a/app/layout.tsx b/app/layout.tsx index 5711250df5..e5f862847f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -10,6 +10,8 @@ import { Noto_Sans, Lato } from "next/font/google"; import { googleTagManager } from "@lib/cspScripts"; import { headers } from "next/headers"; import { auth } from "@lib/auth"; +import ServiceWorker from "@clientComponents/globals/ServiceWorker"; +import { AppUpdater } from "@clientComponents/globals/Update"; export const dynamic = "force-dynamic"; @@ -95,7 +97,14 @@ export default async function Layout({ children }: { children: React.ReactNode } - {children} + + {/* AppUpdater must be the very first element in the body */} + + {process.env.NODE_ENV === "production" || process.env.APP_UPDATER === "true" ? ( + + ) : null} + {children} + ); } diff --git a/components/clientComponents/forms/Form/Form.tsx b/components/clientComponents/forms/Form/Form.tsx index 97264b17f7..9d2e417dda 100644 --- a/components/clientComponents/forms/Form/Form.tsx +++ b/components/clientComponents/forms/Form/Form.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useEffect, useState, useCallback } from "react"; +import React, { useEffect, useState } from "react"; import { withFormik } from "formik"; import { getFormInitialValues } from "@lib/formBuilder"; import { getErrorList, setFocusOnErrorMessage } from "@lib/validation/validation"; @@ -37,11 +37,8 @@ import { SubmitProgress } from "@clientComponents/forms/SubmitProgress/SubmitPro import { handleUploadError } from "@lib/fileInput/handleUploadError"; import { hasFiles } from "@lib/fileExtractor"; import { generateFileChecksums } from "@lib/utils/fileChecksum"; - -import { - copyObjectExcludingFileContent, - uploadFile, -} from "@root/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader"; +import { copyObjectExcludingFileContent } from "@lib/hooks/useResponseCache"; +import { uploadFile } from "@root/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader"; import { SaveAndResumeButton } from "@clientComponents/forms/SaveAndResume/SaveAndResumeButton"; import { LOCKED_GROUPS } from "@formBuilder/components/shared/right-panel/headless-treeview/constants"; @@ -117,23 +114,6 @@ const InnerForm: React.FC = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [formStatusError, errorList, lastSubmitCount, canFocusOnError]); - const handleSessionSave = useCallback(() => { - props.saveSessionProgress && props.saveSessionProgress(language as Language); - }, [language, props]); - - useEffect(() => { - const beforeUnloadHandler = () => { - handleSessionSave(); - return null; - }; - - window.addEventListener("beforeunload", beforeUnloadHandler); - - return () => { - window.removeEventListener("beforeunload", beforeUnloadHandler); - }; - }, [handleSessionSave]); - // Show the Captcha fail screen when hCAPTCHA detects a suspicous user // Note: check done here vs higher in the tree so the Form session will still exist on the screen if (props.captchaFail) { @@ -335,13 +315,6 @@ export const Form = withFormik({ clearInterval(progressInterval); - // Failed to find Server Action (likely due to newer deployment) - if (result === undefined) { - formikBag.props.saveSessionProgress(); - logMessage.info("Failed to find Server Action caught and session saved"); - formikBag.setStatus(FormStatus.SERVER_ID_ERROR); - return; - } // Start here to upload files and handle errors below into something easier to read if (result.error) { diff --git a/components/clientComponents/forms/Form/types.ts b/components/clientComponents/forms/Form/types.ts index 66e69a3679..8810346f18 100644 --- a/components/clientComponents/forms/Form/types.ts +++ b/components/clientComponents/forms/Form/types.ts @@ -2,7 +2,6 @@ import { type JSX } from "react"; import type { TFunction } from "i18next"; import { FormikProps } from "formik"; -import { Language } from "@lib/types/form-builder-types"; import { Responses, PublicFormRecord, Validate } from "@lib/types"; export interface FormProps { @@ -23,7 +22,6 @@ export interface FormProps { allowGrouping?: boolean | undefined; groupHistory?: string[]; matchedIds?: string[]; - saveSessionProgress: (language?: Language) => void; saveAndResumeEnabled?: boolean; currentGroup: string | null; setCaptchaFail?: React.Dispatch>; diff --git a/components/clientComponents/forms/ResumeForm/Upload.tsx b/components/clientComponents/forms/ResumeForm/Upload.tsx index fecf7aede3..3ba686178a 100644 --- a/components/clientComponents/forms/ResumeForm/Upload.tsx +++ b/components/clientComponents/forms/ResumeForm/Upload.tsx @@ -2,10 +2,10 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "@clientComponents/globals"; import { useTranslation } from "@i18n/client"; import Link from "next/link"; -import { useRouter } from "next/navigation"; + import { Dialog, useDialogRef } from "@formBuilder/components/shared/Dialog"; -import { saveSessionProgress } from "@lib/utils/saveSessionProgress"; +import { saveSessionProgress } from "@lib/hooks/useResponseCache"; import { type FormValues } from "@gcforms/types"; import { toast } from "@formBuilder/components/shared/Toast"; @@ -28,7 +28,7 @@ type ResumeFormResponse = { }; type PendingMismatchResume = Omit & { - sourceFormId: string; + formVersionId: string; }; export const Upload = ({ formId }: { formId: string }) => { @@ -37,7 +37,6 @@ export const Upload = ({ formId }: { formId: string }) => { i18n: { language }, } = useTranslation(["form-builder", "common"]); - const router = useRouter(); const { logClientError } = useLogClient(); const dialogRef = useDialogRef(); const dragResetTimeoutRef = useRef | null>(null); @@ -73,16 +72,18 @@ export const Upload = ({ formId }: { formId: string }) => { values, history, currentGroup, - sourceFormId, - }: ResumeFormResponse & { sourceFormId?: string }) => { - saveSessionProgress(language, { + formVersionId, + }: ResumeFormResponse & { formVersionId?: string }) => { + saveSessionProgress({ id, values, - history, currentGroup, - sourceFormId, + language, + history, + formVersionId, + }).then(() => { + window.location.href = `/${language}/id/${id}`; }); - router.push(`/${language}/id/${id}`); }; const handleContinueAnyway = () => { @@ -96,7 +97,7 @@ export const Upload = ({ formId }: { formId: string }) => { values: pendingMismatchResume.values, history: pendingMismatchResume.history, currentGroup: pendingMismatchResume.currentGroup, - sourceFormId: pendingMismatchResume.sourceFormId, + formVersionId: pendingMismatchResume.formVersionId, }); setPendingMismatchResume(null); }; @@ -173,7 +174,7 @@ export const Upload = ({ formId }: { formId: string }) => { if (id !== formId) { resetInput?.(); setPendingMismatchResume({ - sourceFormId: id, + formVersionId: id, values: parsed.values, history: parsed.history, currentGroup: parsed.currentGroup, @@ -321,7 +322,7 @@ export const Upload = ({ formId }: { formId: string }) => {

{t("saveAndResume.resumePage.mismatchedForm.warning")}

{t("saveAndResume.resumePage.mismatchedForm.matchingFormLink")} diff --git a/components/clientComponents/globals/ClientContexts.tsx b/components/clientComponents/globals/ClientContexts.tsx index 3bb1289ba4..8d8c2173a7 100644 --- a/components/clientComponents/globals/ClientContexts.tsx +++ b/components/clientComponents/globals/ClientContexts.tsx @@ -7,6 +7,7 @@ import { RefsProvider } from "@formBuilder/[id]/edit/components/RefsContext"; import { FeatureFlagsProvider } from "@lib/hooks/useFeatureFlags"; import { Flags } from "@lib/cache/types"; import { Announce } from "@gcforms/announce"; +import { AppUpdateProvider } from "@lib/hooks/useAppUpdate"; export const ClientContexts: React.FC<{ session: Session | null; @@ -14,7 +15,7 @@ export const ClientContexts: React.FC<{ featureFlags: Flags; }> = ({ session, children, featureFlags }) => { return ( - <> + - + ); }; diff --git a/components/clientComponents/globals/ServiceWorker.tsx b/components/clientComponents/globals/ServiceWorker.tsx new file mode 100644 index 0000000000..1995e31049 --- /dev/null +++ b/components/clientComponents/globals/ServiceWorker.tsx @@ -0,0 +1,19 @@ +"use client"; +import { useEffect } from "react"; + +export async function registerServiceWorker() { + return navigator.serviceWorker.register("/service-worker.js", { + scope: "/", + updateViaCache: "none", + }); +} + +export default function ServiceWorker() { + useEffect(() => { + if ("serviceWorker" in navigator) { + registerServiceWorker(); + } + }, []); + + return null; +} diff --git a/components/clientComponents/globals/Update.tsx b/components/clientComponents/globals/Update.tsx new file mode 100644 index 0000000000..a38e80a606 --- /dev/null +++ b/components/clientComponents/globals/Update.tsx @@ -0,0 +1,136 @@ +"use client"; + +import Markdown from "markdown-to-jsx"; +import { Button } from "./Buttons"; +import { useAppUpdate } from "@lib/hooks/useAppUpdate"; +import { toast } from "@formBuilder/components/shared/Toast"; +import { useTranslation } from "@i18n/client"; +import { useEffect, useState } from "react"; + +const LeftIcon = ({ className, title }: { className?: string; title?: string }) => ( + + {title && {title}} + + + +); + +const RightIcon = ({ className, title }: { className?: string; title?: string }) => ( + + {title && {title}} + + + +); + +export const AppUpdater = () => { + const { updateRequired } = useAppUpdate(); + + // hide any visible toasts while the updater is active + // run on render/mount + useEffect(() => { + try { + toast.dismiss(); + } catch (err) { + // ignore + } + }, []); + + // short circuit when no update needed + if (!updateRequired) return null; + + return ; +}; + +const regex = /^\/?(en|fr)\/id\/[a-z0-9]+$/; + +export const UpdateModal = () => { + const { t } = useTranslation("common"); + + const [isPublicFacing, setIsPublicFacing] = useState(undefined); + + useEffect(() => { + const id = window.setTimeout(() => { + setIsPublicFacing(regex.test(window.location.pathname)); + }, 0); + return () => window.clearTimeout(id); + }, []); + + const heading = isPublicFacing ? t("appUpdate.user.title") : t("appUpdate.builder.title"); + const message = isPublicFacing + ? t("appUpdate.user.description") + : t("appUpdate.builder.description"); + const buttonText = isPublicFacing ? t("appUpdate.user.button") : t("appUpdate.builder.button"); + + if (isPublicFacing === undefined) return null; + + return ( +

+ ); +}; diff --git a/i18n/translations/en/common.json b/i18n/translations/en/common.json index 7a49e1962c..e82c273aa6 100644 --- a/i18n/translations/en/common.json +++ b/i18n/translations/en/common.json @@ -389,5 +389,17 @@ "title": "Sign in to GC Forms", "description": "Use your government account to build forms and access submitted responses.", "button": "Sign in" + }, + "appUpdate": { + "builder": { + "title": "Update GC Forms to continue", + "description": "Review your form after updating and before publishing.", + "button": "Update " + }, + "user": { + "title": "To continue, update the software for this form", + "description": "Review your answers after updating and before submitting.", + "button": "Update" + } } } diff --git a/i18n/translations/fr/common.json b/i18n/translations/fr/common.json index 29676465ab..6b149924a4 100644 --- a/i18n/translations/fr/common.json +++ b/i18n/translations/fr/common.json @@ -388,5 +388,17 @@ "title": "Se connecter à Formulaires GC", "description": "Utilisez votre compte du gouvernement pour créer des formulaires et accéder aux réponses soumises.", "button": "Se connecter" + }, + "appUpdate": { + "builder": { + "title": "Veuillez mettre à jour Formulaires GC", + "description": "Vérifiez votre formulaire après la mise à jour et avant la publication.", + "button": "Mettre à jour" + }, + "user": { + "title": "Pour continuer, mettez à jour ce formulaire", + "description": "Vérifiez vos réponses après la mise à jour et avant de soumettre votre formulaire.", + "button": "Mettre à jour" + } } } diff --git a/i18n/translations/fr/my-forms.json b/i18n/translations/fr/my-forms.json index a1a4d63d42..ada750d168 100644 --- a/i18n/translations/fr/my-forms.json +++ b/i18n/translations/fr/my-forms.json @@ -25,7 +25,7 @@ "lastEditedBy": "par : {{lastEditedBy}}", "readOnly": "Ouvert - ", "editing": "{{name}} modifie", - "announceLocked": "Le formulaire nommé {{formName}} est en cours de modification", + "announceLocked": "Le formulaire nommé {{formName}} est en cours de modification", "announceUnlockedLocked": "Le formulaire nommé {{formName}} n'est plus en cours de modification" }, "card": { diff --git a/lib/hooks/form-builder/useTemplateContext.tsx b/lib/hooks/form-builder/useTemplateContext.tsx index e03e861f00..7f8de2ca0a 100644 --- a/lib/hooks/form-builder/useTemplateContext.tsx +++ b/lib/hooks/form-builder/useTemplateContext.tsx @@ -4,10 +4,11 @@ import isEqual from "lodash.isequal"; import { useSession } from "next-auth/react"; import { logMessage } from "@lib/logger"; import { safeJSONParse } from "@lib/utils"; -import { CreateOrUpdateTemplateType, createOrUpdateTemplate } from "@formBuilder/actions"; -import { FormProperties, FormRecord } from "@lib/types"; +import { createOrUpdateTemplate } from "@formBuilder/actions"; +import { FormProperties } from "@lib/types"; import { useTemplateStore } from "@lib/store/useTemplateStore"; import { useSubscibeToTemplateStore } from "@lib/store/hooks/useSubscibeToTemplateStore"; +import { useAppUpdate } from "../useAppUpdate"; export type SaveDraftStatus = "saved" | "skipped" | "invalid" | "locked" | "error"; @@ -17,23 +18,10 @@ type SaveDraftResult = { }; interface TemplateApiType { - templateIsDirty: React.MutableRefObject; + templateIsDirty: React.RefObject; updatedAt: number | undefined; setUpdatedAt: React.Dispatch>; - createOrUpdateTemplate: - | (({ - id, - formConfig, - name, - deliveryOption, - securityAttribute, - saveAndResume, - notificationsInterval, - }: CreateOrUpdateTemplateType) => Promise<{ - formRecord: FormRecord | null; - error?: string; - }>) - | null; + saveDraft: () => Promise; saveDraftIfNeeded: () => Promise; resetState: () => void; @@ -43,7 +31,6 @@ const defaultTemplateApi: TemplateApiType = { templateIsDirty: { current: false }, updatedAt: undefined, setUpdatedAt: () => {}, - createOrUpdateTemplate: null, saveDraft: async () => ({ status: "skipped" }), saveDraftIfNeeded: async () => ({ status: "skipped" }), resetState: () => {}, @@ -63,6 +50,7 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } const [updatedAt, setUpdatedAt] = useState(); const [, setDirtyTick] = useState(0); const { status } = useSession(); + const { updateTriggered } = useAppUpdate(); const { getDeliveryOption, @@ -86,11 +74,12 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } setFromRecord: s.setFromRecord, })); - const templateIsDirty = useRef(false); + const templateIsDirty = useRef(updateTriggered); const savedSnapshot = useRef(null); const saveInFlight = useRef(false); const queuedSaveRequested = useRef(false); const saveDraftQueue = useRef void>>([]); + const resetState = useCallback(() => { templateIsDirty.current = false; savedSnapshot.current = null; @@ -241,7 +230,6 @@ export function SaveTemplateProvider({ children }: { children: React.ReactNode } templateIsDirty, updatedAt, setUpdatedAt, - createOrUpdateTemplate, saveDraft: queueSaveDraft, saveDraftIfNeeded, resetState, diff --git a/lib/hooks/useAppUpdate.tsx b/lib/hooks/useAppUpdate.tsx new file mode 100644 index 0000000000..f8d389e02b --- /dev/null +++ b/lib/hooks/useAppUpdate.tsx @@ -0,0 +1,56 @@ +"use client"; +import { useState, useEffect, createContext, useContext } from "react"; +import { logMessage } from "../logger"; +import { AppUpdater } from "@clientComponents/globals/Update"; + +const AppUpdateContext = createContext({ + updateTriggered: false, + updateRequired: false, +}); + +export const AppUpdateProvider = ({ children }: { children: React.ReactNode }) => { + const [updateRequired, setUpdateRequired] = useState(false); + + const updateTriggered = + typeof window !== "undefined" ? Boolean(sessionStorage?.getItem("gcFormsUpdate")) : false; + + if (updateTriggered) { + logMessage.debug("useAppUpdate flagging update was triggered"); + } + + const handleMessage = (event: MessageEvent) => { + logMessage.info("Message to Update Recieved"); + if (event.data.type === "GCFORMS_UPDATE") { + sessionStorage.setItem("gcFormsUpdate", "inProgress"); + setUpdateRequired(true); + } + }; + // Register listeners on component load + useEffect(() => { + navigator.serviceWorker.addEventListener("message", handleMessage); + + return () => { + navigator.serviceWorker.removeEventListener("message", handleMessage); + }; + }, []); + + // After an update clean up and remove the update flag from session storage + useEffect(() => { + sessionStorage.removeItem("gcFormsUpdate"); + }, []); + + return ( + + {updateRequired && } + {children} + + ); +}; + +export const useAppUpdate = () => { + const context = useContext(AppUpdateContext); + if (context === undefined) { + throw new Error("useAppUpdate must be used within the AppUpdateProvider"); + } + return context; +}; diff --git a/lib/hooks/useFormSubmissionData.tsx b/lib/hooks/useFormSubmissionData.tsx index bb6940b39d..8ac4a2f4cc 100644 --- a/lib/hooks/useFormSubmissionData.tsx +++ b/lib/hooks/useFormSubmissionData.tsx @@ -8,7 +8,8 @@ import { useCallback } from "react"; import { slugify } from "@lib/client/clientHelpers"; import { getStartLabels } from "@lib/utils/form-builder/i18nHelpers"; import { type HTMLProps } from "@lib/saveAndResume/types"; -import { copyObjectExcludingFileContent } from "@root/app/(gcforms)/[locale]/(form filler)/id/[...props]/lib/client/fileUploader"; + +import { copyObjectExcludingFileContent } from "./useResponseCache"; import { getValuesWithMatchedIds, getVisibleGroupsBasedOnValuesRecursive } from "@gcforms/core"; diff --git a/lib/hooks/useGCFormContext.tsx b/lib/hooks/useGCFormContext.tsx index e6f7c12143..9de0d14e3f 100644 --- a/lib/hooks/useGCFormContext.tsx +++ b/lib/hooks/useGCFormContext.tsx @@ -1,7 +1,15 @@ "use client"; -import React, { createContext, useContext, ReactNode, useCallback } from "react"; - -import { type FormValues, type GroupsType, type PublicFormRecord } from "@gcforms/types"; +import React, { + createContext, + useContext, + ReactNode, + useEffect, + useState, + useRef, + useCallback, +} from "react"; + +import type { Responses, GroupsType, PublicFormRecord } from "@gcforms/types"; import { type Language } from "@lib/types/form-builder-types"; import { getGroupTitle as groupTitle } from "@lib/utils/getGroupTitle"; @@ -20,19 +28,15 @@ import { clearHistoryAfterId as _clearHistoryAfterId, } from "@lib/utils/form-builder/groupsHistory"; -import { - saveSessionProgress as saveToSession, - restoreSessionProgress as restoreSession, -} from "@lib/utils/saveSessionProgress"; - -import { toggleSavedValues } from "@i18n/toggleSavedValues"; - -import { type FileInputResponse } from "@lib/types"; import { LOCKED_GROUPS } from "@formBuilder/components/shared/right-panel/headless-treeview/constants"; - +import { useAppUpdate } from "@lib/hooks/useAppUpdate"; +import { useTranslation } from "@i18n/client"; +import { logMessage } from "../logger"; +import { FormValues } from "@root/packages/types/dist"; +import { copyObjectExcludingFileContent, saveSessionProgress } from "@lib/hooks/useResponseCache"; interface GCFormsContextValueType { - updateValues: ({ formValues }: { formValues: FormValues }) => void; - getValues: () => FormValues; + updateValues: ({ formValues }: { formValues: Responses }) => void; + getValues: () => Responses; matchedIds: string[]; groups?: GroupsType; currentGroup: string | null; @@ -51,17 +55,12 @@ interface GCFormsContextValueType { pushIdToHistory: (groupId: string) => string[]; clearHistoryAfterId: (groupId: string) => string[]; getGroupTitle: (groupId: string | null, language: Language) => string; - saveSessionProgress: (language: Language | undefined) => void; - restoreSessionProgress: ( - language: Language - ) => false | { id: number; language: Language; values: FormValues | false }; getProgressData: () => { id: string; values: FormValues; history: string[]; currentGroup: string; }; - getNonce: () => string; } const GCFormsContext = createContext(undefined); @@ -69,24 +68,69 @@ const GCFormsContext = createContext(undefi export const GCFormsProvider = ({ children, formRecord, - nonce, }: { children: ReactNode; formRecord: PublicFormRecord; - nonce?: string; }) => { const groups: GroupsType = formRecord.form.groups || {}; const initialGroup = groups ? LOCKED_GROUPS.START : null; - const values = React.useRef({}); - const history = React.useRef([LOCKED_GROUPS.START]); - const [matchedIds, setMatchedIds] = React.useState([]); - const [currentGroup, setCurrentGroup] = React.useState(initialGroup); - const [submissionId, setSubmissionId] = React.useState(undefined); - const [submissionDate, setSubmissionDate] = React.useState(undefined); - + const values = useRef({}); + const history = useRef([LOCKED_GROUPS.START]); + const [matchedIds, setMatchedIds] = useState([]); + const [currentGroup, setCurrentGroup] = useState(initialGroup); + const [submissionId, setSubmissionId] = useState(undefined); + const [submissionDate, setSubmissionDate] = useState(undefined); + const { updateRequired } = useAppUpdate(); + const { + i18n: { language }, + } = useTranslation(); const hasNextAction = (group: string) => { return groups[group]?.nextAction ? true : false; }; + const saveToCache = useCallback(async () => { + // Do not save to cache when on the resume page as it will overwrite the loaded + // values from the file. This is because the GCFormsContext also wraps the + // resume page + + logMessage.debug({ formId: formRecord.id, currentGroup, language }); + if (window.location.href !== `/${language}/id/${formRecord.id}/resume`) { + await saveSessionProgress({ + id: formRecord.id, + values: values.current, + history: history.current, + currentGroup: currentGroup || "", + language: language, + }); + } + }, [formRecord.id, currentGroup, language]); + + useEffect(() => { + const updateCheck = async () => { + logMessage.debug(`Update is ${updateRequired ? "required" : "not required"}`); + if (updateRequired) { + logMessage.debug("Saving response state because update is required"); + await saveToCache(); + } + }; + updateCheck(); + }, [updateRequired, saveToCache]); + + useEffect(() => { + const visibilityChanged = async () => { + logMessage.debug( + `Window visibility changed: ${document.visibilityState ? "hidden" : "visible"}` + ); + await saveToCache(); + }; + + document.addEventListener("visibilitychange", visibilityChanged); + + return () => { + logMessage.debug("Removing event listener for beforeUnloadHandler"); + + document.removeEventListener("visibilitychange", visibilityChanged); + }; + }, [saveToCache]); /** * Handle check if the group is an off-board section @@ -106,7 +150,10 @@ export const GCFormsProvider = ({ const handleNextAction = () => { if (!currentGroup) return; - const filteredResponses = filterValuesByVisibleElements(formRecord, values.current); + const filteredResponses = filterValuesByVisibleElements( + formRecord, + values.current as FormValues + ); const filteredMatchedIds = matchedIds.filter((id) => { const parentId = id.split(".")[0]; if (filteredResponses[parentId]) { @@ -124,13 +171,9 @@ export const GCFormsProvider = ({ } }; - const updateValues = ({ - formValues, - }: { - formValues: Record; - }): void => { + const updateValues = ({ formValues }: { formValues: Responses }): void => { values.current = formValues; - const valueIds = mapIdsToValues(formRecord.form.elements, formValues); + const valueIds = mapIdsToValues(formRecord.form.elements, formValues as FormValues); if (!idArraysMatch(matchedIds, valueIds)) { setMatchedIds(valueIds); } @@ -141,12 +184,8 @@ export const GCFormsProvider = ({ setCurrentGroup(group); }; - const getValues = useCallback(() => { + const getValues = () => { return values.current; - }, []); - - const getNonce = () => { - return nonce || ""; }; // TODO: once groups flag is on, just use formHasGroups @@ -165,68 +204,25 @@ export const GCFormsProvider = ({ const clearHistoryAfterId = (groupId: string) => _clearHistoryAfterId(groupId, history.current); const getProgressData = () => { - const cleanedValues = {} as unknown as FormValues; - - Object.entries(values.current).map(([key, value]) => { - let cleanedValue = value; - - // For file inputs reset the values to null - if (value && typeof value === "object" && "size" in value) { - cleanedValue = { name: null, size: null, content: null } as FileInputResponse; - } - - // For repeating sets (dynamicRow), reset any file inputs within rows - if (Array.isArray(value)) { - cleanedValue = value.map((row) => { - if (row && typeof row === "object" && !Array.isArray(row)) { - const cleanedRow = { ...(row as Record) }; - for (const [rowKey, rowValue] of Object.entries(cleanedRow)) { - if (rowValue && typeof rowValue === "object" && "size" in rowValue) { - cleanedRow[rowKey] = { name: null, size: null, content: null }; - } - } - return cleanedRow; - } - return row; - }); - } - - // For all other inputs just return the value - cleanedValues[key] = cleanedValue as string | string[]; - }); + const { formValuesWithoutFileContent } = copyObjectExcludingFileContent(values.current); return { id: formRecord.id, - values: cleanedValues, + values: formValuesWithoutFileContent as FormValues, history: history.current, currentGroup: currentGroup || "", }; }; - const saveSessionProgress = (language: Language = "en") => { - const vals = - language === "en" - ? values.current - : (toggleSavedValues(formRecord.form, { values: values.current }, "en") as FormValues); - - saveToSession(language, { - id: formRecord.id, - values: vals, - history: history.current, - currentGroup: currentGroup || "", - }); - }; - - const restoreSessionProgress = (language: Language) => { - return restoreSession({ id: formRecord.id, form: formRecord.form, language }); - }; - const getGroupTitle = (groupId: string | null, language: Language) => { return groupTitle({ groups, groupId, language }); }; const getPreviousGroup = (currentGroup: string) => { - const valuesWithMatchedIds = getValuesWithMatchedIds(formRecord.form.elements, values.current); + const valuesWithMatchedIds = getValuesWithMatchedIds( + formRecord.form.elements, + values.current as FormValues + ); const visibleGroups = getVisibleGroupsBasedOnValuesRecursive( formRecord, valuesWithMatchedIds, @@ -263,10 +259,7 @@ export const GCFormsProvider = ({ pushIdToHistory, clearHistoryAfterId, getGroupTitle, - saveSessionProgress, getProgressData, - restoreSessionProgress, - getNonce, }} > {children} @@ -283,7 +276,7 @@ export const useGCFormsContext = () => { return "noop"; }, getValues: () => { - return; + return {}; }, submissionId: undefined, setSubmissionId: () => void 0, @@ -303,7 +296,6 @@ export const useGCFormsContext = () => { pushIdToHistory: () => [], clearHistoryAfterId: () => [], getGroupTitle: () => "", - saveSessionProgress: () => void 0, getProgressData: () => { return { id: "", @@ -312,10 +304,6 @@ export const useGCFormsContext = () => { currentGroup: "", }; }, - restoreSessionProgress: () => { - return {}; - }, - getNonce: () => "", }; } return formsContext; diff --git a/lib/hooks/useResponseCache.tsx b/lib/hooks/useResponseCache.tsx new file mode 100644 index 0000000000..187eee574e --- /dev/null +++ b/lib/hooks/useResponseCache.tsx @@ -0,0 +1,331 @@ +import { FormRestoredWarning } from "@clientComponents/forms/ResumeForm/FormRestoredWarning"; +import { toast } from "@formBuilder/components/shared/Toast"; +import type { Language } from "@lib/types/form-builder-types"; +import { + Responses, + FileInput, + ResponsesWithoutFileContent, + FileInputResponseWithoutContent, + FormProperties, + FileInputResponse, +} from "@gcforms/types"; +import { useTranslation } from "@i18n/client"; +import { use, useEffect } from "react"; +import { toggleSavedValues } from "@i18n/toggleSavedValues"; +import { logMessage } from "../logger"; +import { useAppUpdate } from "./useAppUpdate"; + +import { v4 as uuid } from "uuid"; + +const LOCAL_CACHE_NAME = "gcforms-virtual-files"; +const SESSION_STORAGE_KEY = "form-data"; + +export type Options = { + id: string; + values: Responses; + history: string[]; + currentGroup: string; + language: string; + formVersionId?: string; +}; + +export type RestoredProgress = { + id: string; + language: Language; + values: Responses; + formVersionId?: string; +}; + +const isFileInput = (response: unknown): response is FileInput => { + return ( + response !== null && + typeof response === "object" && + "name" in response && + "size" in response && + "content" in response && + response.name !== null && + response.size !== null && + response.content !== null + ); +}; + +export const isFileInputResponse = (response: unknown): response is FileInputResponse => { + return ( + response !== null && + typeof response === "object" && + "name" in response && + "size" in response && + "content" in response + ); +}; +const isFileInputResponseWithoutContent = ( + response: unknown +): response is FileInputResponseWithoutContent => { + return ( + response !== null && + typeof response === "object" && + "id" in response && + "name" in response && + "size" in response + ); +}; + +const clearCache = async () => { + const localCache = await caches.open(LOCAL_CACHE_NAME); + const files = await localCache.keys(); + await Promise.all( + files.map((file) => { + logMessage.debug(`Cleaning up file ${file.url}`); + localCache.delete(file); + }) + ); +}; + +// Needs to be called on submit +const clearResponseStorage = async () => { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + await clearCache(); +}; + +const getFileInCache = async (id: string) => { + const localCache = await caches.open(LOCAL_CACHE_NAME); + const fileResponse = await localCache.match(`/virtual-files/${id}`); + if (!fileResponse || !fileResponse.ok) { + return null; + } + await localCache.delete(`/virtual-files/${id}`); + + return fileResponse.blob(); +}; + +const storeFileInCache = async (id: string, data: FileInput) => { + const localCache = await caches.open(LOCAL_CACHE_NAME); + + const fileBlob = new Blob([data.content]); + + const fileResponse = new Response(fileBlob, { + status: 200, + statusText: "OK", + headers: { + "Content-Length": fileBlob.size.toString(), + }, + }); + + // Save it using a unique URL path identifier + await localCache.put(`/virtual-files/${id}`, fileResponse); +}; + +export const rebuildObjectWithFileContent = async (originalObject: ResponsesWithoutFileContent) => { + const formValuesWithFileContent: Responses = { ...originalObject }; + const rehydrateFileContent = async function (rehydratedObject: T): Promise { + if (rehydratedObject === null || typeof rehydratedObject !== "object") { + return rehydratedObject; + } + + if (Array.isArray(rehydratedObject)) { + return Promise.all(rehydratedObject.map((item) => rehydrateFileContent(item))) as T; + } + + if (isFileInputResponseWithoutContent(rehydratedObject)) { + const fileData = { + name: rehydratedObject.name, + size: rehydratedObject.size, + content: await getFileInCache(rehydratedObject.id) + // Add error handling here to possible notify the user that file content could not be rehydrated + .then((file) => file && file.arrayBuffer()) + .catch((e) => { + logMessage.error(e); + return null; + }), + } as T; + + return fileData; + } + + await Promise.all( + Object.keys(rehydratedObject).map(async (key) => { + const obj = rehydratedObject as Record; + obj[key] = await rehydrateFileContent(obj[key]); + }) + ); + return rehydratedObject; + }; + await rehydrateFileContent(formValuesWithFileContent); + + return formValuesWithFileContent; +}; + +export const copyObjectExcludingFileContent = ( + originalObject: Responses, + fileObjsRef: Record = {}, + nullifyFileInput = false +) => { + const formValuesWithoutFileContent: ResponsesWithoutFileContent = {}; + function filterFileContent(originalState: T, filteredState: Record): T { + if (originalState === null || typeof originalState !== "object") { + return originalState; + } + + if (Array.isArray(originalState)) { + return originalState.map((item) => filterFileContent(item, {})) as T; + } + + if (isFileInputResponse(originalState)) { + // Used to nullify file input when saving progress to file + if (nullifyFileInput) { + return { + name: null, + size: null, + } as T; + } + const id = originalState.content !== null ? uuid() : null; + + // Collect the file reference if there is content + if (id && isFileInput(originalState)) { + fileObjsRef[id] = originalState; + } + // Return a shallow copy without content + return { + id, + name: originalState.name, + size: originalState.size, + } as T; + } + + Object.keys(originalState).forEach((key) => { + filteredState[key] = filterFileContent((originalState as Record)[key], {}); + }); + return filteredState as unknown as T; + } + filterFileContent(originalObject, formValuesWithoutFileContent); + + return { formValuesWithoutFileContent, fileObjsRef }; +}; +// Must remain outside of hook because it is invoked before components are rendered +const restoreSessionProgress = async (): Promise => { + if (typeof sessionStorage === "undefined") { + return false; + } + + const encodedformData = sessionStorage.getItem(SESSION_STORAGE_KEY); + + if (!encodedformData) return undefined; + + try { + const formData = Buffer.from(encodedformData, "base64").toString("utf8"); + + if (!formData) return false; + + const parsedData = JSON.parse(formData); + const rehydratedValues = await rebuildObjectWithFileContent(parsedData.values); + await clearResponseStorage(); + return { + id: parsedData.id, + language: parsedData.language, + values: rehydratedValues, + formVersionId: parsedData.formVersionId, + }; + } catch (e) { + return false; + } +}; + +export const saveSessionProgress = async ({ + id, + values, + history, + currentGroup, + language, + formVersionId, +}: Options) => { + if (typeof sessionStorage === "undefined") { + return false; + } + + logMessage.debug("Saving Response Session Progress"); + + // Keep text data in session storage + // save files in indexDB, store private key in session storage + // clear session storage and index db on visibility change (data still stored in form state memory) + + // Extract file content from formValues so they are not part of the submission call to the submit action + const { formValuesWithoutFileContent, fileObjsRef } = copyObjectExcludingFileContent(values); + + // Store the file content in indexDB + + // const fileChecksums = await generateFileChecksums(fileObjsRef); + + await Promise.all( + Object.keys(fileObjsRef).map((objId) => storeFileInCache(objId, fileObjsRef[objId])) + ); + + const formData = JSON.stringify({ + // Allow formId to be overwritten when used as part of Upload File to resume + id, + values: formValuesWithoutFileContent, + history, + currentGroup, + language, + formVersionId, + }); + + // Encode UTF-8 string to base64 + const encodedformDataEn = Buffer.from(formData, "utf8").toString("base64"); + sessionStorage.setItem(SESSION_STORAGE_KEY, encodedformDataEn); +}; +// Start the promise on initial JS module load +const rawSessionValuesPromise = restoreSessionProgress(); + +export const useResponsesCache = (id: string, form: FormProperties, formVersionId?: string) => { + const { + t, + i18n: { language }, + } = useTranslation(); + + type useResponseCacheOutput = { + cachedSession?: RestoredProgress; + }; + + const { updateTriggered } = useAppUpdate(); + + const output: useResponseCacheOutput = {}; + + const rawData = use(rawSessionValuesPromise); + + useEffect(() => { + // Show that there was an error loading data + if (rawData === false) { + toast.notice(, "public-facing-form-wide"); + } else { + // Hard page refresh was not caused by a i18n + if (updateTriggered) { + toast.success(t("saveAndResume.formRestored"), "public-facing-form"); + } + } + }, [rawData, language, t, updateTriggered]); + + if (!rawData) { + // Show that there was an error loading data + if (rawData === false) toast.notice(, "public-facing-form-wide"); + + return output; + } + + output.cachedSession = { ...rawData }; + + // if it's the wrong form and version we do not return any values + if (output.cachedSession.id !== id || output.cachedSession.formVersionId !== formVersionId) { + delete output.cachedSession; + return output; + } + if (output.cachedSession.language !== language) { + // If caused by an i18n transtion ensure values are in the right language + output.cachedSession.values = toggleSavedValues( + form, + { values: output.cachedSession.values }, + output.cachedSession.language + ); + } + + return output; +}; diff --git a/lib/store/hooks/useRehydrate.tsx b/lib/store/hooks/useRehydrate.tsx index 91925974ba..719dfb08e6 100644 --- a/lib/store/hooks/useRehydrate.tsx +++ b/lib/store/hooks/useRehydrate.tsx @@ -1,4 +1,4 @@ -import { useEffect, use } from "react"; +import { use } from "react"; import { useTemplateStore, TemplateStoreContext } from "../useTemplateStore"; export const useRehydrate = () => { @@ -7,11 +7,5 @@ export const useRehydrate = () => { if (!store) throw new Error("Missing Template Store Provider in tree"); - useEffect(() => { - if (!hasHydrated) { - store.persist.rehydrate(); - } - }, [store, hasHydrated]); - return hasHydrated; }; diff --git a/lib/store/storage.ts b/lib/store/storage.ts index 7bd0bc5348..b6d960d3f5 100644 --- a/lib/store/storage.ts +++ b/lib/store/storage.ts @@ -1,23 +1,21 @@ +"use client"; import { logMessage } from "@lib/logger"; import { TemplateStoreState } from "./types"; import { StateStorage } from "zustand/middleware"; import { createJSONStorage } from "zustand/middleware"; -/* Note: "async" getItem is intentional here to work-around a hydration issue */ -/* https://github.com/pmndrs/zustand/issues/324#issuecomment-1031392610 */ - const storage: StateStorage = { - getItem: async (name: string) => { + getItem: (name: string) => { return sessionStorage.getItem(name) || null; }, - setItem: async (name: string, value: string) => { + setItem: (name: string, value: string) => { try { sessionStorage.setItem(name, value); } catch (error) { logMessage.info(`Error setting item in session storage: ${JSON.stringify(error)}`); } }, - removeItem: async (name: string) => { + removeItem: (name: string) => { sessionStorage.removeItem(name); }, }; @@ -37,4 +35,17 @@ export const storageOptions = { state?.setHasHydrated(); }; }, + + merge: (persisted: unknown, current: TemplateStoreState) => { + logMessage.debug("Merging state action"); + const persistedState = persisted as TemplateStoreState; + + // During vitest runs do not merge state, return the state + // that was mocked and passed in + if (process.env.NODE_ENV === "test") { + return current; + } + + return { ...current, ...persistedState }; + }, }; diff --git a/lib/store/useTemplateStore.tsx b/lib/store/useTemplateStore.tsx index a04bc52265..5070d39a1e 100644 --- a/lib/store/useTemplateStore.tsx +++ b/lib/store/useTemplateStore.tsx @@ -59,6 +59,7 @@ import { useFeatureFlags } from "../hooks/useFeatureFlags"; import { ErrorPanel } from "@clientComponents/globals/ErrorPanel"; import { useTranslation } from "@root/i18n/client"; import { getImportedTemplate } from "./importBuffer"; +import { useRehydrate } from "./hooks/useRehydrate"; const createTemplateStore = ( checkFeatureFlag: (flag: string) => boolean, @@ -188,14 +189,10 @@ const createTemplateStore = ( generateElementId: generateElementId(set, get), transform: transform(set, get), getSchema: () => { - // hasHydrated should work here but we get an error. leaving this timeout for now. - setTimeout(() => { - if (!get().hasTransformed) { - get().transform(); - } + if (get().hasHydrated && !get().hasTransformed) { + get().transform(); set({ hasTransformed: true }); - }, 500); - + } return JSON.stringify(getSchemaFromState(get(), get().allowGroupsFlag), null, 2); }, getId: () => get().id, @@ -300,11 +297,19 @@ export const TemplateStoreProvider = ({ props.closingDate, ]); + useEffect(() => { + if (!store.getState().hasHydrated) { + store.persist.rehydrate(); + } + }, [store]); + try { return ( - {children} + + {children} + ); @@ -325,3 +330,11 @@ export const useTemplateStore = ( if (!store) throw new Error("Missing Template Store Provider in tree"); return useStoreWithEqualityFn(store, selector, equalityFn ?? shallow); }; + +const OnlyRenderOnceHydrated = ({ children }: React.PropsWithChildren) => { + const hasHydrated = useRehydrate(); + if (hasHydrated) { + return <>{children}; + } + return null; +}; diff --git a/lib/utils/saveSessionProgress.ts b/lib/utils/saveSessionProgress.ts deleted file mode 100644 index 6cc696fab1..0000000000 --- a/lib/utils/saveSessionProgress.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { type Language } from "@lib/types/form-builder-types"; -import { FormProperties } from "@gcforms/types"; -import { type FormValues } from "@gcforms/types"; -import { toggleSavedValues } from "@i18n/toggleSavedValues"; - -export const SESSION_STORAGE_KEY = "form-data"; - -export const removeProgressStorage = () => { - sessionStorage.removeItem(SESSION_STORAGE_KEY); -}; - -export type Options = { - id: string; - values: FormValues; - history: string[]; - currentGroup: string; - sourceFormId?: string; -}; - -type RestoredProgress = { - id: number; - language: Language; - values: FormValues | false; - sourceFormId?: string; -}; - -export const saveSessionProgress = ( - language: string = "en", - { id, values, history, currentGroup, sourceFormId }: Options -) => { - if (typeof sessionStorage === "undefined") { - return false; - } - - const formData = JSON.stringify({ - id: id, - values: values, - history: history, - currentGroup: currentGroup, - language: language, - sourceFormId: sourceFormId, - }); - - // Encode UTF-8 string to base64 - const encodedformDataEn = Buffer.from(formData, "utf8").toString("base64"); - sessionStorage.setItem(SESSION_STORAGE_KEY, encodedformDataEn); -}; - -export const restoreSessionProgress = ({ - id, - form, - language, -}: { - id: string; - form: FormProperties; - language: Language; -}): RestoredProgress | false => { - if (typeof sessionStorage === "undefined") { - return false; - } - - const encodedformData = sessionStorage.getItem(SESSION_STORAGE_KEY); - - if (!encodedformData) return false; - - try { - const formData = Buffer.from(encodedformData, "base64").toString("utf8"); - - if (!formData) return false; - - const parsedData = JSON.parse(formData); - - if (parsedData.id === id) { - // Toggle the values if the language is different - if (parsedData.language !== language) { - const vals = toggleSavedValues(form, parsedData, parsedData.language); - return { - id: parsedData.id, - language: parsedData.language, - values: vals ? (vals as FormValues) : false, - sourceFormId: parsedData.sourceFormId, - }; - } - - return { - id: parsedData.id, - language: parsedData.language, - values: parsedData.values, - sourceFormId: parsedData.sourceFormId, - }; - } - } catch (e) { - return false; - } - - return false; -}; diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c681e86050..39079ffe9a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -132,4 +132,4 @@ ### Changed -- Setup package for common standalone functions +- Setup package for common standalone functions \ No newline at end of file diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index a4c5e12310..f30a6bdbe2 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.3] - 2026-06-16 + +- Add Response types without file content + ## [1.0.34] - 2026-06-11 - Add lastEditedBy to the FormRecord type diff --git a/packages/types/package.json b/packages/types/package.json index b15435f9fd..405be314a1 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@gcforms/types", - "version": "1.0.34", + "version": "1.0.35", "author": "Canadian Digital Service", "license": "MIT", "publishConfig": { diff --git a/packages/types/src/form-response-types.ts b/packages/types/src/form-response-types.ts index 198a876f45..d64e75834a 100644 --- a/packages/types/src/form-response-types.ts +++ b/packages/types/src/form-response-types.ts @@ -4,6 +4,10 @@ export type Responses = { [key: string]: Response; }; +export type ResponsesWithoutFileContent = { + [key: string]: ResponseWithoutFileContent; +}; + export type Response = | string | string[] @@ -19,3 +23,14 @@ export type FileInputResponse = { size: number | null; content: ArrayBuffer | null; }; + +export type FileInputResponseWithoutContent = { + id: string; + name: string | null; + size: number | null; +}; + +type ResponseWithoutFileContent = + | Exclude + | FileInputResponseWithoutContent + | FileInputResponseWithoutContent[]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7b68593c7b..093b785b44 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -33,7 +33,13 @@ import { ValidationInputType, } from "./form-types"; -export type { Response, Responses, FileInputResponse } from "./form-response-types"; +export type { + Response, + Responses, + FileInputResponse, + ResponsesWithoutFileContent, + FileInputResponseWithoutContent, +} from "./form-response-types"; export type { dynamicRowType }; export type { FormElement }; diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 0000000000..657399a025 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,102 @@ +/// +// @ts-check + +/** @type {ServiceWorkerGlobalScope} */ +// @ts-expect-error - self is ServiceWorkerGlobalScope in worker context +const sw = self; + +let triggerUpdate = false; + +/** @type {ReturnType | null} */ +let triggerRef = null; + +/** + * @typedef {Object} GCFormsMessage + * @property {string} type + * @property {string} message + */ + +/* eslint-disable no-console */ +sw.addEventListener("install", () => { + console.info("service worker installed"); + sw.skipWaiting(); +}); + +sw.addEventListener("activate", async () => { + console.info("service worker activated"); + sw.clients.claim(); +}); + +sw.addEventListener("fetch", (event) => { + const requestMethod = event.request.method; + const nextAction = Boolean(event.request.headers.get("next-action")); + fakeUpdateRequirement(); + if (requestMethod === "POST" && nextAction) { + event.respondWith( + new Promise((resolve) => { + // Here for testing purposes only, remove below before merging + if (triggerUpdate) { + const testingHeaders = new Headers(); + testingHeaders.set("x-nextjs-action-not-found", "1"); + testingHeaders.set("cache-control", "no-cache, no-store, max-age=0, must-revalidate"); + + const modifiedResponse = new Response("Server action not found.", { + status: 404, + statusText: "Not Found", + headers: testingHeaders, + }); + + console.info("Asking clients to update"); + broadcastMessageToClients({ + type: "GCFORMS_UPDATE", + message: "Update is required to use server actions", + }); + triggerUpdate = false; + + return resolve(modifiedResponse); + } + + // Here for testing purposes only, remove above before merging + + fetch(event.request).then((response) => { + if ( + response.status === 404 && + Boolean(response.headers.get("x-nextjs-action-not-found")) + ) { + console.info("Asking clients to update"); + broadcastMessageToClients({ + type: "GCFORMS_UPDATE", + message: "Update is required to use server actions", + }); + } + resolve(response); + }); + }) + ); + } +}); + +/** + * @param {GCFormsMessage} messageData + */ +function broadcastMessageToClients(messageData) { + sw.clients.matchAll({ includeUncontrolled: true, type: "window" }).then((clients) => { + clients.forEach((client) => { + client.postMessage(messageData); + }); + }); +} + +// Here for testing purposes only, remove below before merging +function fakeUpdateRequirement() { + // Set that a update is required every min. + if (!triggerUpdate && !triggerRef) { + console.info("Setting Timer to request site update"); + triggerRef = setTimeout(() => { + triggerUpdate = true; + triggerRef = null; + console.info("Update ready to be triggered"); + }, 30000); + } +} +// Here for testing purposes only, remove above before merging