Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
9bbaf1c
wip
bryan-robitaille Apr 29, 2025
482bb6a
Merge branch 'main' into feature/service_worker
bryan-robitaille Feb 18, 2026
9c3d5aa
base service worker
bryan-robitaille Feb 18, 2026
778c004
Merge branch 'main' into feature/service_worker
bryan-robitaille Feb 20, 2026
bee2bd9
wip
bryan-robitaille Feb 20, 2026
3aff677
wip
bryan-robitaille Feb 23, 2026
4d738db
wip
bryan-robitaille Feb 23, 2026
56cdadc
Merge branch 'main' into feature/service_worker
bryan-robitaille Mar 6, 2026
683e5a8
Merge branch 'main' into feature/service_worker
bryan-robitaille Mar 18, 2026
407d1bc
wip
bryan-robitaille Mar 23, 2026
90243ab
continue testing
bryan-robitaille Mar 23, 2026
779c807
wip
bryan-robitaille Mar 30, 2026
a14fcbd
Merge branch 'main' into feature/service_worker
bryan-robitaille Apr 24, 2026
7d519bd
Merge branch 'main' into feature/service_worker
bryan-robitaille May 20, 2026
ce405e4
ignore otel folder with config files for testing
bryan-robitaille May 20, 2026
6e2e84a
skip hcaptcha in local dev
bryan-robitaille May 22, 2026
2b48355
edit page now restores state properly
bryan-robitaille May 22, 2026
07f0d7d
update state after server save
bryan-robitaille May 22, 2026
1627f89
Merge branch 'main' into feature/service_worker
bryan-robitaille May 25, 2026
ffa564b
service worker on built versions only
bryan-robitaille May 25, 2026
7550d52
fix package version bump
bryan-robitaille May 25, 2026
48a0fd0
some dependencies for tests to pass until refactored
bryan-robitaille May 25, 2026
f865556
Merge branch 'main' into feature/service_worker
bryan-robitaille May 25, 2026
51b82a0
fix filter for checking for migrations
bryan-robitaille May 25, 2026
8f71f69
Merge branch 'feature/service_worker' of https://github.com/cds-snc/p…
bryan-robitaille May 25, 2026
5292886
Merge branch 'main' into feature/service_worker
bryan-robitaille May 25, 2026
c26eb0c
Merge branch 'main' into feature/service_worker
Jun 4, 2026
28cb9e8
add strings
Jun 4, 2026
1558257
add icon
Jun 4, 2026
d15aea3
add blur
Jun 4, 2026
9f7e21d
revert update --- moved to a seperate PR
Jun 5, 2026
e37319c
Merge branch 'main' into feature/service_worker
timarney Jun 8, 2026
141ee03
Merge branch 'main' into feature/service_worker
Jun 9, 2026
20cb7ff
update styles
Jun 9, 2026
8f23b10
revert
Jun 9, 2026
7af23b6
Merge branch 'main' into feature/service_worker
timarney Jun 9, 2026
19b94a8
Merge branch 'main' into feature/service_worker
timarney Jun 9, 2026
edae9dc
Merge branch 'main' into feature/service_worker
timarney Jun 9, 2026
cd4c6b5
Merge branch 'main' into feature/service_worker
timarney Jun 10, 2026
888af11
Apply suggestion from @anikbrazeau
anikbrazeau Jun 10, 2026
703c1f6
Apply suggestion from @anikbrazeau
anikbrazeau Jun 10, 2026
6fced3c
Merge branch 'main' into feature/service_worker
timarney Jun 10, 2026
213c238
Merge branch 'main' into feature/service_worker
timarney Jun 11, 2026
e895b7f
Merge branch 'main' into feature/service_worker
bryan-robitaille Jun 16, 2026
41f1e85
wip
bryan-robitaille Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pr-review-client-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
with:
filters: |
migrations:
- 'prisma/migrations/**'
- 'packages/database/prisma/migrations/**'

