From d54fcd782d9c06d307070e642b6b1e6171871ee9 Mon Sep 17 00:00:00 2001 From: Joonatan Kuosa Date: Fri, 31 Jan 2025 11:39:39 +0200 Subject: [PATCH] refactor: use form context instead of prop drilling --- apps/ui/components/application/Page2.tsx | 335 +----------------- .../ui/components/application/TimePreview.tsx | 38 +- .../components/application/TimeSelector.tsx | 106 ++++-- apps/ui/components/application/application.ts | 232 ++++++++++++ 4 files changed, 340 insertions(+), 371 deletions(-) create mode 100644 apps/ui/components/application/application.ts diff --git a/apps/ui/components/application/Page2.tsx b/apps/ui/components/application/Page2.tsx index f30b73869..f38969b3c 100644 --- a/apps/ui/components/application/Page2.tsx +++ b/apps/ui/components/application/Page2.tsx @@ -10,34 +10,26 @@ import { useTranslation } from "next-i18next"; import { useRouter } from "next/router"; import styled from "styled-components"; import { useFormContext } from "react-hook-form"; -import { - Priority, - type ApplicationQuery, - type ApplicationRoundTimeSlotNode, -} from "@gql/gql-types"; +import { type ApplicationQuery } from "@gql/gql-types"; import { filterNonNullable } from "common/src/helpers"; import type { ApplicationSectionFormValue, - ApplicationEventScheduleFormType, ApplicationFormValues, - SuitableTimeRangeFormValues, } from "./Form"; import { convertLanguageCode, getTranslationSafe, } from "common/src/common/util"; -import { - convertWeekday, - transformWeekday, - type Day, -} from "common/src/conversion"; import { getReadableList } from "@/modules/util"; import { AccordionWithState as Accordion } from "@/components/Accordion"; +import { TimeSelector } from "./TimeSelector"; import { - type ApplicationEventSchedulePriority, - TimeSelector, -} from "./TimeSelector"; -import { errorToast, successToast } from "common/src/common/toast"; + aesToCells, + Cell, + cellsToApplicationEventSchedules, + convertToSchedule, +} from "./application"; +import { errorToast } from "common/src/common/toast"; import { ButtonContainer } from "common/styles/util"; import { getApplicationPath } from "@/modules/urls"; @@ -47,188 +39,16 @@ type Props = { onNext: (appToSave: ApplicationFormValues) => void; }; -type OpeningHourPeriod = { - begin: string; - end: string; -} | null; - -type DailyOpeningHours = Pick< - ApplicationRoundTimeSlotNode, - "weekday" | "closed" | "reservableTimes" ->[]; - const StyledNotification = styled(Notification)` margin-top: var(--spacing-m); `; -function cellLabel(row: number): string { - return `${row} - ${row + 1}`; -} - function getListOfApplicationEventTitles( applicationSections: ApplicationSectionFormValue[], ids: number[] ): string { return getReadableList(ids.map((id) => `"${applicationSections[id].name}"`)); } - -function getOpeningHours( - day: number, - openingHours?: DailyOpeningHours -): OpeningHourPeriod[] | null { - if (!openingHours) { - return null; - } - const dayOpeningHours = openingHours.find((oh) => oh.weekday === day); - if (!dayOpeningHours) { - return null; - } - if (dayOpeningHours.closed) { - return null; - } - return dayOpeningHours.reservableTimes ?? null; -} - -function aesToCells( - schedule: ApplicationEventScheduleFormType[], - openingHours?: DailyOpeningHours -): Cell[][] { - const firstSlotStart = 7; - const lastSlotStart = 23; - - const cells: Cell[][] = []; - - for (let j = 0; j < 7; j += 1) { - const day: Cell[] = []; - const openingHoursForADay = getOpeningHours(j, openingHours); - const dayOpeningHours = filterNonNullable(openingHoursForADay).map((t) => ({ - begin: t && +t.begin.split(":")[0], - end: t && +t.end.split(":")[0] === 0 ? 24 : t && +t.end.split(":")[0], - })); - // state is 50 if the cell is outside the opening hours, 100 if it's inside - for (let i = firstSlotStart; i <= lastSlotStart; i += 1) { - const isAvailable = dayOpeningHours.some( - (t) => t.begin != null && t.end != null && t?.begin <= i && t?.end > i - ); - day.push({ - key: `${i}-${j}`, - hour: i, - label: cellLabel(i), - state: isAvailable ? 100 : 50, - }); - } - cells.push(day); - } - - for (const aes of schedule) { - const { day, priority } = aes; - const hourBegin = Number(aes.begin.substring(0, 2)) - firstSlotStart; - const hourEnd = (Number(aes.end.substring(0, 2)) || 24) - firstSlotStart; - for (let h = hourBegin; h < hourEnd; h += 1) { - const cell = cells[day][h]; - if (cell) { - cell.state = convertPriorityToState(priority); - } - } - } - - return cells; -} - -function convertPriorityToState( - priority: number -): ApplicationEventSchedulePriority { - switch (priority) { - case 300: - return 300; - case 200: - return 200; - default: - return 100; - } -} - -/// unsafe -function formatNumber(n: number): string { - if (n < 0) { - throw new Error("Negative number"); - } - if (n > 23) { - return "00"; - } - if (n < 10) { - return `0${n}`; - } - return `${n}`; -} - -type Timespan = { - begin: number; - end: number; - priority: ApplicationEventSchedulePriority; -}; - -type Cell = { - hour: number; - label: string; - state: ApplicationEventSchedulePriority; - key: string; -}; - -type ApplicationEventScheduleType = { - day: Day; - begin: string; - end: string; - priority: number; -}; - -function cellsToApplicationEventSchedules( - cells: Cell[][] -): ApplicationEventScheduleType[] { - const daySchedules: ApplicationEventScheduleType[] = []; - if (cells.length > 7) { - throw new Error("Too many days"); - } - const range = [0, 1, 2, 3, 4, 5, 6] as const; - for (const day of range) { - const dayCells = cells[day]; - const transformedDayCells = dayCells - .filter((cell) => cell.state) - .map((cell) => ({ - begin: cell.hour, - end: cell.hour + 1, - priority: cell.state, - })) - .reduce((prev, current) => { - if (!prev.length) { - return [current]; - } - if ( - prev[prev.length - 1].end === current.begin && - prev[prev.length - 1].priority === current.priority - ) { - return [ - ...prev.slice(0, prev.length - 1), - { - begin: prev[prev.length - 1].begin, - end: prev[prev.length - 1].end + 1, - priority: prev[prev.length - 1].priority, - }, - ]; - } - return [...prev, current]; - }, []) - .map((cell) => ({ - day, - begin: `${formatNumber(cell.begin)}:00`, - end: `${formatNumber(cell.end)}:00`, - priority: cell.priority, - })); - daySchedules.push(...transformedDayCells); - } - return daySchedules; -} - function getLongestChunks(selectorData: Cell[][][]): number[] { return selectorData.map((n) => { const primarySchedules = cellsToApplicationEventSchedules( @@ -262,57 +82,6 @@ function getApplicationEventsWhichMinDurationsIsNotFulfilled( ); } -function convertToSchedule( - b: NonNullable[0]> -): ApplicationEventScheduleFormType[] { - return ( - b.suitableTimeRanges?.map((range) => { - return { - day: range ? convertWeekday(range.dayOfTheWeek) : 0, - begin: range?.beginTime ?? "", - end: range?.endTime ?? "", - priority: range?.priority === Priority.Primary ? 300 : 200, - }; - }) ?? [] - ); -} - -function covertCellsToTimeRange( - cells: Cell[][][] -): SuitableTimeRangeFormValues[][] { - // So this returns them as: - // applicationSections (N) - // - ApplicationEventSchedule[][]: Array(7) (i is the day) - // - ApplicationEventSchedule[]: Array(M) (j is the continuous block) - // priority: 200 | 300 (200 is secondary, 300 is primary) - // priority: 100 (? assuming it's not selected) - const selectedAppEvents = cells - .map((cell) => cellsToApplicationEventSchedules(cell)) - .map((aes) => - aes.filter((ae) => ae.priority === 300 || ae.priority === 200) - ); - // this seems to work except - // TODO: day is incorrect (empty days at the start are missing, and 200 / 300 priority on the same day gets split into two days) - // TODO refactor the Cell -> ApplicationEventSchedule conversion to use FormTypes - return selectedAppEvents.map((appEventSchedule) => { - const val: SuitableTimeRangeFormValues[] = appEventSchedule.map( - (appEvent) => { - const { day } = appEvent; - return { - beginTime: appEvent.begin, - endTime: appEvent.end, - // The default will never happen (it's already filtered) - // TODO type this better - priority: - appEvent.priority === 300 ? Priority.Primary : Priority.Secondary, - dayOfTheWeek: transformWeekday(day), - }; - } - ); - return val; - }); -} - function Page2({ application, onNext }: Props): JSX.Element { const { t } = useTranslation(); @@ -359,7 +128,6 @@ function Page2({ application, onNext }: Props): JSX.Element { key={section.formKey} index={index} section={application?.applicationSections[index]} - enableCopyCells={applicationSections.length > 1} /> ) : null )} @@ -408,14 +176,11 @@ function Page2({ application, onNext }: Props): JSX.Element { function ApplicationSectionTimePicker({ index: sectionIndex, section, - enableCopyCells = true, }: { index: number; section: NonNullable[0]; - enableCopyCells?: boolean; }): JSX.Element { - const { setValue, getValues, watch } = - useFormContext(); + const { watch } = useFormContext(); const { t, i18n } = useTranslation(); const language = convertLanguageCode(i18n.language); @@ -432,83 +197,6 @@ function ApplicationSectionTimePicker({ allOpeningHours.find((n) => n.pk === selectedReservationUnitPk) ?.openingHours ?? []; - const setSelectorData = (selected: Cell[][][]) => { - const formVals = covertCellsToTimeRange(selected); - for (const i of formVals.keys()) { - setValue(`applicationSections.${i}.suitableTimeRanges`, formVals[i]); - } - }; - - const updateCells = (index: number, newCells: Cell[][]) => { - const applicationSections = filterNonNullable(watch("applicationSections")); - const selectorData = applicationSections.map((ae) => - aesToCells(convertToSchedule(ae), reservationUnitOpeningHours) - ); - const updated = [...selectorData]; - updated[index] = newCells; - setSelectorData(updated); - }; - - // TODO should remove the cell not set a priority - const resetCells = (index: number) => { - const applicationSections = filterNonNullable(watch("applicationSections")); - const selectorData = applicationSections.map((ae) => - aesToCells(convertToSchedule(ae), reservationUnitOpeningHours) - ); - - const updated = [...selectorData]; - updated[index] = selectorData[index].map((n) => - n.map((nn) => ({ ...nn, state: 100 })) - ); - setSelectorData(updated); - }; - - const copyCells = (index: number) => { - const applicationSections = filterNonNullable(watch("applicationSections")); - const selectorData = applicationSections.map((ae) => - aesToCells(convertToSchedule(ae), reservationUnitOpeningHours) - ); - - const updated = [...selectorData]; - const srcCells = updated[index]; - srcCells.forEach((day, i) => { - day.forEach((cell, j) => { - const { state } = cell; - for (let k = 0; k < updated.length; k += 1) { - if (k !== index) { - updated[k][i][j].state = state; - } - } - }); - }); - setSelectorData(updated); - successToast({ - text: t("application:Page2.notification.copyCells"), - dataTestId: "application__page2--notification-success", - }); - }; - - // NOTE there is something funny with this one on the first render - // (it's undefined and not Array as expected). - const schedules = - getValues(`applicationSections.${sectionIndex}.suitableTimeRanges`) ?? []; - const summaryDataPrimary = schedules - .filter((n) => n.priority === Priority.Primary) - .map((a) => ({ - begin: a.beginTime, - end: a.endTime, - priority: 300 as const, - day: convertWeekday(a.dayOfTheWeek), - })); - const summaryDataSecondary = schedules - .filter((n) => n.priority === Priority.Secondary) - .map((a) => ({ - begin: a.beginTime, - end: a.endTime, - priority: 200 as const, - day: convertWeekday(a.dayOfTheWeek), - })); - const reservationUnitOptions = filterNonNullable( section.reservationUnitOptions ) @@ -541,11 +229,8 @@ function ApplicationSectionTimePicker({ updateCells(sectionIndex, cells)} - copyCells={enableCopyCells ? () => copyCells(sectionIndex) : undefined} - resetCells={() => resetCells(sectionIndex)} - summaryData={[summaryDataPrimary, summaryDataSecondary]} reservationUnitOptions={reservationUnitOptions} + reservationUnitOpeningHours={reservationUnitOpeningHours} /> ); diff --git a/apps/ui/components/application/TimePreview.tsx b/apps/ui/components/application/TimePreview.tsx index a47fa1403..dc8095c78 100644 --- a/apps/ui/components/application/TimePreview.tsx +++ b/apps/ui/components/application/TimePreview.tsx @@ -5,12 +5,17 @@ import { breakpoints } from "common/src/common/style"; import { fontBold, H4 } from "common/src/common/typography"; import { fromMondayFirstUnsafe } from "common/src/helpers"; import { WEEKDAYS } from "common/src/const"; -import { ApplicationEventScheduleFormType } from "./Form"; +import { + ApplicationEventScheduleFormType, + ApplicationFormValues, +} from "./Form"; import { getDayTimes } from "@/modules/util"; +import { useFormContext } from "react-hook-form"; +import { Priority } from "@/gql/gql-types"; +import { convertWeekday } from "common/src/conversion"; type Props = { - primary: ApplicationEventScheduleFormType[]; - secondary: ApplicationEventScheduleFormType[]; + index: number; }; const WeekWrapper = styled.div` @@ -68,9 +73,30 @@ const Heading = styled(H4).attrs({ margin-top: 0; `; -const TimePreview = ({ primary, secondary }: Props): JSX.Element => { +export function TimePreview({ index }: Props): JSX.Element { const { t } = useTranslation(); + const { watch } = useFormContext(); + + const schedules = + watch(`applicationSections.${index}.suitableTimeRanges`) ?? []; + const primary = schedules + .filter((n) => n.priority === Priority.Primary) + .map((a) => ({ + begin: a.beginTime, + end: a.endTime, + priority: 300 as const, + day: convertWeekday(a.dayOfTheWeek), + })); + const secondary = schedules + .filter((n) => n.priority === Priority.Secondary) + .map((a) => ({ + begin: a.beginTime, + end: a.endTime, + priority: 200 as const, + day: convertWeekday(a.dayOfTheWeek), + })); + return (
@@ -83,6 +109,4 @@ const TimePreview = ({ primary, secondary }: Props): JSX.Element => {
); -}; - -export { TimePreview }; +} diff --git a/apps/ui/components/application/TimeSelector.tsx b/apps/ui/components/application/TimeSelector.tsx index 37c29bab6..988a5f5b2 100644 --- a/apps/ui/components/application/TimeSelector.tsx +++ b/apps/ui/components/application/TimeSelector.tsx @@ -3,39 +3,33 @@ import styled from "styled-components"; import { TFunction, useTranslation } from "next-i18next"; import { Button, ButtonVariant, IconCross } from "hds-react"; import { breakpoints } from "common/src/common/style"; -import { fromMondayFirstUnsafe } from "common/src/helpers"; +import { filterNonNullable, fromMondayFirstUnsafe } from "common/src/helpers"; import { WEEKDAYS } from "common/src/const"; import { arrowDown, arrowUp } from "@/styles/util"; import { TimePreview } from "./TimePreview"; -import { - type ApplicationFormValues, - type ApplicationEventScheduleFormType, -} from "./Form"; +import { type ApplicationFormValues } from "./Form"; import { useFormContext } from "react-hook-form"; import { ControlledSelect } from "common/src/components/form"; import { Flex, NoWrap } from "common/styles/util"; import { isTouchDevice } from "@/modules/util"; - -export type ApplicationEventSchedulePriority = 50 | 100 | 200 | 300; - -type Cell = { - hour: number; - label: string; - state: ApplicationEventSchedulePriority; - key: string; -}; +import { + aesToCells, + ApplicationEventSchedulePriority, + Cell, + convertToSchedule, + covertCellsToTimeRange, +} from "./application"; +import { successToast } from "common/src/common/toast"; +import { ApplicationQuery } from "@/gql/gql-types"; + +type ApplicationT = NonNullable; +type SectionT = NonNullable[0]; type Props = { index: number; cells: Cell[][]; - updateCells: (cells: Cell[][]) => void; - copyCells?: () => void; - resetCells: () => void; - summaryData: [ - ApplicationEventScheduleFormType[], - ApplicationEventScheduleFormType[], - ]; reservationUnitOptions: { label: string; value: number }[]; + reservationUnitOpeningHours: SectionT["reservationUnitOptions"][0]["reservationUnit"]["applicationRoundTimeSlots"]; }; const CalendarHead = styled.div` @@ -331,21 +325,11 @@ const CELL_TYPES = [ }, ] as const; -/// TODO what is the responsibility of this component? -/// Why does it take a bucket full of props? -/// Why is it used in only two different places? -/// TODO why does it require some Cell functions in the props? what are these? -/// TODO why is the summaryData type so weird? -/// TODO why is the summaryData coupled with the Selector? instead of passing JSX child element or a JSX component? -/// TODO why does summaryData include priority but is split by priority also? one of these is redundant export function TimeSelector({ cells, - updateCells, - copyCells, - resetCells, index, - summaryData, reservationUnitOptions, + reservationUnitOpeningHours, }: Props): JSX.Element | null { const { t } = useTranslation(); const [paintState, setPaintState] = useState< @@ -358,17 +342,59 @@ export function TimeSelector({ label: t(cell.label), })); - const { watch } = useFormContext(); + const { setValue, watch } = useFormContext(); const priority = watch(`applicationSections.${index}.priority`); - /* - const { setValue, getValues, watch } = useFormContext(); + const setSelectorData = (selected: Cell[][][]) => { + const formVals = covertCellsToTimeRange(selected); + for (const i of formVals.keys()) { + setValue(`applicationSections.${i}.suitableTimeRanges`, formVals[i]); + } + }; + const getSelectorData = (): Cell[][][] => { const applicationSections = filterNonNullable(watch("applicationSections")); const selectorData = applicationSections.map((ae) => aesToCells(convertToSchedule(ae), reservationUnitOpeningHours) ); - */ + return selectorData; + }; + + const updateCells = (newCells: Cell[][]) => { + const updated = [...getSelectorData()]; + updated[index] = newCells; + setSelectorData(updated); + }; + + // TODO should remove the cell not set a priority + const resetCells = () => { + const selectorData = [...getSelectorData()]; + const updated = [...selectorData]; + updated[index] = selectorData[index].map((n) => + n.map((nn) => ({ ...nn, state: 100 })) + ); + setSelectorData(updated); + }; + + const copyCells = () => { + const updated = [...getSelectorData()]; + const srcCells = updated[index]; + srcCells.forEach((day, i) => { + day.forEach((cell, j) => { + const { state } = cell; + for (let k = 0; k < updated.length; k += 1) { + if (k !== index) { + updated[k][i][j].state = state; + } + } + }); + }); + setSelectorData(updated); + successToast({ + text: t("application:Page2.notification.copyCells"), + dataTestId: "application__page2--notification-success", + }); + }; const setCellValue = ( selection: Cell, value: ApplicationEventSchedulePriority | false @@ -380,10 +406,12 @@ export function TimeSelector({ : h ), ]); - updateCells(newVal); }; + const enableCopyCells = + filterNonNullable(watch("applicationSections")).length > 1; + return ( <> - + - {copyCells && ( + {enableCopyCells && (