-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: schedule form closing date (#4433)
* Initial commit - adds dialog * add strings * add link text * use strings * add padding * styles * Adds some style to the time input * Adds the dates html * Wires up inputs * Adds start on date validation * Updates comments with todos * add data attr * add use effect * add scheduled * use new callback for future dates * fix comment * Updates closingDate to use null as closed or date as open * Adds date check to status update * Fixes a few hydration errors * Adds basic client validation to date dialog * Adds server validation for closing date * Adds start on handling past dates * Fixes closeForm date check * Fixes setting a closed form to open using the toggle * Update i18n/translations/en/form-builder.json Co-authored-by: Anik Brazeau <[email protected]> * Update i18n/translations/en/form-builder.json Co-authored-by: Anik Brazeau <[email protected]> * Update i18n/translations/fr/form-builder.json Co-authored-by: Anik Brazeau <[email protected]> * Adds a workaround for the toggle bug * Adds error message for dates in the past * try am/pm formatting * Updates close dialog to use a month dropdown * Updates dialog to set the date order by language * Updates to have feature behind a feature flag * Updates dialog to pre-populate date info * Fixes a bug related to months * Removes some unnecessary types * Fixes a unit test * Updates a few comments * Fixes a bug * wrap flag * cleanup * add note * cleanup --------- Co-authored-by: Tim Arney <[email protected]> Co-authored-by: Tim Arney <[email protected]> Co-authored-by: Anik Brazeau <[email protected]>
- Loading branch information
1 parent
5c5ec15
commit cd724b6
Showing
20 changed files
with
517 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
245 changes: 245 additions & 0 deletions
245
...cale]/(form administration)/form-builder/[id]/settings/manage/close/ClosingDateDialog.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<React.SetStateAction<boolean>>; | ||
save: (futureDate?: number) => Promise<void>; | ||
closingDate: string | null | undefined; | ||
}) => { | ||
const { | ||
t, | ||
i18n: { language }, | ||
} = useTranslation("form-builder"); | ||
const dialogRef = useDialogRef(); | ||
const [hasErrors, setHasErrors] = useState(false); | ||
|
||
const [month, setMonth] = useState<string>(""); | ||
const [day, setDay] = useState<string>(""); | ||
const [year, setYear] = useState<string>(""); | ||
const [time, setTime] = useState<string>(""); | ||
|
||
// 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 ( | ||
<Dialog | ||
title={t("scheduleClosingPage.dialog.title")} | ||
dialogRef={dialogRef} | ||
handleClose={handleClose} | ||
className="max-w-[800px]" | ||
> | ||
<form | ||
onSubmit={(e) => { | ||
e.preventDefault(); | ||
handleSave(); | ||
}} | ||
> | ||
<div className="p-4"> | ||
<p className="mb-2 font-bold">{t("scheduleClosingPage.dialog.text1")}</p> | ||
<div> | ||
<div className="mb-4"> | ||
<fieldset role="group" aria-label="Date picker"> | ||
<legend className="mb-4">{t("scheduleClosingPage.dialog.text2")}</legend> | ||
<div role="alert"> | ||
{hasErrors && ( | ||
<div className="mb-4 text-red-700 flex align-middle"> | ||
<WarningIcon className="fill-red-800 mr-2" /> | ||
{t("scheduleClosingPage.dialog.error.notFutureDate")} | ||
</div> | ||
)} | ||
</div> | ||
<div className="inline-flex gap-2"> | ||
{language === "en" && ( | ||
<MonthDropdown | ||
month={month} | ||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => | ||
setMonth(e.target.value) | ||
} | ||
/> | ||
)} | ||
|
||
<div className="gcds-input-wrapper !mr-2 flex flex-col"> | ||
<label className="!mr-2 mb-2" htmlFor="date-picker-day"> | ||
{t("scheduleClosingPage.dialog.datePicker.day")} | ||
</label> | ||
<input | ||
className={"!w-16 !mr-2"} | ||
name="date-picker-day" | ||
id="date-picker-day" | ||
type="number" | ||
min={1} | ||
max={month && year ? getMaxMonthDay(Number(month), Number(year)) : 31} | ||
onChange={(e) => setDay(e.target.value)} | ||
value={day} | ||
required | ||
data-testid="date-picker-day" | ||
/> | ||
</div> | ||
|
||
{language === "fr" && ( | ||
<MonthDropdown | ||
month={month} | ||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => | ||
setMonth(e.target.value) | ||
} | ||
/> | ||
)} | ||
|
||
<div className="gcds-input-wrapper !mr-2 !flex !flex-col"> | ||
<label className="mb-2" htmlFor="date-picker-year"> | ||
{t("scheduleClosingPage.dialog.datePicker.year")} | ||
</label> | ||
<input | ||
className={"!w-28"} | ||
name="date-picker-year" | ||
id="date-picker-year" | ||
type="number" | ||
min={new Date().getFullYear()} | ||
onChange={(e) => setYear(e.target.value)} | ||
value={year} | ||
required | ||
data-testid="date-picker-year" | ||
/> | ||
</div> | ||
</div> | ||
</fieldset> | ||
</div> | ||
|
||
<div className="gcds-input-wrapper !mr-2 !flex !flex-col"> | ||
<label htmlFor="time-picker" className="mb-2 font-bold"> | ||
{t("scheduleClosingPage.dialog.timePicker.text1")} | ||
</label> | ||
<p id="time-picker-description" className="mb-4"> | ||
{t("scheduleClosingPage.dialog.timePicker.text2")} | ||
</p> | ||
<input | ||
className="!w-20" | ||
id="time-picker" | ||
name="time-picker" | ||
role="time" | ||
aria-describedby="time-picker-description" | ||
minLength={5} | ||
maxLength={5} | ||
onChange={(e) => setTime(e.target.value)} | ||
value={time} | ||
placeholder="00:00" | ||
required | ||
pattern="^(?:[01][0-9]|2[0-3]):[0-5][0-9]$" | ||
/> | ||
</div> | ||
</div> | ||
|
||
<div className="mt-8 flex gap-4"> | ||
<Button theme="secondary" onClick={handleClose}> | ||
{t("scheduleClosingPage.dialog.cancel")} | ||
</Button> | ||
<Button theme="primary" type="submit"> | ||
{t("scheduleClosingPage.dialog.save")} | ||
</Button> | ||
</div> | ||
</div> | ||
</form> | ||
</Dialog> | ||
); | ||
}; | ||
|
||
const MonthDropdown = ({ | ||
month, | ||
onChange, | ||
}: { | ||
month?: string; | ||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void; | ||
}) => { | ||
const { t } = useTranslation("form-builder"); | ||
return ( | ||
<div className="gcds-select-wrapper !mr-2 flex flex-col"> | ||
<label className="mb-2" htmlFor="date-picker-month"> | ||
{t("scheduleClosingPage.dialog.datePicker.month")} | ||
</label> | ||
<select | ||
name="date-picker-month" | ||
id="date-picker-month" | ||
className={"gc-dropdown"} | ||
onChange={onChange} | ||
value={month ? month : ""} | ||
required | ||
data-testid="date-picker-month" | ||
> | ||
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => ( | ||
<option key={month} value={month}> | ||
{t(`formattedDate.months.${month}`)} | ||
</option> | ||
))} | ||
</select> | ||
</div> | ||
); | ||
}; |
58 changes: 58 additions & 0 deletions
58
...e]/(form administration)/form-builder/[id]/settings/manage/close/ScheduledClosingDate.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div className="mb-4"> | ||
{t("closingNotice.text1")}{" "} | ||
{t("closingNotice.text2", { | ||
month, | ||
day, | ||
year, | ||
hour, | ||
minute, | ||
dayPeriod, | ||
})} | ||
</div> | ||
); | ||
}; |
Oops, something went wrong.