build-and-push-container:
needs: [run-check]
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,5 @@ packages/database/src/generated
tests/.auth/*.json
eslint_report.html

/otel
next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const PreviewFormWrapper = ({
setSent: React.Dispatch<React.SetStateAction<string | null | undefined>>;
}) => {
const { status } = useSession();
const { saveSessionProgress, currentGroup } = useGCFormsContext();
const { currentGroup } = useGCFormsContext();

const { translationLanguagePriority, getLocalizationAttribute } = useTemplateStore((s) => ({
translationLanguagePriority: s.translationLanguagePriority,
Expand All @@ -43,7 +43,6 @@ export const PreviewFormWrapper = ({
return (
<Form
formRecord={formRecord}
saveSessionProgress={saveSessionProgress}
isPreview={true}
language={translationLanguagePriority}
t={translatedT}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,8 @@ export const toast = {
default: (message: string | JSX.Element, containerId = "default") => {
originalToast(toastContent(message, "default"), { containerId });
},
// Dismiss toasts. If `containerId` is provided, dismiss only that container.
dismiss: (containerId?: string) => {
originalToast.dismiss(containerId);
},
};
64 changes: 8 additions & 56 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/clientSide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,7 +32,6 @@ export const FormWrapper = ({
i18n: { language },
} = useTranslation(["common", "confirmation", "form-closed", "review"]);
const {
saveSessionProgress,
setSubmissionId,
submissionId,
submissionDate,
Expand All @@ -43,33 +40,18 @@ 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
const currentForm = useMemo(() => {
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
Expand All @@ -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(<FormRestoredWarning />, "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 (
Expand All @@ -136,8 +88,9 @@ export const FormWrapper = ({
return (
<>
{header}

<Form
initialValues={initialValues || undefined}
initialValues={cachedSession?.values}
formRecord={formRecord}
language={language}
onSuccess={(formID, submissionId) => {
Expand All @@ -153,7 +106,6 @@ export const FormWrapper = ({
}
}}
t={t}
saveSessionProgress={saveSessionProgress}
saveAndResumeEnabled={saveAndResume}
renderSubmit={({ validateForm, fallBack }) => {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, FileInput> = {}
) => {
const formValuesWithoutFileContent: Responses = {};
const filterFileContent = <T>(originalState: T, filteredState: Record<string, T>): 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<string, T>)[key], {});
});
return filteredState as unknown as T;
};
filterFileContent(originalObject, formValuesWithoutFileContent);

return { formValuesWithoutFileContent, fileObjsRef };
};

export const uploadFile = async (
file: FileInput,
preSigned: PresignedPost,
Expand Down
48 changes: 25 additions & 23 deletions app/(gcforms)/[locale]/(form filler)/id/[...props]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -74,27 +74,29 @@ export default async function Page(props0: {
);

return (
<FormDisplayLayout
pathname={pathname}
language={language}
formRecord={formRecord}
isPastClosingDate={isPastClosingDate}
step={step}
saveAndResume={saveAndResume}
footer={footer}
>
<GCFormsProvider formRecord={formRecord} nonce={nonce}>
<PageContent
formRecord={formRecord}
language={language}
formTitle={formTitle}
isPastClosingDate={isPastClosingDate}
step={step}
formId={formId}
saveAndResume={saveAndResume}
isAllowGrouping={isAllowGrouping}
/>
</GCFormsProvider>
</FormDisplayLayout>
<Suspense>
<FormDisplayLayout
pathname={pathname}
language={language}
formRecord={formRecord}
isPastClosingDate={isPastClosingDate}
step={step}
saveAndResume={saveAndResume}
footer={footer}
>
<GCFormsProvider formRecord={formRecord}>
<PageContent
formRecord={formRecord}
language={language}
formTitle={formTitle}
isPastClosingDate={isPastClosingDate}
step={step}
formId={formId}
saveAndResume={saveAndResume}
isAllowGrouping={isAllowGrouping}
/>
</GCFormsProvider>
</FormDisplayLayout>
</Suspense>
);
}
11 changes: 10 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -95,7 +97,14 @@ export default async function Layout({ children }: { children: React.ReactNode }
</noscript>
</head>

<body className={"has-[.bkd-soft]:bg-gray-soft"}>{children}</body>
<body className={"has-[.bkd-soft]:bg-gray-soft"}>
{/* AppUpdater must be the very first element in the body */}
<AppUpdater />
{process.env.NODE_ENV === "production" || process.env.APP_UPDATER === "true" ? (
<ServiceWorker />
) : null}
{children}
</body>
</html>
);
}
33 changes: 3 additions & 30 deletions components/clientComponents/forms/Form/Form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -117,23 +114,6 @@ const InnerForm: React.FC<InnerFormProps> = (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) {
Expand Down Expand Up @@ -335,13 +315,6 @@ export const Form = withFormik<FormProps, Responses>({

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) {
Expand Down
Loading
Loading