Skip to content

Commit

Permalink
fix: validate before sending application
Browse files Browse the repository at this point in the history
- use schemas to validate all pages before sending and display an error
  with a link to the page.
- use schemas to validate the exact step (funnel page) user has access
- fix: individual address broken to form conversion
  • Loading branch information
joonatank committed Feb 18, 2025
1 parent 32a2b26 commit a65652d
Show file tree
Hide file tree
Showing 15 changed files with 265 additions and 160 deletions.
102 changes: 39 additions & 63 deletions apps/ui/components/application/ApplicationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import React from "react";
import { useTranslation } from "next-i18next";
import styled from "styled-components";
import { breakpoints } from "common/src/common/style";
import {
ApplicantTypeChoice,
ApplicationFormFragment,
ApplicationStatusChoice,
} from "@gql/gql-types";
import { type ApplicationFormFragment } from "@gql/gql-types";
import { useRouter } from "next/router";
import NotesWhenApplying from "@/components/application/NotesWhenApplying";
import { applicationsPrefix, getApplicationPath } from "@/modules/urls";
import { Breadcrumb } from "../common/Breadcrumb";
import { fontBold, H1 } from "common";
import { Stepper as HDSStepper, StepState } from "hds-react";
import { validateApplication } from "./form";

const InnerContainer = styled.div`
display: grid;
Expand Down Expand Up @@ -42,78 +39,59 @@ const StyledStepper = styled(HDSStepper)`
}
`;

// TODO this should have more complete checks (but we are thinking of splitting the form anyway)
function calculateCompletedStep(aes: Node): 0 | 1 | 2 | 3 | 4 {
const { status } = aes;
// 4 should only be returned if the application state === Received
if (status === ApplicationStatusChoice.Received) {
return 4;
}
function calculateCompletedStep(
application: ApplicationFormFragment
): -1 | 0 | 1 | 2 | 3 {
const isValid = validateApplication(application);

// 3 if the user information is filled
if (
(aes.billingAddress?.streetAddressFi &&
aes.applicantType === ApplicantTypeChoice.Individual) ||
aes.contactPerson != null
) {
if (isValid.valid) {
return 3;
}

// 2 only if application events have time schedules
if (
aes.applicationSections?.length &&
aes.applicationSections?.find((x) => x?.suitableTimeRanges) != null
) {
const { page } = isValid;
if (page === 1) {
return 0;
} else if (page === 2) {
return 1;
} else if (page === 3) {
return 2;
}
return -1;
}

// First page is valid
if (
aes.applicationSections?.[0]?.reservationUnitOptions?.length &&
aes.applicationSections?.[0]?.reservationsBeginDate &&
aes.applicationSections?.[0]?.reservationsEndDate &&
aes.applicationSections?.[0]?.name &&
aes.applicationSections?.[0]?.numPersons &&
aes.applicationSections?.[0]?.purpose
) {
return 1;
function getStepState(completedStep: number, step: number) {
if (step - 1 === completedStep) {
return StepState.available;
}
return 0;
if (completedStep >= step) {
return StepState.completed;
}
return StepState.disabled;
}

type Node = ApplicationFormFragment;
type ApplicationPageProps = {
application: Node;
application: ApplicationFormFragment;
translationKeyPrefix: string;
overrideText?: string;
children?: React.ReactNode;
headContent?: React.ReactNode;
};

const getStep = (slug: string) => {
switch (slug) {
case "page1":
return 0;
case "page2":
return 1;
case "page3":
return 2;
case "preview":
return 3;
default:
return 0;
// Ordered list of steps by page slug
export const PAGES_WITH_STEPPER = [
"page1",
"page2",
"page3",
"preview",
] as const;

function getStep(slug: string) {
const index = PAGES_WITH_STEPPER.findIndex((x) => x === slug);
if (index === -1) {
return 0;
}
};

const getStepState = (completedStep: number, step: number) => {
if (completedStep === step) {
return StepState.completed;
}
if (completedStep > step) {
return StepState.completed;
}
return StepState.disabled;
};
return index;
}

export function ApplicationPageWrapper({
application,
Expand All @@ -126,12 +104,10 @@ export function ApplicationPageWrapper({
const router = useRouter();
const { asPath, push } = router;

const pages = ["page1", "page2", "page3", "preview"] as const;

const hideStepper =
pages.filter((x) => router.asPath.match(`/${x}`)).length === 0;
PAGES_WITH_STEPPER.filter((x) => router.asPath.match(`/${x}`)).length === 0;
const completedStep = calculateCompletedStep(application);
const steps = pages.map((x, i) => ({
const steps = PAGES_WITH_STEPPER.map((x, i) => ({
label: t(`application:navigation.${x}`),
state: getStepState(completedStep, i),
}));
Expand Down
51 changes: 21 additions & 30 deletions apps/ui/components/application/ViewApplication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
ApplicationSection,
ApplicationSectionHeader,
StyledNotification,
TermsAccordion as Accordion,
} from "./styled";
import { ApplicationEventList } from "./ApplicationEventList";
import { Sanitize } from "common/src/components/Sanitize";
Expand Down Expand Up @@ -53,38 +52,30 @@ export function ViewApplication({
<ApplicationEventList application={application} />
</div>
{tos && (
<Accordion
<TermsBox
id="preview.acceptTermsOfUse"
heading={t("reservationUnit:termsOfUse")}
open
>
<TermsBox
id="preview.acceptTermsOfUse"
body={<Sanitize html={getTranslationSafe(tos, "text", lang)} />}
acceptLabel={t("application:preview.userAcceptsGeneralTerms")}
accepted={isTermsAccepted?.general}
setAccepted={
setIsTermsAccepted
? (val) => setIsTermsAccepted("general", val)
: undefined
}
/>
</Accordion>
body={<Sanitize html={getTranslationSafe(tos, "text", lang)} />}
acceptLabel={t("application:preview.userAcceptsGeneralTerms")}
accepted={isTermsAccepted?.general}
setAccepted={
setIsTermsAccepted
? (val) => setIsTermsAccepted("general", val)
: undefined
}
/>
)}
{tos2 && (
<Accordion heading={t("application:preview.reservationUnitTerms")} open>
<TermsBox
id="preview.acceptServiceSpecificTerms"
body={<Sanitize html={getTranslationSafe(tos2, "text", lang)} />}
acceptLabel={t("application:preview.userAcceptsSpecificTerms")}
accepted={isTermsAccepted?.specific}
setAccepted={
setIsTermsAccepted
? (val) => setIsTermsAccepted("specific", val)
: undefined
}
/>
</Accordion>
<TermsBox
id="preview.acceptServiceSpecificTerms"
body={<Sanitize html={getTranslationSafe(tos2, "text", lang)} />}
acceptLabel={t("application:preview.userAcceptsSpecificTerms")}
accepted={isTermsAccepted?.specific}
setAccepted={
setIsTermsAccepted
? (val) => setIsTermsAccepted("specific", val)
: undefined
}
/>
)}
{shouldShowNotification && (
<div>
Expand Down
37 changes: 31 additions & 6 deletions apps/ui/components/application/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
Priority,
type UpdateApplicationSectionForApplicationSerializerInput,
type ApplicantFragment,
type ApplicationPage1Query,
type ApplicationPage2Query,
ApplicationFormFragment,
} from "@gql/gql-types";
import { type Maybe } from "graphql/jsutils/Maybe";
import { z } from "zod";
Expand All @@ -21,11 +21,11 @@ import {
} from "common/src/schemas/schemaCommon";
import { convertWeekday } from "common/src/conversion";

// TODO fragment
type Node = NonNullable<ApplicationPage1Query["application"]>;
type Organisation = Node["organisation"];
type Organisation = ApplicationFormFragment["organisation"];
type Address = NonNullable<Organisation>["address"];
type SectionType = NonNullable<Node["applicationSections"]>[0];
type SectionType = NonNullable<
ApplicationFormFragment["applicationSections"]
>[0];

type NodePage2 = NonNullable<ApplicationPage2Query["application"]>;
type SectionTypePage2 = NonNullable<NodePage2["applicationSections"]>[0];
Expand Down Expand Up @@ -594,7 +594,7 @@ export function convertApplicationPage2(
};
}
export function convertApplicationPage1(
app: Node,
app: ApplicationFormFragment,
// We pass reservationUnits here so we have a default selection for a new application section
reservationUnits: number[]
): ApplicationPage1FormValues {
Expand Down Expand Up @@ -721,3 +721,28 @@ export function transformPage3Application(
: {}),
};
}

export function validateApplication(
application: ApplicationFormFragment
): { valid: true } | { valid: false; page: 1 | 2 | 3 } {
const { applicationRound } = application;
const begin = new Date(applicationRound.reservationPeriodBegin);
const end = new Date(applicationRound.reservationPeriodEnd);
const schema = ApplicationPage1SchemaRefined({ begin, end });
const page1 = schema.safeParse(convertApplicationPage1(application, []));
if (!page1.success) {
return { valid: false, page: 1 };
}
const form2 = convertApplicationPage2(application);
const page2 = ApplicationPage2Schema.safeParse(form2);
if (!page2.success) {
return { valid: false, page: 2 };
}
const page3 = ApplicationPage3Schema.safeParse(
convertApplicationPage3(application)
);
if (!page3.success) {
return { valid: false, page: 3 };
}
return { valid: true };
}
9 changes: 0 additions & 9 deletions apps/ui/components/application/styled.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import styled from "styled-components";
import { Notification } from "hds-react";
import { breakpoints, fontMedium, fontRegular } from "common";
import { AccordionWithState } from "@/components/Accordion";
import { H5 } from "common/src/common/typography";
import { Flex, FullRow } from "common/styles/util";

Expand Down Expand Up @@ -159,14 +158,6 @@ export const ScheduleDay = styled.div`
}
`;

export const TermsAccordion = styled(AccordionWithState)`
margin-bottom: 0;
--accordion-border-color: var(--color-black-90);
[class^="Button-module_label"] div {
font-size: var(--fontsize-heading-s);
}
`;

export const SpanFullRow = styled(FullRow).attrs({ as: "span" })``;

export const FormSubHeading = styled(H5).attrs({ as: "h2" })`
Expand Down
Loading

0 comments on commit a65652d

Please sign in to comment.