Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add: cancel all reservations in application section #1573

Open
wants to merge 1 commit into
base: add-canceling-seasonal-reservation
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/admin-ui/gql/gql-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ export type ApplicationSectionNode = Node & {
allocations?: Maybe<Scalars["Int"]["output"]>;
application: ApplicationNode;
appliedReservationsPerWeek: Scalars["Int"]["output"];
extUuid: Scalars["UUID"]["output"];
/** The ID of the object */
id: Scalars["ID"]["output"];
name: Scalars["String"]["output"];
Expand Down Expand Up @@ -1777,6 +1778,7 @@ export type Query = {
application?: Maybe<ApplicationNode>;
applicationRound?: Maybe<ApplicationRoundNode>;
applicationRounds?: Maybe<ApplicationRoundNodeConnection>;
applicationSection?: Maybe<ApplicationSectionNode>;
applicationSections?: Maybe<ApplicationSectionNodeConnection>;
applications?: Maybe<ApplicationNodeConnection>;
bannerNotification?: Maybe<BannerNotificationNode>;
Expand Down Expand Up @@ -1908,6 +1910,10 @@ export type QueryApplicationRoundsArgs = {
pk?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
};

export type QueryApplicationSectionArgs = {
id: Scalars["ID"]["input"];
};

export type QueryApplicationSectionsArgs = {
after?: InputMaybe<Scalars["String"]["input"]>;
ageGroup?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
Expand Down Expand Up @@ -3638,6 +3644,7 @@ export type ReservationUnitCreateMutationInput = {
reservationsMaxDaysBefore?: InputMaybe<Scalars["Int"]["input"]>;
reservationsMinDaysBefore?: InputMaybe<Scalars["Int"]["input"]>;
resources?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
searchTerms?: InputMaybe<Array<InputMaybe<Scalars["String"]["input"]>>>;
serviceSpecificTerms?: InputMaybe<Scalars["String"]["input"]>;
spaces?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
surfaceArea?: InputMaybe<Scalars["Int"]["input"]>;
Expand Down Expand Up @@ -3709,6 +3716,7 @@ export type ReservationUnitCreateMutationPayload = {
reservationsMaxDaysBefore?: Maybe<Scalars["Int"]["output"]>;
reservationsMinDaysBefore?: Maybe<Scalars["Int"]["output"]>;
resources?: Maybe<Array<Maybe<Scalars["Int"]["output"]>>>;
searchTerms?: Maybe<Array<Maybe<Scalars["String"]["output"]>>>;
serviceSpecificTerms?: Maybe<Scalars["String"]["output"]>;
spaces?: Maybe<Array<Maybe<Scalars["Int"]["output"]>>>;
surfaceArea?: Maybe<Scalars["Int"]["output"]>;
Expand Down Expand Up @@ -3846,6 +3854,7 @@ export type ReservationUnitNode = Node & {
reservationsMaxDaysBefore?: Maybe<Scalars["Int"]["output"]>;
reservationsMinDaysBefore?: Maybe<Scalars["Int"]["output"]>;
resources: Array<ResourceNode>;
searchTerms: Array<Scalars["String"]["output"]>;
serviceSpecificTerms?: Maybe<TermsOfUseNode>;
spaces: Array<SpaceNode>;
surfaceArea?: Maybe<Scalars["Int"]["output"]>;
Expand Down Expand Up @@ -4221,6 +4230,7 @@ export type ReservationUnitUpdateMutationInput = {
reservationsMaxDaysBefore?: InputMaybe<Scalars["Int"]["input"]>;
reservationsMinDaysBefore?: InputMaybe<Scalars["Int"]["input"]>;
resources?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
searchTerms?: InputMaybe<Array<InputMaybe<Scalars["String"]["input"]>>>;
serviceSpecificTerms?: InputMaybe<Scalars["String"]["input"]>;
spaces?: InputMaybe<Array<InputMaybe<Scalars["Int"]["input"]>>>;
surfaceArea?: InputMaybe<Scalars["Int"]["input"]>;
Expand Down Expand Up @@ -4292,6 +4302,7 @@ export type ReservationUnitUpdateMutationPayload = {
reservationsMaxDaysBefore?: Maybe<Scalars["Int"]["output"]>;
reservationsMinDaysBefore?: Maybe<Scalars["Int"]["output"]>;
resources?: Maybe<Array<Maybe<Scalars["Int"]["output"]>>>;
searchTerms?: Maybe<Array<Maybe<Scalars["String"]["output"]>>>;
serviceSpecificTerms?: Maybe<Scalars["String"]["output"]>;
spaces?: Maybe<Array<Maybe<Scalars["Int"]["output"]>>>;
surfaceArea?: Maybe<Scalars["Int"]["output"]>;
Expand Down
17 changes: 2 additions & 15 deletions apps/admin-ui/src/spa/ReservationUnit/edit/form.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { filterNonNullable, toNumber } from "common/src/helpers";
import { convertTime, filterNonNullable, toNumber } from "common/src/helpers";
import {
fromApiDate,
fromUIDate,
Expand Down Expand Up @@ -761,19 +761,6 @@ function convertImage(image?: Node["images"][0]): ImageFormType {
};
}

/// Primary use case is to clip out seconds from backend time strings
/// Assumed only to be used for backend time strings which are in format HH:MM or HH:MM:SS
/// NOTE does not handle incorrect time strings (ex. bar:foo)
/// NOTE does not have any boundary checks (ex. 25:99 is allowed)
const convertTime = (t?: string) => {
if (t == null || t === "") {
return "";
}
// NOTE split has incorrect typing
const [h, m, _]: Array<string | undefined> = t.split(":");
return `${h ?? "00"}:${m ?? "00"}`;
};

// Always return all 7 days
// Always return at least one reservableTime
function convertSeasonalList(
Expand All @@ -785,7 +772,7 @@ function convertSeasonalList(

const times = filterNonNullable(season?.reservableTimes).map((rt) => ({
begin: convertTime(rt.begin),
end: convertTime(rt?.end),
end: convertTime(rt.end),
}));
return {
pk: season?.pk ?? 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,8 +383,8 @@ function TimeRequested({
const durationString = createDurationString(applicationSection, t);

const aes = filterNonNullable(applicationSection?.suitableTimeRanges);
const primaryTimes = formatTimeRangeList(aes, Priority.Primary);
const secondaryTimes = formatTimeRangeList(aes, Priority.Secondary);
const primaryTimes = formatTimeRangeList(t, aes, Priority.Primary);
const secondaryTimes = formatTimeRangeList(t, aes, Priority.Secondary);

return (
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ApplicationSectionAllocationsQuery,
ApplicationRoundFilterQuery,
} from "@gql/gql-types";
import i18next from "i18next";
import { type TFunction } from "next-i18next";
import { filterNonNullable } from "common/src/helpers";
import { formatDuration } from "common/src/common/util";
Expand Down Expand Up @@ -151,11 +150,11 @@ export function parseApiTime(time: string): number | null {
return h1 + m1 / 60;
}

export const getTimeSeries = (
export function getTimeSeries(
day: string,
begin: string,
end: string
): string[] => {
): string[] {
const [, startHours, startMinutes] = begin.split("-").map(Number);
const [, endHours, endMinutes] = end.split("-").map(Number);
const timeSlots: string[] = [];
Expand All @@ -168,22 +167,23 @@ export const getTimeSeries = (
if (endMinutes === 0) timeSlots.pop();

return timeSlots;
};
}

// TODO is this parse? or format? it looks like a format
function formatTimeRange(
t: TFunction,
range: Pick<SuitableTimeRangeNode, "dayOfTheWeek" | "beginTime" | "endTime">
): string {
// TODO convert the day of the week
const day = convertWeekday(range.dayOfTheWeek);
const weekday = i18next.t(`dayShort.${day}`);
const weekday = t(`dayShort.${day}`);
// TODO don't use substring to convert times (wrap it in a function)
return `${weekday} ${Number(
range.beginTime.substring(0, 2)
)}-${Number(range.endTime.substring(0, 2))}`;
}

export function formatTimeRangeList(
t: TFunction,
aes: Pick<
SuitableTimeRangeNode,
"dayOfTheWeek" | "beginTime" | "endTime" | "priority"
Expand All @@ -196,7 +196,7 @@ export function formatTimeRangeList(
);

return filterNonNullable(schedules)
.map((schedule) => formatTimeRange(schedule))
.map((schedule) => formatTimeRange(t, schedule))
.join(", ");
}

Expand Down
119 changes: 119 additions & 0 deletions apps/ui/components/CancellationForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { useEffect } from "react";
import styled from "styled-components";
import { useForm } from "react-hook-form";
import { Button, IconCross } from "hds-react";
import { useTranslation } from "next-i18next";
import { fontMedium } from "common/src/common/typography";
import { type CancelReasonFieldsFragment } from "@gql/gql-types";
import Sanitize from "./common/Sanitize";
import {
convertLanguageCode,
getTranslationSafe,
} from "common/src/common/util";
import { ControlledSelect } from "common/src/components/form";
import { AutoGrid, ButtonContainer, Flex } from "common/styles/util";
import { ButtonLikeLink } from "./common/ButtonLikeLink";
import TermsBox from "common/src/termsbox/TermsBox";
import { AccordionWithState } from "./Accordion";
import { breakpoints } from "common";

const Actions = styled(ButtonContainer).attrs({
$justifyContent: "space-between",
})`
grid-column: 1 / -1;
`;

const Form = styled.form`
label {
${fontMedium};
}
`;

export type CancelFormValues = {
reason: number;
};

const FormWrapper = styled(Flex)`
@media (min-width: ${breakpoints.m}) {
grid-row: 2 / -1;
grid-column: 1;
}
`;

export function CancellationForm(props: {
onNext: (values: CancelFormValues) => void;
cancelReasons: CancelReasonFieldsFragment[];
cancellationTerms: string | null;
backLink: string;
isLoading?: boolean;
isDisabled?: boolean;
}): JSX.Element {
const {
cancelReasons,
onNext,
isLoading,
isDisabled,
cancellationTerms,
backLink,
} = props;
const { t, i18n } = useTranslation();
const lang = convertLanguageCode(i18n.language);

const reasons = cancelReasons.map((node) => ({
label: getTranslationSafe(node, "reason", lang),
value: node?.pk ?? 0,
}));

const form = useForm<CancelFormValues>();
const { register, handleSubmit, watch, control } = form;

// TODO can we remove this? should be auto registered when the form is created
// should we add zod schema for the required fields?
useEffect(() => {
register("reason", { required: true });
}, [register]);

return (
<FormWrapper>
{cancellationTerms != null && (
<AccordionWithState
heading={t("reservationUnit:cancellationTerms")}
disableBottomMargin
>
<TermsBox body={<Sanitize html={cancellationTerms} />} />
</AccordionWithState>
)}
<Form onSubmit={handleSubmit(onNext)}>
<AutoGrid>
<ControlledSelect
name="reason"
control={control}
label={t("reservations:cancel.reason")}
options={reasons}
required
disabled={isDisabled}
/>
<Actions>
<ButtonLikeLink
data-testid="reservation-cancel__button--back"
href={backLink}
size="large"
>
<IconCross aria-hidden="true" />
{t("reservations:cancelButton")}
</ButtonLikeLink>
<Button
variant="primary"
type="submit"
disabled={isDisabled || !watch("reason")}
data-testid="reservation-cancel__button--cancel"
isLoading={isLoading}
>
{t("reservations:cancel.reservation")}
</Button>
</Actions>
</AutoGrid>
</Form>
</FormWrapper>
);
}
24 changes: 10 additions & 14 deletions apps/ui/components/application/ApprovedReservations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import {
ReservationStateChoice,
} from "@/gql/gql-types";
import {
getApplicationPath,
getApplicationReservationPath,
getApplicationSectionPath,
getReservationUnitPath,
} from "@/modules/urls";
import { breakpoints, fontMedium, fontRegular, H5 } from "common";
import { errorToast } from "common/src/common/toast";
import {
getTranslationSafe,
toApiDate,
Expand Down Expand Up @@ -528,9 +527,7 @@ function ReservationsTable({
const router = useRouter();

const handleCancel = (pk: number) => {
const appPath = getApplicationPath(application.pk, "view");
const url = `${appPath}/${pk}/cancel`;
router.push(url);
router.push(getApplicationReservationPath(application.pk, pk));
};

const cols = [
Expand Down Expand Up @@ -805,17 +802,16 @@ export function ApplicationSection({
{t("application:view.reservationsTab.showAllReservations")}
<IconLinkExternal />
</ButtonLikeLink>
<Button
variant="secondary"
theme="black"
size="small"
onClick={() => {
errorToast({ text: "Not implemented: cancel application" });
}}
iconRight={<IconCross />}
<ButtonLikeLink
href={getApplicationSectionPath(
applicationSection.pk,
application.pk,
"cancel"
)}
>
{t("application:view.reservationsTab.cancelApplication")}
</Button>
<IconCross />
</ButtonLikeLink>
</ButtonContainer>
</Flex>
);
Expand Down
Loading
Loading