diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosedDateBanner.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosedDateBanner.tsx index 43d5cafabd..8d0b7583bf 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosedDateBanner.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosedDateBanner.tsx @@ -12,7 +12,7 @@ export const ClosedDateBanner = ({ closingDate }: { closingDate?: string | null const isPastClosingDate = dateHasPast(Date.parse(closingDate)); - const { month, day, year, hour, minute } = formClosingDateEst(closingDate); + const { month, day, year, hour, minute, dayPeriod } = formClosingDateEst(closingDate); const closedText = t("closingDate.banner.text", { month, @@ -20,6 +20,7 @@ export const ClosedDateBanner = ({ closingDate }: { closingDate?: string | null year, hour, minute, + dayPeriod, }); if (!isPastClosingDate) { diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosingDateDialog.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosingDateDialog.tsx new file mode 100644 index 0000000000..3930024c7d --- /dev/null +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ClosingDateDialog.tsx @@ -0,0 +1,245 @@ +import { getMaxMonthDay } from "@clientComponents/forms/FormattedDate/utils"; +import { Button } from "@clientComponents/globals"; +import { Dialog, useDialogRef } from "@formBuilder/components/shared"; +import { useTranslation } from "@i18n/client"; +import { useEffect, useState } from "react"; +import { toast } from "@formBuilder/components/shared/Toast"; +import { WarningIcon } from "@serverComponents/icons"; +import { formClosingDateEst } from "@lib/utils/date/utcToEst"; +import { logMessage } from "@lib/logger"; + +export const ClosingDateDialog = ({ + showDateTimeDialog, + setShowDateTimeDialog, + save, + closingDate, +}: { + showDateTimeDialog: boolean; + setShowDateTimeDialog: React.Dispatch>; + save: (futureDate?: number) => Promise; + closingDate: string | null | undefined; +}) => { + const { + t, + i18n: { language }, + } = useTranslation("form-builder"); + const dialogRef = useDialogRef(); + const [hasErrors, setHasErrors] = useState(false); + + const [month, setMonth] = useState(""); + const [day, setDay] = useState(""); + const [year, setYear] = useState(""); + const [time, setTime] = useState(""); + + // Pre-populate the form with the closing date if it exists + useEffect(() => { + if (!closingDate) { + return; + } + try { + const { day, year, hour, minute } = formClosingDateEst(closingDate, language); + const month = (new Date(closingDate).getMonth() + 1).toString(); + if (month && day && year && hour && minute) { + setMonth(month); + setDay(day); + setYear(year); + setTime(`${hour}:${minute}`); + } + } catch (error) { + logMessage.debug(`Unable to parse closing date: ${closingDate}`); + } + }, [closingDate, language]); + + const handleClose = () => { + setShowDateTimeDialog(false); + dialogRef.current?.close(); + }; + + const handleSave = () => { + try { + // Client validation is done using native HTML Form validation, the below is unlikely + if (!month || !day || !year || !time) { + throw new Error("Missing required fields"); + } + + const hours = Number(time.split(":")[0]); + const minutes = Number(time.split(":")[1]); + const date = new Date( + Number(year), + Number(month) - 1, + Number(day), + Number(hours), + Number(minutes) + ); + const timestamp = date.getTime(); + + if (timestamp < Date.now()) { + setHasErrors(true); + return; + } + + save(timestamp); + handleClose(); + } catch (error) { + handleClose(); + toast.error(t("closingDate.savedErrorMessage")); + } + }; + + if (!showDateTimeDialog) { + return null; + } + + return ( + +
{ + e.preventDefault(); + handleSave(); + }} + > +
+

{t("scheduleClosingPage.dialog.text1")}

+
+
+
+ {t("scheduleClosingPage.dialog.text2")} +
+ {hasErrors && ( +
+ + {t("scheduleClosingPage.dialog.error.notFutureDate")} +
+ )} +
+
+ {language === "en" && ( + ) => + setMonth(e.target.value) + } + /> + )} + +
+ + setDay(e.target.value)} + value={day} + required + data-testid="date-picker-day" + /> +
+ + {language === "fr" && ( + ) => + setMonth(e.target.value) + } + /> + )} + +
+ + setYear(e.target.value)} + value={year} + required + data-testid="date-picker-year" + /> +
+
+
+
+ +
+ +

+ {t("scheduleClosingPage.dialog.timePicker.text2")} +

+ setTime(e.target.value)} + value={time} + placeholder="00:00" + required + pattern="^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" + /> +
+
+ +
+ + +
+
+
+
+ ); +}; + +const MonthDropdown = ({ + month, + onChange, +}: { + month?: string; + onChange: (e: React.ChangeEvent) => void; +}) => { + const { t } = useTranslation("form-builder"); + return ( +
+ + +
+ ); +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ScheduledClosingDate.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ScheduledClosingDate.tsx new file mode 100644 index 0000000000..65ae06a935 --- /dev/null +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/ScheduledClosingDate.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { isFutureDate } from "lib/utils/date/isFutureDate"; +import { useTranslation } from "@i18n/client"; +import { formClosingDateEst } from "lib/utils/date/utcToEst"; +import { logMessage } from "@lib/logger"; +import { useRehydrate } from "@lib/store/useTemplateStore"; + +export const ScheduledClosingDate = ({ + closingDate, + language, +}: { + closingDate?: string; + language: string; +}) => { + const { t } = useTranslation("common"); + + const hasHydrated = useRehydrate(); + + if (!hasHydrated) { + return null; + } + + if (!closingDate) { + return null; + } + + if (!isFutureDate(closingDate)) { + return null; + } + + let month, day, year, hour, minute, dayPeriod; + + try { + ({ month, day, year, hour, minute, dayPeriod } = formClosingDateEst(closingDate, language)); + } catch (error) { + logMessage.info("Unable to parse closing date", closingDate); + return null; + } + + if (!month || !day || !year || !hour || !minute || !dayPeriod) { + return null; + } + + return ( +
+ {t("closingNotice.text1")}{" "} + {t("closingNotice.text2", { + month, + day, + year, + hour, + minute, + dayPeriod, + })} +
+ ); +}; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/SetClosingDate.tsx b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/SetClosingDate.tsx index b15864ad4c..8d5e52a270 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/SetClosingDate.tsx +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/[id]/settings/manage/close/SetClosingDate.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { useTranslation } from "@i18n/client"; import { useTemplateStore } from "@lib/store/useTemplateStore"; import { toast } from "@formBuilder/components/shared/Toast"; @@ -13,6 +13,11 @@ import { ClosedSuccess } from "./ClosedSuccess"; import { ClosedDateBanner } from "./ClosedDateBanner"; import { closeForm } from "@formBuilder/actions"; +import { ClosingDateDialog } from "./ClosingDateDialog"; + +import { ScheduledClosingDate } from "./ScheduledClosingDate"; +import { dateHasPast } from "@lib/utils"; +import { useFeatureFlags } from "@lib/hooks/useFeatureFlags"; export const SetClosingDate = ({ formId, @@ -43,16 +48,54 @@ export const SetClosingDate = ({ return true; }, [closedMessage]); - const [status, setStatus] = useState(closingDate ? "closed" : "open"); + const [status, setStatus] = useState( + dateHasPast(Date.parse(closingDate || "")) ? "closed" : "open" + ); + + // Needed to sync the status with the closing date + useEffect(() => { + setStatus(dateHasPast(Date.parse(closingDate || "")) ? "closed" : "open"); + }, [closingDate]); + + const [showDateTimeDialog, setShowDateTimeDialog] = useState(false); const handleToggle = (value: boolean) => { setStatus(value == true ? "closed" : "open"); }; + // Called from the date scheduling modal + const saveFutureDate = useCallback( + async (futureDate?: number) => { + if (!futureDate) { + return; + } + + const closingDate = new Date(futureDate).toISOString(); + + const result = await closeForm({ + id: formId, + closingDate, + closedDetails: closedMessage, + }); + + if (!result || result.error) { + toast.error(t("closingDate.savedErrorMessage")); + return; + } + + // Update the local template store + setClosingDate(closingDate); + + toast.success(t("closingDate.savedSuccessMessage")); + }, + [formId, setClosingDate, t, closedMessage] + ); + const saveFormStatus = useCallback(async () => { - let closeDate = "open"; + let closeDate = closingDate ? closingDate : null; if (status === "closed") { + // Set date to now to close the form right away const now = new Date(); closeDate = now.toISOString(); } @@ -68,15 +111,18 @@ export const SetClosingDate = ({ return; } - // update the local store - setClosingDate(status !== "open" ? closeDate : null); + // Setting local store + setClosingDate(closeDate); if (status === "closed") { toast.success(, "wide"); } else { toast.success(t("closingDate.savedSuccessMessage")); } - }, [status, formId, setClosingDate, t, closedMessage]); + }, [status, formId, setClosingDate, t, closedMessage, closingDate]); + + const { getFlag } = useFeatureFlags(); + const hasScheduleClosingDate = getFlag("scheduleClosingDate"); return (
@@ -95,6 +141,21 @@ export const SetClosingDate = ({ description={t("closingDate.status")} />
+
+ {hasScheduleClosingDate && closingDate && ( + + )} + + {hasScheduleClosingDate && ( + + )} +
{t("closingDate.saveButton")} + {showDateTimeDialog && ( + + )}
); }; diff --git a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts index 3be80e8e03..3bbd170b1a 100644 --- a/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts +++ b/app/(gcforms)/[locale]/(form administration)/form-builder/actions.ts @@ -25,6 +25,7 @@ import { import { serverTranslation } from "@i18n"; import { revalidatePath } from "next/cache"; import { checkOne } from "@lib/cache/flags"; +import { isValidDateString } from "@lib/utils/date/isValidDateString"; export type CreateOrUpdateTemplateType = { id?: string; @@ -252,7 +253,7 @@ export const closeForm = async ({ closedDetails, }: { id: string; - closingDate: string; + closingDate: string | null; closedDetails?: ClosedDetails; }): Promise<{ formID: string; @@ -262,6 +263,14 @@ export const closeForm = async ({ try { const { ability } = await authCheckAndThrow(); + // closingDate: null means the form is open, or will be set to be open + // closingDate: (now/past date) means the form is closed + // closingDate: (future date) means the form is scheduled to close in the future + + if (closingDate && !isValidDateString(closingDate)) { + throw new Error(`Invalid closing date. Request information: { ${formID}, ${closingDate} }`); + } + const response = await updateClosedData(ability, formID, closingDate, closedDetails); if (!response) { throw new Error( diff --git a/app/(gcforms)/[locale]/layout.tsx b/app/(gcforms)/[locale]/layout.tsx index 08da81756b..ca9ac60f5f 100644 --- a/app/(gcforms)/[locale]/layout.tsx +++ b/app/(gcforms)/[locale]/layout.tsx @@ -9,6 +9,7 @@ export default async function Layout({ children }: { children: React.ReactNode } const featureFlags = await getSomeFlags([ FeatureFlags.addressComplete, FeatureFlags.repeatingSets, + FeatureFlags.scheduleClosingDate, ]); return ( <> diff --git a/components/clientComponents/forms/ClosingNotice/ClosingNotice.tsx b/components/clientComponents/forms/ClosingNotice/ClosingNotice.tsx index 29a3ab93b7..523cf8baf9 100644 --- a/components/clientComponents/forms/ClosingNotice/ClosingNotice.tsx +++ b/components/clientComponents/forms/ClosingNotice/ClosingNotice.tsx @@ -4,6 +4,7 @@ import { isFutureDate } from "lib/utils/date/isFutureDate"; import { useTranslation } from "@i18n/client"; import { formClosingDateEst } from "lib/utils/date/utcToEst"; import { logMessage } from "@lib/logger"; +import { useEffect, useState } from "react"; export const ClosingNotice = ({ closingDate, @@ -14,6 +15,14 @@ export const ClosingNotice = ({ }) => { const { t } = useTranslation("common"); + const [loading, setLoading] = useState(true); + + useEffect(() => setLoading(false), []); + + if (loading) { + return null; + } + if (!closingDate) { return null; } @@ -22,16 +31,16 @@ export const ClosingNotice = ({ return null; } - let month, day, year, hour, minute; + let month, day, year, hour, minute, dayPeriod; try { - ({ month, day, year, hour, minute } = formClosingDateEst(closingDate, language)); + ({ month, day, year, hour, minute, dayPeriod } = formClosingDateEst(closingDate, language)); } catch (error) { logMessage.info("Unable to parse closing date", closingDate); return null; } - if (!month || !day || !year || !hour || !minute) { + if (!month || !day || !year || !hour || !minute || !dayPeriod) { return null; } @@ -47,6 +56,7 @@ export const ClosingNotice = ({ year, hour, minute, + dayPeriod, })}

diff --git a/flag_initialization/default_flag_settings.json b/flag_initialization/default_flag_settings.json index a7aa90b742..daf7a2488e 100644 --- a/flag_initialization/default_flag_settings.json +++ b/flag_initialization/default_flag_settings.json @@ -1,4 +1,5 @@ { "repeatingSets": false, - "addressComplete": false + "addressComplete": false, + "scheduleClosingDate": false } diff --git a/i18n/translations/en/admin-flags.json b/i18n/translations/en/admin-flags.json index 2575a02aa7..449411bcea 100644 --- a/i18n/translations/en/admin-flags.json +++ b/i18n/translations/en/admin-flags.json @@ -21,6 +21,10 @@ "addressComplete": { "title": "Address Complete", "description": "Enable CanadaPost address completion for forms" + }, + "scheduleClosingDate": { + "title": "Schedule Closing Date", + "description": "Enable scheduling a form closing date" } } } diff --git a/i18n/translations/en/common.json b/i18n/translations/en/common.json index 879483bfd1..99652c0ac0 100644 --- a/i18n/translations/en/common.json +++ b/i18n/translations/en/common.json @@ -199,7 +199,7 @@ "closingNotice": { "title": "Form closing", "text1": "Form accepting submissions until:", - "text2": "{{month}} {{day}}, {{year}} at {{hour}}:{{minute}} ET." + "text2": "{{month}} {{day}}, {{year}} at {{hour}}:{{minute}} {{dayPeriod}} ET." }, "cancel": "Cancel" } diff --git a/i18n/translations/en/form-builder.json b/i18n/translations/en/form-builder.json index 6e2c075aa0..0eb7001ac7 100644 --- a/i18n/translations/en/form-builder.json +++ b/i18n/translations/en/form-builder.json @@ -944,7 +944,7 @@ } }, "banner": { - "text": "Form closed on {{month}} {{day}}, {{year}} at {{hour}}:{{minute}} ET" + "text": "Form closed on {{month}} {{day}}, {{year}} at {{hour}}:{{minute}} {{dayPeriod}} ET" } }, "errorSavingForm": { @@ -1181,5 +1181,27 @@ "description": "Add a relevant action that is specific for the people filling out the form. For example, “Remove address”" } } + }, + "scheduleClosingPage": { + "linkText": "Schedule form closing", + "dialog": { + "title": "Schedule the closed form message", + "text1": "Schedule a closing date", + "text2": "Set when you’d like to close the form.", + "datePicker": { + "month": "Month", + "day": "Day", + "year": "Year" + }, + "timePicker": { + "text1": "Set a time", + "text2": "Provide the time using the 24-hour clock with timezone in Eastern Time" + }, + "error": { + "notFutureDate": "Date and time must be upcoming, in the future." + }, + "save": "Save", + "cancel": "Cancel" + } } } diff --git a/i18n/translations/fr/admin-flags.json b/i18n/translations/fr/admin-flags.json index 7caa253414..a60e7f5ef8 100644 --- a/i18n/translations/fr/admin-flags.json +++ b/i18n/translations/fr/admin-flags.json @@ -21,6 +21,10 @@ "addressComplete": { "title": "Adresse complète", "description": "Activer la complétion d'adresse CanadaPost pour les formulaires" + }, + "scheduleClosingDate": { + "title": "Date de clôture du calendrier", + "description": "Activer la planification d'une date de clôture de formulaire" } } } diff --git a/i18n/translations/fr/common.json b/i18n/translations/fr/common.json index c408a6c7eb..4a7cd15a0c 100644 --- a/i18n/translations/fr/common.json +++ b/i18n/translations/fr/common.json @@ -199,7 +199,7 @@ "closingNotice": { "title": "Fermeture du formulaire", "text1": "Les soumissions sont acceptées jusqu'au :", - "text2": "{{day}} {{month}} {{year}} à {{hour}} h {{minute}} HE" + "text2": "{{day}} {{month}} {{year}} à {{hour}} h {{minute}} {{dayPeriod}} HE" }, "cancel": "Annuler" } diff --git a/i18n/translations/fr/form-builder.json b/i18n/translations/fr/form-builder.json index 3e75fc8ded..8b1cb94ad1 100644 --- a/i18n/translations/fr/form-builder.json +++ b/i18n/translations/fr/form-builder.json @@ -944,7 +944,7 @@ } }, "banner": { - "text": "Le formulaire a été fermé le {{day}} {{month}} {{year}} à {{hour}} h {{minute}} HE" + "text": "Le formulaire a été fermé le {{day}} {{month}} {{year}} à {{hour}} h {{minute}} {{dayPeriod}} HE" } }, "errorSavingForm": { @@ -1181,5 +1181,27 @@ "description": "Ajoutez une action pertinente spécifique pour aider les personnes à remplir le formulaire. Par exemple : « Supprimer l'adresse »" } } + }, + "scheduleClosingPage": { + "linkText": "Programmer la fermeture du formulaire", + "dialog": { + "title": "Programmez le message de fermeture du formulaire", + "text1": "Fixez la date de fermeture", + "text2": "Décidez la date à laquelle vous souhaitez fermer le formulaire", + "datePicker": { + "month": "Mois", + "day": "Jour", + "year": "Année" + }, + "timePicker": { + "text1": "Fixez l'heure de fermeture", + "text2": "Indiquez l'heure en utilisant l'horloge de 24 heures avec le fuseau horaire de l'heure de l'Est" + }, + "error": { + "notFutureDate": "[FR]Date and time must be upcoming, in the future." + }, + "save": "Enregistrer", + "cancel": "Annuler" + } } } diff --git a/lib/cache/types.ts b/lib/cache/types.ts index a77f8ac119..c7f944fedb 100644 --- a/lib/cache/types.ts +++ b/lib/cache/types.ts @@ -2,6 +2,7 @@ export const FeatureFlags = { addressComplete: "addressComplete", repeatingSets: "repeatingSets", + scheduleClosingDate: "scheduleClosingDate", } as const; export type FeatureFlagKeys = keyof typeof FeatureFlags; diff --git a/lib/templates.ts b/lib/templates.ts index 468bee2170..4f44d1ccc2 100644 --- a/lib/templates.ts +++ b/lib/templates.ts @@ -1493,15 +1493,9 @@ export const onlyIncludePublicProperties = (template: FormRecord): PublicFormRec export const updateClosedData = async ( ability: UserAbility, formID: string, - closingDate: string, + closingDate: string | null, details?: ClosedDetails ) => { - let d = null; - - if (closingDate !== "open") { - d = closingDate; - } - let detailsData: ClosedDetails | null = null; // Add the closed details if they exist @@ -1518,7 +1512,7 @@ export const updateClosedData = async ( id: formID, }, data: { - closingDate: d, + closingDate, closedDetails: detailsData !== null ? (detailsData as Prisma.JsonObject) : Prisma.JsonNull, }, @@ -1539,7 +1533,7 @@ export const updateClosedData = async ( ); throw e; } - return { formID, closingDate: d }; + return { formID, closingDate }; }; export const updateSecurityAttribute = async ( diff --git a/lib/utils/date/isValidDateString.ts b/lib/utils/date/isValidDateString.ts new file mode 100644 index 0000000000..a47a869bca --- /dev/null +++ b/lib/utils/date/isValidDateString.ts @@ -0,0 +1,4 @@ +export const isValidDateString = (dateString: string) => { + const date = new Date(dateString); + return !isNaN(date.getTime()); +}; diff --git a/lib/utils/date/isValidDateString.vitest.ts b/lib/utils/date/isValidDateString.vitest.ts new file mode 100644 index 0000000000..8a7b2c993e --- /dev/null +++ b/lib/utils/date/isValidDateString.vitest.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { isValidDateString } from "./isValidDateString"; + +describe("isValidDateString", () => { + it("should return true for a valid date", () => { + const date = "2023-10-01T12:00:00Z"; + const result = isValidDateString(date); + expect(result).toBe(true); + }); + + it("should return true for a valid date", () => { + const date = "2023-10-10"; + const result = isValidDateString(date); + expect(result).toBe(true); + }); + + it("should return false for an invalid date", () => { + const date = undefined; + // @ts-expect-error - testing invalid input + const result = isValidDateString(date); + expect(result).toBe(false); + }); + + it("should return false for an invalid date", () => { + const date = ""; + const result = isValidDateString(date); + expect(result).toBe(false); + }); + + it("should return false for an invalid date", () => { + const date = "invalid-date"; + const result = isValidDateString(date); + expect(result).toBe(false); + }); + + it("should return false for an invalid date", () => { + const date = "11-11-2011 invalid"; + const result = isValidDateString(date); + expect(result).toBe(false); + }); +}); diff --git a/lib/utils/date/utcToEst.ts b/lib/utils/date/utcToEst.ts index 044160ef5b..a66f444670 100644 --- a/lib/utils/date/utcToEst.ts +++ b/lib/utils/date/utcToEst.ts @@ -8,7 +8,8 @@ export const formClosingDateEst = (utcDate: string, lang: string = "en") => { day: "2-digit", hour: "2-digit", minute: "2-digit", - hour12: false, + hour12: true, + hourCycle: "h12", }; const locale = lang === "fr" ? "fr-CA" : "en-CA"; @@ -25,6 +26,7 @@ export const formClosingDateEst = (utcDate: string, lang: string = "en") => { const year = parts.find((part) => part.type === "year")?.value; const hour = parts.find((part) => part.type === "hour")?.value; const minute = parts.find((part) => part.type === "minute")?.value; + const dayPeriod = parts.find((part) => part.type === "dayPeriod")?.value; return { month, @@ -32,5 +34,6 @@ export const formClosingDateEst = (utcDate: string, lang: string = "en") => { year, hour, minute, + dayPeriod, }; }; diff --git a/lib/utils/date/utcToEst.vitest.ts b/lib/utils/date/utcToEst.vitest.ts index a134658cc2..088750e814 100644 --- a/lib/utils/date/utcToEst.vitest.ts +++ b/lib/utils/date/utcToEst.vitest.ts @@ -12,6 +12,7 @@ describe('utcToEst', () => { "minute": "00", "month": "October", "year": "2023", + "dayPeriod": "a.m.", }); }); @@ -25,6 +26,7 @@ describe('utcToEst', () => { "minute": "00", "month": "octobre", "year": "2023", + "dayPeriod": "a.m.", }); });