From fbfbe92ae3160fb377bc99109635bdf9ba4df8c6 Mon Sep 17 00:00:00 2001 From: Dave Samojlenko Date: Thu, 31 Oct 2024 10:21:30 -0400 Subject: [PATCH 1/4] chore: Add Transport Canada branding (#4536) Add Transport Canada branding --- .../[id]/settings/branding/components/options.ts | 11 ++++++++++- public/img/branding/tc-en.svg | 1 + public/img/branding/tc-fr.svg | 1 + 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 public/img/branding/tc-en.svg create mode 100644 public/img/branding/tc-fr.svg diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/branding/components/options.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/branding/components/options.ts index 5a225867cf..7ca302b78c 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/branding/components/options.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/branding/components/options.ts @@ -213,6 +213,15 @@ export const options = [ logoEn: "/img/branding/grain-en.svg", logoFr: "/img/branding/grain-fr.svg", logoTitleEn: "Canadian Grain Commission", - logoTitleFr: "Commission canadienne des grains" + logoTitleFr: "Commission canadienne des grains", + }, + { + name: "transport", + urlEn: "https://tc.canada.ca/en", + urlFr: "hhttps://tc.canada.ca/fr", + logoEn: "/img/branding/tc-en.svg", + logoFr: "/img/branding/tc-fr.svg", + logoTitleEn: "Transport Canada", + logoTitleFr: "Transports Canada", }, ]; diff --git a/public/img/branding/tc-en.svg b/public/img/branding/tc-en.svg new file mode 100644 index 0000000000..2b45c453ea --- /dev/null +++ b/public/img/branding/tc-en.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/img/branding/tc-fr.svg b/public/img/branding/tc-fr.svg new file mode 100644 index 0000000000..c716fd4e50 --- /dev/null +++ b/public/img/branding/tc-fr.svg @@ -0,0 +1 @@ + \ No newline at end of file From 66b45d7ba98829bea25ba80e6167fefd421165d3 Mon Sep 17 00:00:00 2001 From: Dave Samojlenko Date: Thu, 31 Oct 2024 10:42:57 -0400 Subject: [PATCH 2/4] fix: Allow apostrophes in email addresses (#4522) * Use single email validation check * Use gov email message * update messages * fix tests * Add test to catch apostrophe --- .../[locale]/(user authentication)/auth/login/actions.ts | 3 ++- .../[locale]/(user authentication)/auth/register/action.ts | 6 +----- .../auth/reset-password/[[...token]]/action.ts | 3 --- cypress/e2e/login_page.cy.ts | 5 ++++- cypress/e2e/register_page.cy.ts | 7 +++++-- lib/tests/validation/validation.test.js | 1 + 6 files changed, 13 insertions(+), 12 deletions(-) diff --git a/app/(gcforms)/[locale]/(user authentication)/auth/login/actions.ts b/app/(gcforms)/[locale]/(user authentication)/auth/login/actions.ts index 6eade2b813..8f7183c917 100644 --- a/app/(gcforms)/[locale]/(user authentication)/auth/login/actions.ts +++ b/app/(gcforms)/[locale]/(user authentication)/auth/login/actions.ts @@ -6,6 +6,7 @@ import { redirect } from "next/navigation"; import { CognitoIdentityProviderServiceException } from "@aws-sdk/client-cognito-identity-provider"; import { hasError } from "@lib/hasError"; import { handleErrorById } from "@lib/auth/cognito"; +import { isValidGovEmail } from "@lib/validation/validation"; export interface ErrorStates { authError?: { @@ -37,7 +38,7 @@ const validate = async ( v.toLowerCase(), v.toTrimmed(), v.minLength(1, t("input-validation.required", { ns: "common" })), - v.email(t("input-validation.email", { ns: "common" })), + v.custom((input) => isValidGovEmail(input), t("input-validation.validGovEmail")), ]), password: v.string([ v.minLength(1, t("input-validation.required", { ns: "common" })), diff --git a/app/(gcforms)/[locale]/(user authentication)/auth/register/action.ts b/app/(gcforms)/[locale]/(user authentication)/auth/register/action.ts index 52c497f263..42121027a4 100644 --- a/app/(gcforms)/[locale]/(user authentication)/auth/register/action.ts +++ b/app/(gcforms)/[locale]/(user authentication)/auth/register/action.ts @@ -52,11 +52,7 @@ const validate = async ( v.toLowerCase(), v.toTrimmed(), v.minLength(1, t("input-validation.required", { ns: "common" })), - v.email(t("input-validation.email", { ns: "common" })), - v.custom( - (input) => isValidGovEmail(input), - t("signUpRegistration.fields.username.error.validGovEmail") - ), + v.custom((input) => isValidGovEmail(input), t("input-validation.validGovEmail")), ]), password: v.string([ v.minLength(1, t("input-validation.required", { ns: "common" })), diff --git a/app/(gcforms)/[locale]/(user authentication)/auth/reset-password/[[...token]]/action.ts b/app/(gcforms)/[locale]/(user authentication)/auth/reset-password/[[...token]]/action.ts index 63db09061a..2a7623c360 100644 --- a/app/(gcforms)/[locale]/(user authentication)/auth/reset-password/[[...token]]/action.ts +++ b/app/(gcforms)/[locale]/(user authentication)/auth/reset-password/[[...token]]/action.ts @@ -49,7 +49,6 @@ const validateInitialResetForm = async ( v.toLowerCase(), v.toTrimmed(), v.minLength(1, t("input-validation.required")), - v.email(t("input-validation.email")), v.custom((input) => isValidGovEmail(input), t("input-validation.validGovEmail")), ]), }); @@ -90,7 +89,6 @@ const validateQuestionChallengeForm = async ( v.toLowerCase(), v.toTrimmed(), v.minLength(1, t("input-validation.required")), - v.email(t("input-validation.email")), v.custom((input) => isValidGovEmail(input), t("input-validation.validGovEmail")), ]), }); @@ -113,7 +111,6 @@ const validatePasswordResetForm = async ( v.toLowerCase(), v.toTrimmed(), v.minLength(1, t("input-validation.required")), - v.email(t("input-validation.email")), v.custom((input) => isValidGovEmail(input), t("input-validation.validGovEmail")), ]), password: v.string([ diff --git a/cypress/e2e/login_page.cy.ts b/cypress/e2e/login_page.cy.ts index a9bd0e4a8c..22a1465b0a 100644 --- a/cypress/e2e/login_page.cy.ts +++ b/cypress/e2e/login_page.cy.ts @@ -37,7 +37,10 @@ describe("Login Page", () => { cy.get("button[type='submit']").should("be.visible"); cy.get("button[type='submit']").click(); cy.get("[id='errorMessageusername']").should("be.visible"); - cy.get("[id='errorMessageusername']").should("contain", "Enter a valid email address."); + cy.get("[id='errorMessageusername']").should( + "contain", + "Enter a valid government email address." + ); }); it("Displays no error message when submitting a valid email", () => { cy.typeInField("input[id='username']", "test@cds-snc.ca"); diff --git a/cypress/e2e/register_page.cy.ts b/cypress/e2e/register_page.cy.ts index e740fe946c..5d434e25ec 100644 --- a/cypress/e2e/register_page.cy.ts +++ b/cypress/e2e/register_page.cy.ts @@ -24,14 +24,17 @@ describe("Register Page", () => { it("Error on submitting a form with an invalid email", () => { cy.typeInField("input[id='username']", "myemail@email"); cy.get("[type='submit']").click(); - cy.get("[id='errorMessageusername']").should("contain", "Enter a valid email address."); + cy.get("[id='errorMessageusername']").should( + "contain", + "Enter a valid government email address." + ); }); it("Error on submitting a form with a non government email", () => { cy.typeInField("input[id='username']", "myemail@email.com"); cy.get("[type='submit']").click(); cy.get("[id='errorMessageusername']").should( "contain", - "This field must be a valid federal government email" + "Enter a valid government email address." ); }); it("No error on submitting a form with a valid government email", () => { diff --git a/lib/tests/validation/validation.test.js b/lib/tests/validation/validation.test.js index 51e1d9c7d7..4188895172 100644 --- a/lib/tests/validation/validation.test.js +++ b/lib/tests/validation/validation.test.js @@ -463,6 +463,7 @@ describe("Gov Email domain validator", () => { ["test@something.ca", false], ["test+example@cds-snc.ca", true], ["test.hi+example-1@cds-snc.ca", true], + ["test.with'apostrophe@cds-snc.ca", true], ])(`Should return true if email is valid (testing "%s")`, async (email, isValid) => { expect(isValidGovEmail(email)).toBe(isValid); }); From 75b06d54ae88ed32df0fbc64a1f6bfa5765b3215 Mon Sep 17 00:00:00 2001 From: Dave Samojlenko Date: Thu, 31 Oct 2024 10:56:05 -0400 Subject: [PATCH 3/4] fix: Add client side validation to public forms (#4529) * Validate form client side before calling the serverAction * rename for clarity * Validate form client side before calling the serverAction * Validate form client side before calling the serverAction * unnecessary use client --- .../components/{server => client}/Success.tsx | 8 +- .../contact/components/client/ContactForm.tsx | 387 ++++++++++-------- .../[locale]/(support)/contact/page.tsx | 11 +- .../support/components/client/SupportForm.tsx | 310 ++++++++------ .../[locale]/(support)/support/page.tsx | 11 +- .../(support)/unlock-publishing/actions.ts | 5 +- .../components/{server => client}/Success.tsx | 6 +- .../client/UnlockPublishingForm.tsx | 235 +++++++---- .../(support)/unlock-publishing/page.tsx | 19 +- 9 files changed, 560 insertions(+), 432 deletions(-) rename app/(gcforms)/[locale]/(support)/components/{server => client}/Success.tsx (74%) rename app/(gcforms)/[locale]/(support)/unlock-publishing/components/{server => client}/Success.tsx (80%) diff --git a/app/(gcforms)/[locale]/(support)/components/server/Success.tsx b/app/(gcforms)/[locale]/(support)/components/client/Success.tsx similarity index 74% rename from app/(gcforms)/[locale]/(support)/components/server/Success.tsx rename to app/(gcforms)/[locale]/(support)/components/client/Success.tsx index a6033a5d11..06aa9eff91 100644 --- a/app/(gcforms)/[locale]/(support)/components/server/Success.tsx +++ b/app/(gcforms)/[locale]/(support)/components/client/Success.tsx @@ -1,14 +1,14 @@ -import { serverTranslation } from "@i18n"; import Link from "next/link"; import { LinkButton } from "@serverComponents/globals/Buttons/LinkButton"; import { FocusHeader } from "../client/FocusHeader"; +import { useTranslation } from "@i18n/client"; -export const Success = async ({ lang }: { lang: string }) => { - const { t } = await serverTranslation("form-builder", { lang }); +export const Success = ({ lang }: { lang: string }) => { + const { t } = useTranslation("form-builder"); return ( <> {t("requestSuccess.title")} -

{t("requestSuccess.weWillRespond")}

+

{t("requestSuccess.weWillRespond")}

{t("requestSuccess.backToForms")} diff --git a/app/(gcforms)/[locale]/(support)/contact/components/client/ContactForm.tsx b/app/(gcforms)/[locale]/(support)/contact/components/client/ContactForm.tsx index 74e6526b26..faf8b6b139 100644 --- a/app/(gcforms)/[locale]/(support)/contact/components/client/ContactForm.tsx +++ b/app/(gcforms)/[locale]/(support)/contact/components/client/ContactForm.tsx @@ -1,7 +1,6 @@ "use client"; import { useTranslation } from "@i18n/client"; -import { useFormState } from "react-dom"; -import { contact } from "../../actions"; +import { contact, ErrorStates } from "../../actions"; import { Label, Alert as ValidationMessage, @@ -15,7 +14,9 @@ import { TextInput } from "../../../components/client/TextInput"; import { MultipleChoiceGroup } from "../../../components/client/MultipleChoiceGroup"; import { TextArea } from "../../../components/client/TextArea"; import { SubmitButton } from "../../../components/client/SubmitButton"; -import { redirect } from "next/navigation"; +import { email, minLength, object, safeParse, string, toLowerCase, toTrimmed } from "valibot"; +import { useState } from "react"; +import { Success } from "../../../components/client/Success"; export const ContactForm = () => { const { @@ -23,181 +24,227 @@ export const ContactForm = () => { i18n: { language }, } = useTranslation(["form-builder", "common"]); - const [state, formAction] = useFormState(contact.bind(null, language), { validationErrors: [] }); + const [errors, setErrors] = useState({ validationErrors: [] }); + const [submitted, setSubmitted] = useState(false); const getError = (fieldKey: string) => { - return state.validationErrors.find((e) => e.fieldKey === fieldKey)?.fieldValue || ""; + return errors.validationErrors.find((e) => e.fieldKey === fieldKey)?.fieldValue || ""; }; - if (state.error === "") { - //Route through the client. - redirect(`/${language}/contact?success`); - } + const submitForm = async (formData: FormData) => { + const formEntries = Object.fromEntries(formData.entries()); + + const SupportSchema = object({ + // checkbox input can send a non-string value when empty + request: string(t("input-validation.required", { ns: "common" }), [ + minLength(1, t("input-validation.required", { ns: "common" })), + ]), + description: string([minLength(1, t("input-validation.required", { ns: "common" }))]), + name: string([minLength(1, t("input-validation.required", { ns: "common" }))]), + email: string([ + toLowerCase(), + toTrimmed(), + minLength(1, t("input-validation.required", { ns: "common" })), + email(t("input-validation.email", { ns: "common" })), + ]), + department: string([minLength(1, t("input-validation.required", { ns: "common" }))]), + // Note: branch and jobTitle are not required/validated + branch: string(), + jobTitle: string(), + }); + + const validateForm = safeParse(SupportSchema, formEntries, { abortPipeEarly: true }); + + if (!validateForm.success) { + setErrors({ + validationErrors: validateForm.issues.map((issue) => ({ + fieldKey: issue.path?.[0].key as string, + fieldValue: issue.message, + })), + }); + return; + } + + // Submit the form + const result = await contact(language, errors, formData); + + if (result.error) { + setErrors({ ...result }); + return; + } + + setSubmitted(true); + return; + }; return ( <> - {/* @todo Add general error to show user there was an internal service error */} - {Object.keys(state.validationErrors).length > 0 && ( - -
    - {Object.entries(state.validationErrors).map(([, { fieldKey, fieldValue }]) => { - return ( - - ); - })} -
-
+ {submitted ? ( + + ) : ( + <> + {Object.keys(errors.validationErrors).length > 0 && ( + +
    + {Object.entries(errors.validationErrors).map(([, { fieldKey, fieldValue }]) => { + return ( + + ); + })} +
+
+ )} +

{t("contactus.title")}

+

{t("contactus.useThisForm")}

+

+ {t("contactus.gcFormsTeamPart1")}{" "} + + {t("contactus.gcFormsTeamLink")} + {" "} + {t("contactus.gcFormsTeamPart2")} +

+ +

+ {t("contactus.ifYouExperience")}{" "} + {t("contactus.supportFormLink")}. +

+
+
+ {errors.error && ( + +

{t(errors.error)}

+
+ )} +
+ + {t("contactus.request.title")}{" "} + + ({t("required")}) + + + +
+
+ + + {t("contactus.description.description")} + +