Availability / Single View (#4467)
* Revamped Schedule before type fixes * Removed t('copy_all'); fake focus state on CopyButton open * Added skeletons, availability single view (wip, still a bit glitchy) * Fixed isDefault switch glitchiness * Fixes changing the default schedule to the created schedule * Fix some type errors, on create set availability to DEFAULT_SCHEDULE * Provided missing translation for copy_times_to * Fix type errors, this may not be possible until v8 RHF * Fix lint error * Some responsiveness fixes Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>pull/4483/head^2
parent
a3462657db
commit
33e8b5d227
|
@ -3,23 +3,16 @@ import { useRouter } from "next/router";
|
|||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc, TRPCClientErrorLike } from "@calcom/trpc/react";
|
||||
import { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import { Form } from "@calcom/ui/form/fields";
|
||||
import { Button } from "@calcom/ui/v2";
|
||||
|
||||
import { DEFAULT_SCHEDULE } from "@lib/availability";
|
||||
import type { Schedule as ScheduleType } from "@lib/types/schedule";
|
||||
|
||||
interface ISetupAvailabilityProps {
|
||||
nextStep: () => void;
|
||||
defaultScheduleId?: number | null;
|
||||
defaultAvailability?: { schedule?: TimeRanges[][] };
|
||||
}
|
||||
|
||||
interface ScheduleFormValues {
|
||||
schedule: ScheduleType;
|
||||
}
|
||||
|
||||
const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
||||
|
@ -37,7 +30,9 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||
}
|
||||
|
||||
const availabilityForm = useForm({
|
||||
defaultValues: { schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE },
|
||||
defaultValues: {
|
||||
schedule: queryAvailability?.data?.availability || DEFAULT_SCHEDULE,
|
||||
},
|
||||
});
|
||||
|
||||
const mutationOptions = {
|
||||
|
@ -51,7 +46,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", mutationOptions);
|
||||
const updateSchedule = trpc.useMutation("viewer.availability.schedule.update", mutationOptions);
|
||||
return (
|
||||
<Form<ScheduleFormValues>
|
||||
<Form
|
||||
className="w-full bg-white text-black dark:bg-opacity-5 dark:text-white"
|
||||
form={availabilityForm}
|
||||
handleSubmit={async (values) => {
|
||||
|
@ -75,7 +70,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
|
|||
}
|
||||
}
|
||||
}}>
|
||||
<Schedule />
|
||||
<Schedule control={availabilityForm.control} name="schedule" weekStart={1} />
|
||||
|
||||
<div>
|
||||
<Button
|
||||
|
|
|
@ -1,36 +1,60 @@
|
|||
import { GetStaticPaths, GetStaticProps } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Schedule } from "@calcom/features/schedules";
|
||||
import { availabilityAsString, DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import Schedule from "@calcom/features/schedules/components/Schedule";
|
||||
import { availabilityAsString } from "@calcom/lib/availability";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { inferQueryOutput, trpc } from "@calcom/trpc/react";
|
||||
import { BadgeCheckIcon } from "@calcom/ui/Icon";
|
||||
import Shell from "@calcom/ui/Shell";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Schedule as ScheduleType } from "@calcom/types/schedule";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import TimezoneSelect from "@calcom/ui/form/TimezoneSelect";
|
||||
import { Button, Form, showToast, Switch, TextField } from "@calcom/ui/v2";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import Shell from "@calcom/ui/v2/core/Shell";
|
||||
import Switch from "@calcom/ui/v2/core/Switch";
|
||||
import VerticalDivider from "@calcom/ui/v2/core/VerticalDivider";
|
||||
import { Form, Label } from "@calcom/ui/v2/core/form/fields";
|
||||
import showToast from "@calcom/ui/v2/core/notifications";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
import { QueryCell } from "@lib/QueryCell";
|
||||
import { HttpError } from "@lib/core/http/error";
|
||||
|
||||
import EditableHeading from "@components/ui/EditableHeading";
|
||||
|
||||
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
|
||||
const { t } = useLocale();
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
});
|
||||
|
||||
type AvailabilityFormValues = {
|
||||
name: string;
|
||||
schedule: ScheduleType;
|
||||
timeZone: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export default function Availability({ schedule }: { schedule: number }) {
|
||||
const { t, i18n } = useLocale();
|
||||
const router = useRouter();
|
||||
const utils = trpc.useContext();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
schedule: props.availability || DEFAULT_SCHEDULE,
|
||||
isDefault: !!props.isDefault,
|
||||
timeZone: props.timeZone,
|
||||
},
|
||||
});
|
||||
const { data, isLoading } = trpc.useQuery(["viewer.availability.schedule", { scheduleId: schedule }]);
|
||||
|
||||
const form = useForm<AvailabilityFormValues>();
|
||||
const { control, reset, setValue } = form;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && data) {
|
||||
reset({
|
||||
name: data?.schedule?.name,
|
||||
schedule: data.availability,
|
||||
timeZone: data.timeZone,
|
||||
isDefault: data.isDefault,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, reset]);
|
||||
|
||||
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
|
||||
onSuccess: async ({ schedule }) => {
|
||||
|
@ -53,107 +77,100 @@ export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.sc
|
|||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
handleSubmit={async (values) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: parseInt(router.query.schedule as string, 10),
|
||||
name: props.schedule.name,
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1">
|
||||
<div className="rounded-md border-gray-200 bg-white px-4 py-5 sm:border sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
|
||||
<Schedule />
|
||||
</div>
|
||||
<div className="flex justify-end px-4 pt-4 sm:px-0">
|
||||
<Button>{t("save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
|
||||
<Controller
|
||||
name="isDefault"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="xl:max-w-80 w-full">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Controller
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-6">
|
||||
<TextField
|
||||
name="aciveOn"
|
||||
label={t("active_on")}
|
||||
disabled
|
||||
value={t("nr_event_type_other", { count: props.schedule.eventType?.length })}
|
||||
className="focus:border-brand mt-1 block w-full rounded-md border-gray-300 text-sm"
|
||||
<Shell
|
||||
backPath="/availability"
|
||||
title={t("availability_title", { availabilityTitle: data?.schedule.name })}
|
||||
heading={
|
||||
<EditableHeading title={data?.schedule.name || ""} onChange={(name) => setValue("name", name)} />
|
||||
}
|
||||
subtitle={data?.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
))}
|
||||
CTA={
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center rounded-md px-2 sm:hover:bg-gray-100">
|
||||
<Label htmlFor="hiddenSwitch" className="mt-2 hidden cursor-pointer self-center pr-2 sm:inline">
|
||||
{t("set_to_default")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="hiddenSwitch"
|
||||
disabled={isLoading}
|
||||
checked={form.watch("isDefault")}
|
||||
onCheckedChange={(e) => {
|
||||
form.setValue("isDefault", e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-8" />
|
||||
<div className="rounded-md">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||
<div className="mt-3 flex">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Button>
|
||||
|
||||
<VerticalDivider />
|
||||
|
||||
<div className="border-l-2 border-gray-300" />
|
||||
<Button className="ml-4 lg:ml-0" type="submit" form="availability-form">
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex items-baseline sm:mt-0">
|
||||
{/* TODO: Find a better way to guarantee alignment, but for now this'll do. */}
|
||||
<Icon.FiArrowLeft className=" mr-3 text-transparent hover:cursor-pointer" />
|
||||
<div className="w-full">
|
||||
<Form
|
||||
form={form}
|
||||
id="availability-form"
|
||||
handleSubmit={async (values) => {
|
||||
updateMutation.mutate({
|
||||
scheduleId: schedule,
|
||||
...values,
|
||||
});
|
||||
}}
|
||||
className="-mx-4 flex flex-col pb-16 sm:mx-0 xl:flex-row xl:space-x-6">
|
||||
<div className="flex-1">
|
||||
<div className="rounded-md border-gray-200 bg-white py-5 pr-4 sm:border sm:p-6">
|
||||
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">
|
||||
{t("change_start_end")}
|
||||
</h3>
|
||||
<Schedule control={control} name="schedule" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
|
||||
<div className="xl:max-w-80 w-full pr-4 sm:p-0">
|
||||
<div>
|
||||
<label htmlFor="timeZone" className="block text-sm font-medium text-gray-700">
|
||||
{t("timezone")}
|
||||
</label>
|
||||
<Controller
|
||||
name="timeZone"
|
||||
render={({ field: { onChange, value } }) =>
|
||||
value ? (
|
||||
<TimezoneSelect
|
||||
value={value}
|
||||
className="focus:border-brand mt-1 block rounded-md border-gray-300 text-sm"
|
||||
onChange={(timezone) => onChange(timezone.value)}
|
||||
/>
|
||||
) : (
|
||||
<SkeletonText className="h-6 w-full" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<hr className="my-8" />
|
||||
<div className="rounded-md">
|
||||
<h3 className="text-sm font-medium text-gray-900">{t("something_doesnt_look_right")}</h3>
|
||||
<div className="mt-3 flex">
|
||||
<Button href="/availability/troubleshoot" color="secondary">
|
||||
{t("launch_troubleshooter")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const querySchema = z.object({
|
||||
schedule: stringOrNumber,
|
||||
});
|
||||
|
||||
export default function Availability() {
|
||||
const router = useRouter();
|
||||
const { i18n } = useLocale();
|
||||
const { schedule: scheduleId } = router.isReady ? querySchema.parse(router.query) : { schedule: -1 };
|
||||
const query = trpc.useQuery(["viewer.availability.schedule", { scheduleId }], { enabled: router.isReady });
|
||||
const [name, setName] = useState<string>();
|
||||
return (
|
||||
<div>
|
||||
<QueryCell
|
||||
query={query}
|
||||
success={({ data }) => {
|
||||
return (
|
||||
<Shell
|
||||
heading={<EditableHeading title={name || data.schedule.name} onChange={setName} />}
|
||||
subtitle={data.schedule.availability.map((availability) => (
|
||||
<span key={availability.id}>
|
||||
{availabilityAsString(availability, { locale: i18n.language })}
|
||||
<br />
|
||||
</span>
|
||||
))}>
|
||||
<AvailabilityForm
|
||||
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
|
||||
/>
|
||||
</Shell>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -631,8 +631,10 @@
|
|||
"billing": "Billing",
|
||||
"manage_your_billing_info": "Manage your billing information and cancel your subscription.",
|
||||
"availability": "Availability",
|
||||
"availability_title": "{{availabilityTitle}} | Availability",
|
||||
"edit_availability": "Edit availability",
|
||||
"configure_availability": "Configure times when you are available for bookings.",
|
||||
"copy_times_to": "Copy times to",
|
||||
"change_weekly_schedule": "Change your weekly schedule",
|
||||
"logo": "Logo",
|
||||
"error": "Error",
|
||||
|
@ -1150,7 +1152,6 @@
|
|||
"set_availability_getting_started_subtitle_2": "You can customise all of this later in the availability page.",
|
||||
"connect_calendars_from_app_store": "You can add more calendars from the app store",
|
||||
"current_step_of_total": "Step {{currentStep}} of {{maxSteps}}",
|
||||
"copy_all": "Copy All",
|
||||
"add_variable": "Add variable",
|
||||
"custom_phone_number": "Custom phone number",
|
||||
"message_template": "Message template",
|
||||
|
|
|
@ -1,113 +1,194 @@
|
|||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
UseFieldArrayAppend,
|
||||
import { useCallback, useEffect, useMemo, useState, Fragment } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type {
|
||||
UseFieldArrayRemove,
|
||||
useFormContext,
|
||||
FieldValues,
|
||||
FieldPath,
|
||||
FieldPathValue,
|
||||
FieldArrayWithId,
|
||||
ArrayPath,
|
||||
ControllerRenderProps,
|
||||
Control,
|
||||
} from "react-hook-form";
|
||||
import { GroupBase, Props } from "react-select";
|
||||
|
||||
import dayjs, { ConfigType, Dayjs } from "@calcom/dayjs";
|
||||
import { defaultDayRange as DEFAULT_DAY_RANGE } from "@calcom/lib/availability";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { TimeRange } from "@calcom/types/schedule";
|
||||
import { Icon } from "@calcom/ui";
|
||||
import Dropdown, { DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
|
||||
import { Button, Select, Switch, Tooltip } from "@calcom/ui/v2";
|
||||
import { Select, Switch } from "@calcom/ui/v2";
|
||||
import Button from "@calcom/ui/v2/core/Button";
|
||||
import { SkeletonText } from "@calcom/ui/v2/core/skeleton";
|
||||
|
||||
const Schedule = () => {
|
||||
const { i18n } = useLocale();
|
||||
const form = useFormContext();
|
||||
export type FieldPathByValue<TFieldValues extends FieldValues, TValue> = {
|
||||
[Key in FieldPath<TFieldValues>]: FieldPathValue<TFieldValues, Key> extends TValue ? Key : never;
|
||||
}[FieldPath<TFieldValues>];
|
||||
|
||||
const initialValue = form.watch();
|
||||
const ScheduleDay = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
weekday,
|
||||
control,
|
||||
CopyButton,
|
||||
}: {
|
||||
name: string;
|
||||
weekday: string;
|
||||
control: Control<TFieldValues>;
|
||||
CopyButton: JSX.Element;
|
||||
}) => {
|
||||
const { watch, setValue } = useFormContext();
|
||||
const watchDayRange = watch(name);
|
||||
|
||||
const copyAllPosition = (initialValue["schedule"] as Array<TimeRange[]>)?.findIndex(
|
||||
(item: TimeRange[]) => item.length > 0
|
||||
return (
|
||||
<div className="mb-1 flex w-full flex-col py-1 sm:flex-row">
|
||||
{/* Label & switch container */}
|
||||
<div className="flex h-11 items-center justify-between">
|
||||
<div>
|
||||
<label className="flex flex-row items-center space-x-2">
|
||||
<div>
|
||||
<Switch
|
||||
disabled={!watchDayRange}
|
||||
defaultChecked={watchDayRange && watchDayRange.length > 0}
|
||||
checked={watchDayRange && !!watchDayRange.length}
|
||||
onCheckedChange={(isChecked) => {
|
||||
setValue(name, isChecked ? [DEFAULT_DAY_RANGE] : []);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{watchDayRange ? (
|
||||
<div className="flex sm:ml-2">
|
||||
<DayRanges control={control} name={name} />
|
||||
{!!watchDayRange.length && <div className="mt-1">{CopyButton}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<SkeletonText className="mt-2.5 ml-1 h-6 w-48" />
|
||||
)}
|
||||
</>
|
||||
<div className="my-2 h-[1px] w-full bg-gray-200 sm:hidden" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CopyButton = ({
|
||||
getValuesFromDayRange,
|
||||
weekStart,
|
||||
}: {
|
||||
getValuesFromDayRange: string;
|
||||
weekStart: number;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [open, setOpen] = useState(false);
|
||||
const fieldArrayName = getValuesFromDayRange.substring(0, getValuesFromDayRange.lastIndexOf("."));
|
||||
const { setValue, getValues } = useFormContext();
|
||||
return (
|
||||
<Dropdown open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={classNames(open && "ring-brand-500 !bg-gray-100 outline-none ring-2 ring-offset-1")}
|
||||
type="button"
|
||||
tooltip={t("duplicate")}
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiCopy}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<CopyTimes
|
||||
weekStart={weekStart}
|
||||
disabled={parseInt(getValuesFromDayRange.replace(fieldArrayName + ".", ""), 10)}
|
||||
onClick={(selected) => {
|
||||
selected.forEach((day) => setValue(`${fieldArrayName}.${day}`, getValues(getValuesFromDayRange)));
|
||||
}}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const Schedule = <
|
||||
TFieldValues extends FieldValues,
|
||||
TPath extends FieldPathByValue<TFieldValues, TimeRange[][]>
|
||||
>({
|
||||
name,
|
||||
control,
|
||||
weekStart = 0,
|
||||
}: {
|
||||
name: TPath;
|
||||
control: Control<TFieldValues>;
|
||||
weekStart?: number;
|
||||
}) => {
|
||||
const { i18n } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* First iterate for each day */}
|
||||
{weekdayNames(i18n.language, 0, "long").map((weekday, num) => {
|
||||
const name = `schedule.${num}`;
|
||||
const copyAllShouldRender = copyAllPosition === num;
|
||||
{weekdayNames(i18n.language, weekStart, "long").map((weekday, num) => {
|
||||
const weekdayIndex = (num + weekStart) % 7;
|
||||
const dayRangeName = `${name}.${weekdayIndex}`;
|
||||
return (
|
||||
<div className="mb-1 flex w-full flex-col py-1 sm:flex-row" key={weekday}>
|
||||
{/* Label & switch container */}
|
||||
<div className="flex h-11 items-center justify-between">
|
||||
<div>
|
||||
<label className="flex flex-row items-center space-x-2">
|
||||
<div>
|
||||
<Switch
|
||||
defaultChecked={initialValue["schedule"][num].length > 0}
|
||||
checked={!!initialValue["schedule"][num].length}
|
||||
onCheckedChange={(isChecked) => {
|
||||
form.setValue(name, isChecked ? [DEFAULT_DAY_RANGE] : []);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="inline-block min-w-[88px] text-sm capitalize">{weekday}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline sm:hidden">
|
||||
<ActionButtons
|
||||
name={name}
|
||||
setValue={form.setValue}
|
||||
watcher={form.watch(name, initialValue[name])}
|
||||
copyAllShouldRender={copyAllShouldRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full sm:ml-2">
|
||||
<DayRanges name={name} copyAllShouldRender={copyAllShouldRender} />
|
||||
</div>
|
||||
<div className="my-2 h-[1px] w-full bg-gray-200 sm:hidden" />
|
||||
</div>
|
||||
<ScheduleDay
|
||||
name={dayRangeName}
|
||||
key={weekday}
|
||||
weekday={weekday}
|
||||
control={control}
|
||||
CopyButton={<CopyButton weekStart={weekStart} getValuesFromDayRange={dayRangeName} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const DayRanges = ({
|
||||
const DayRanges = <TFieldValues extends FieldValues>({
|
||||
name,
|
||||
copyAllShouldRender,
|
||||
control,
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: TimeRange[];
|
||||
copyAllShouldRender?: boolean;
|
||||
control: Control<TFieldValues>;
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const fields = form.watch(`${name}` as `schedule.0`);
|
||||
|
||||
const { remove } = useFieldArray({
|
||||
name,
|
||||
const { remove, fields, append } = useFieldArray({
|
||||
control,
|
||||
name: name as unknown as ArrayPath<TFieldValues>,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field: { id: string }, index: number) => (
|
||||
<div key={field.id + name} className="mt-2 mb-2 flex rtl:space-x-reverse sm:mt-0">
|
||||
<TimeRangeField name={`${name}.${index}`} />
|
||||
{index === 0 && (
|
||||
<div className="hidden sm:inline">
|
||||
<ActionButtons
|
||||
name={name}
|
||||
setValue={form.setValue}
|
||||
watcher={form.watch(name)}
|
||||
copyAllShouldRender={copyAllShouldRender}
|
||||
<div>
|
||||
{fields.map((field, index: number) => (
|
||||
<Fragment key={field.id}>
|
||||
<div className="mb-2 flex first:mt-1">
|
||||
<Controller name={`${name}.${index}`} render={({ field }) => <TimeRangeField {...field} />} />
|
||||
{index === 0 && (
|
||||
<Button
|
||||
tooltip={t("add_time_availability")}
|
||||
className=" text-neutral-400"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nextRange: any = getNextRange(fields[fields.length - 1]);
|
||||
if (nextRange) append(nextRange);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{index !== 0 && <RemoveTimeButton index={index} remove={remove} />}
|
||||
</div>
|
||||
)}
|
||||
{index !== 0 && <RemoveTimeButton index={index} remove={remove} />}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -132,48 +213,26 @@ const RemoveTimeButton = ({
|
|||
);
|
||||
};
|
||||
|
||||
interface TimeRangeFieldProps {
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
||||
const { watch } = useFormContext();
|
||||
|
||||
const values = watch(name);
|
||||
const minEnd = values["start"];
|
||||
const maxStart = values["end"];
|
||||
|
||||
const TimeRangeField = ({ className, value, onChange }: { className?: string } & ControllerRenderProps) => {
|
||||
// this is a controlled component anyway given it uses LazySelect, so keep it RHF agnostic.
|
||||
return (
|
||||
<div className={classNames("mx-1 flex", className)}>
|
||||
<Controller
|
||||
name={`${name}.start`}
|
||||
render={({ field: { onChange } }) => {
|
||||
return (
|
||||
<LazySelect
|
||||
className="h-9 w-[100px]"
|
||||
value={values["start"]}
|
||||
max={maxStart}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
<div className={classNames("mx-1", className)}>
|
||||
<LazySelect
|
||||
className="inline-block h-9 w-[100px]"
|
||||
value={value.start}
|
||||
max={value.end}
|
||||
onChange={(option) => {
|
||||
onChange({ ...value, start: new Date(option?.value as number) });
|
||||
}}
|
||||
/>
|
||||
<span className="mx-2 w-2 self-center"> - </span>
|
||||
<Controller
|
||||
name={`${name}.end`}
|
||||
render={({ field: { onChange } }) => (
|
||||
<LazySelect
|
||||
className="w-[100px] rounded-md"
|
||||
value={values["end"]}
|
||||
min={minEnd}
|
||||
onChange={(option) => {
|
||||
onChange(new Date(option?.value as number));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LazySelect
|
||||
className="inline-block w-[100px] rounded-md"
|
||||
value={value.end}
|
||||
min={value.start}
|
||||
onChange={(option) => {
|
||||
onChange({ ...value, end: new Date(option?.value as number) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -266,136 +325,69 @@ const useOptions = () => {
|
|||
return { options: filteredOptions, filter };
|
||||
};
|
||||
|
||||
const ActionButtons = ({
|
||||
name,
|
||||
watcher,
|
||||
setValue,
|
||||
copyAllShouldRender,
|
||||
}: {
|
||||
name: string;
|
||||
watcher: TimeRange[];
|
||||
setValue: (key: string, value: TimeRange[]) => void;
|
||||
copyAllShouldRender?: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const form = useFormContext();
|
||||
|
||||
const values = form.watch();
|
||||
const { append } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Tooltip content={t("add_time_availability") as string}>
|
||||
<Button
|
||||
className="text-neutral-400"
|
||||
type="button"
|
||||
color="minimal"
|
||||
size="icon"
|
||||
StartIcon={Icon.FiPlus}
|
||||
onClick={() => {
|
||||
handleAppend({
|
||||
fields: watcher,
|
||||
/* Generics should help with this, but forgive us father as I have sinned */
|
||||
append: append as unknown as UseFieldArrayAppend<TimeRange>,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Dropdown>
|
||||
<Tooltip content={t("duplicate") as string}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" color="minimal" size="icon" StartIcon={Icon.FiCopy} />
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
<CopyTimes
|
||||
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
||||
onApply={(selected) =>
|
||||
selected.forEach((day) => {
|
||||
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
{/* This only displays on Desktop */}
|
||||
{copyAllShouldRender && (
|
||||
<Tooltip content={t("add_time_availability") as string}>
|
||||
<Button
|
||||
color="minimal"
|
||||
className="whitespace-nowrap text-sm text-neutral-400"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
values["schedule"].forEach((item: TimeRange[], index: number) => {
|
||||
if (item.length > 0) {
|
||||
setValue(`schedule.${index}`, watcher);
|
||||
}
|
||||
});
|
||||
}}
|
||||
title={`${t("copy_all")}`}>
|
||||
{t("copy_all")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAppend = ({
|
||||
fields = [],
|
||||
append,
|
||||
}: {
|
||||
fields: TimeRange[];
|
||||
append: UseFieldArrayAppend<TimeRange>;
|
||||
}) => {
|
||||
if (fields.length === 0) {
|
||||
return append(DEFAULT_DAY_RANGE);
|
||||
}
|
||||
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
||||
const getNextRange = (field?: FieldArrayWithId) => {
|
||||
const nextRangeStart = dayjs((field as unknown as TimeRange).end);
|
||||
const nextRangeEnd = dayjs(nextRangeStart).add(1, "hour");
|
||||
|
||||
if (nextRangeEnd.isBefore(nextRangeStart.endOf("day"))) {
|
||||
return append({
|
||||
return {
|
||||
start: nextRangeStart.toDate(),
|
||||
end: nextRangeEnd.toDate(),
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
||||
const CopyTimes = ({
|
||||
disabled,
|
||||
onClick,
|
||||
onCancel,
|
||||
weekStart,
|
||||
}: {
|
||||
disabled: number;
|
||||
onClick: (selected: number[]) => void;
|
||||
onCancel: () => void;
|
||||
weekStart: number;
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const { i18n, t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="m-4 space-y-2 py-4">
|
||||
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
||||
<ol className="space-y-2">
|
||||
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||
<li key={weekday}>
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<span className="px-1">{weekday}</span>
|
||||
<input
|
||||
value={num}
|
||||
defaultChecked={disabled.includes(num)}
|
||||
disabled={disabled.includes(num)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !selected.includes(num)) {
|
||||
setSelected(selected.concat([num]));
|
||||
} else if (!e.target.checked && selected.includes(num)) {
|
||||
setSelected(selected.slice(selected.indexOf(num), 1));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="inline-block rounded-[4px] border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="p-2">
|
||||
<p className="h6 pb-3 pl-1 text-xs font-medium uppercase text-neutral-400">{t("copy_times_to")}</p>
|
||||
<ol className="space-y-2">
|
||||
{weekdayNames(i18n.language, weekStart).map((weekday, num) => {
|
||||
const weekdayIndex = (num + weekStart) % 7;
|
||||
return (
|
||||
<li key={weekday}>
|
||||
<label className="flex w-full items-center justify-between">
|
||||
<span className="px-1">{weekday}</span>
|
||||
<input
|
||||
value={weekdayIndex}
|
||||
defaultChecked={disabled === weekdayIndex}
|
||||
disabled={disabled === weekdayIndex}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked && !selected.includes(weekdayIndex)) {
|
||||
setSelected(selected.concat([weekdayIndex]));
|
||||
} else if (!e.target.checked && selected.includes(weekdayIndex)) {
|
||||
setSelected(selected.slice(selected.indexOf(weekdayIndex), 1));
|
||||
}
|
||||
}}
|
||||
type="checkbox"
|
||||
className="inline-block rounded-[4px] border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="space-x-2 px-2">
|
||||
<Button color="minimalSecondary" onClick={() => onCancel()}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button color="primary" onClick={() => onClick(selected)}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Availability as AvailabilityModel, Prisma, Schedule as ScheduleModel, U
|
|||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
import { stringOrNumber } from "@calcom/prisma/zod-utils";
|
||||
import { Schedule } from "@calcom/types/schedule";
|
||||
|
@ -114,24 +114,22 @@ export const availabilityRouter = createProtectedRouter()
|
|||
},
|
||||
};
|
||||
|
||||
if (input.schedule) {
|
||||
const availability = getAvailabilityFromSchedule(input.schedule);
|
||||
data.availability = {
|
||||
createMany: {
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
const availability = getAvailabilityFromSchedule(input.schedule || DEFAULT_SCHEDULE);
|
||||
data.availability = {
|
||||
createMany: {
|
||||
data: availability.map((schedule) => ({
|
||||
days: schedule.days,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const schedule = await prisma.schedule.create({
|
||||
data,
|
||||
});
|
||||
const hasDefaultScheduleId = await hasDefaultSchedule(user, prisma);
|
||||
|
||||
if (hasDefaultScheduleId) {
|
||||
if (!hasDefaultScheduleId) {
|
||||
await setupDefaultSchedule(user.id, schedule.id, prisma);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue