Feature/multiple schedules post turbo (#2150)

* Concluded merge

* Applied stash to newly merged

* Always disconnect + remove redundant success message

* Added named dialog to replace new=1

* Merged with main p2

* Set eventTypeId to @unique

* WIP

* Undo vscode changes

* Availability dropdown works

* Remove console.log + set schedule to null as it is unneeded

* Added schedule to availability endpoint

* Reduce one refresh; hotfix state inconsistency with forced refresh for now

* Add missing translations

* Fixed some type errors I missed

* Ditch outdated remnant from before packages/prisma

* Remove Availability section for teams

* Bringing back the Availability section temporarily to teams to allow configuration

* Migrated getting-started to new availability system + updated translations + updated seed

* Fixed type error coming from main

* Titlecase 'default' by providing translation

* Fixed broken 'radio' buttons.

* schedule deleted translation added

* Added empty state for when no schedules are configured

* Added correct created message + hotfix reload hard on delete to refresh state

* Removed index renames

* Type fixes

* Update NewScheduleButton.tsx

Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Bailey Pumfleet <pumfleet@hey.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Alex van Andel 2022-03-17 16:48:23 +00:00 committed by GitHub
parent bcbf8390e0
commit 6a211dd5b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1073 additions and 1207 deletions

View File

@ -307,7 +307,7 @@ export default function Shell(props: {
</Button>
</div>
)}
<div className="block min-h-[80px] justify-between px-4 sm:flex sm:px-6 md:px-8">
<div className="block justify-between px-4 sm:flex sm:px-6 md:px-8">
{props.HeadingLeftIcon && <div className="ltr:mr-4">{props.HeadingLeftIcon}</div>}
<div className="mb-8 w-full">
<h1 className="font-cal mb-1 text-xl text-gray-900">{props.heading}</h1>

View File

@ -0,0 +1,77 @@
import { PlusIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useForm } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Button } from "@calcom/ui";
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import { Form, TextField } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error";
import { trpc } from "@lib/trpc";
export function NewScheduleButton({ name = "new-schedule" }: { name?: string }) {
const router = useRouter();
const { t } = useLocale();
const form = useForm<{
name: string;
}>();
const { register } = form;
const createMutation = trpc.useMutation("viewer.availability.schedule.create", {
onSuccess: async ({ schedule }) => {
await router.push("/availability/" + schedule.id);
showToast(t("schedule_created_successfully", { scheduleName: schedule.name }), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
const message = `${err.data.code}: You are not able to create this event`;
showToast(message, "error");
}
},
});
return (
<Dialog name={name} clearQueryParamsOnClose={["copy-schedule-id"]}>
<DialogTrigger asChild>
<Button data-testid={name} StartIcon={PlusIcon}>
{t("new_schedule_btn")}
</Button>
</DialogTrigger>
<DialogContent>
<div className="mb-4">
<h3 className="text-lg font-bold leading-6 text-gray-900" id="modal-title">
{t("add_new_schedule")}
</h3>
<div>
<p className="text-sm text-gray-500">{t("new_event_type_to_book_description")}</p>
</div>
</div>
<Form
form={form}
handleSubmit={(values) => {
createMutation.mutate(values);
}}>
<div className="mt-3 space-y-4">
<TextField label={t("name")} {...register("name")} />
</div>
<div className="mt-8 flex flex-row-reverse gap-x-2">
<Button type="submit" loading={createMutation.isLoading}>
{t("continue")}
</Button>
<DialogClose asChild>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
</div>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -147,7 +147,7 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
};
return (
<fieldset className="flex min-h-[86px] flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
<fieldset className="flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
<div className="w-1/3">
<label className="flex items-center space-x-2 rtl:space-x-reverse">
<input

View File

@ -0,0 +1,34 @@
import { PencilIcon } from "@heroicons/react/solid";
import { useState } from "react";
const EditableHeading = ({ title, onChange }: { title: string; onChange: (value: string) => void }) => {
const [editIcon, setEditIcon] = useState(true);
return (
<div className="group relative cursor-pointer" onClick={() => setEditIcon(false)}>
{editIcon ? (
<>
<h1
style={{ fontSize: 22, letterSpacing: "-0.0009em" }}
className="inline pl-0 text-gray-900 focus:text-black group-hover:text-gray-500">
{title}
</h1>
<PencilIcon className="ml-1 -mt-1 inline h-4 w-4 text-gray-700 group-hover:text-gray-500" />
</>
) : (
<div style={{ marginBottom: -11 }}>
<input
type="text"
autoFocus
style={{ top: -6, fontSize: 22 }}
required
className="relative h-10 w-full cursor-pointer border-none bg-transparent pl-0 text-gray-900 hover:text-gray-700 focus:text-black focus:outline-none focus:ring-0"
defaultValue={title}
onChange={(e) => onChange(e.target.value)}
/>
</div>
)}
</div>
);
};
export default EditableHeading;

View File

@ -1,8 +1 @@
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
export function weekdayNames(locale: string | string[], weekStart = 0, type: "short" | "long" = "long") {
return Array.from(Array(7).keys()).map((d) => nameOfDay(locale, d + weekStart, type));
}
export function nameOfDay(locale: string | string[], day: number, type: "short" | "long" = "long") {
return new Intl.DateTimeFormat(locale, { weekday: type }).format(new Date(1970, 0, day + 4));
}
export * from "@calcom/lib/weekday";

View File

@ -43,6 +43,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodDays: true,
periodCountCalendarDays: true,
schedulingType: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
minimumBookingNotice: true,
beforeEventBuffer: true,
afterEventBuffer: true,
@ -80,6 +86,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: true,
brandColor: true,
darkBrandColor: true,
defaultScheduleId: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
theme: true,
plan: true,
eventTypes: {
@ -175,13 +189,24 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
periodEndDate: eventType.periodEndDate?.toString() ?? null,
});
const schedule = eventType.schedule
? { ...eventType.schedule }
: {
...user.schedules.filter(
(schedule) => !user.defaultScheduleId || schedule.id === user.defaultScheduleId
)[0],
};
const timeZone = schedule.timeZone || eventType.timeZone || user.timeZone;
const workingHours = getWorkingHours(
{
timeZone: eventType.timeZone || user.timeZone,
timeZone,
},
eventType.availability.length ? eventType.availability : user.availability
schedule.availability || (eventType.availability.length ? eventType.availability : user.availability)
);
eventTypeObject.schedule = null;
eventTypeObject.availability = [];
return {

View File

@ -36,6 +36,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
startTime: true,
endTime: true,
selectedCalendars: true,
schedules: {
select: {
availability: true,
timeZone: true,
id: true,
},
},
defaultScheduleId: true,
},
});
@ -44,6 +52,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
where: { id },
select: {
timeZone: true,
schedule: {
select: {
availability: true,
timeZone: true,
},
},
availability: {
select: {
startTime: true,
@ -76,10 +90,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
end: dayjs(a.end).add(currentUser.bufferTime, "minute").toString(),
}));
const timeZone = eventType?.timeZone || currentUser.timeZone;
const schedule = eventType?.schedule
? { ...eventType?.schedule }
: {
...currentUser.schedules.filter(
(schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId
)[0],
};
const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone;
const workingHours = getWorkingHours(
{ timeZone },
eventType?.availability.length ? eventType.availability : currentUser.availability
{
timeZone,
},
schedule.availability ||
(eventType?.availability.length ? eventType.availability : currentUser.availability)
);
res.status(200).json({

View File

@ -0,0 +1,154 @@
import { BadgeCheckIcon } from "@heroicons/react/solid";
import { useRouter } from "next/router";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import TimezoneSelect from "react-timezone-select";
import { DEFAULT_SCHEDULE, availabilityAsString } from "@calcom/lib/availability";
import showToast from "@calcom/lib/notification";
import Button from "@calcom/ui/Button";
import Switch from "@calcom/ui/Switch";
import { Form } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import Shell from "@components/Shell";
import Schedule from "@components/availability/Schedule";
import EditableHeading from "@components/ui/EditableHeading";
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability.schedule">) {
const { t } = useLocale();
const router = useRouter();
const form = useForm({
defaultValues: {
schedule: props.availability || DEFAULT_SCHEDULE,
isDefault: !!props.isDefault,
timeZone: props.timeZone,
},
});
const updateMutation = trpc.useMutation("viewer.availability.schedule.update", {
onSuccess: async () => {
await router.push("/availability");
window.location.reload();
showToast(t("availability_updated_successfully"), "success");
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<Form
form={form}
handleSubmit={async (values) => {
updateMutation.mutate({
scheduleId: parseInt(router.query.schedule as string, 10),
name: props.schedule.name,
...values,
});
}}
className="grid grid-cols-3 gap-2">
<div className="col-span-3 space-y-2 lg:col-span-2">
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
<div className="space-x-2 text-right">
<Button color="secondary" href="/availability" tabIndex={-1}>
{t("cancel")}
</Button>
<Button>{t("save")}</Button>
</div>
</div>
<div className="min-w-40 col-span-3 ml-2 space-y-2 lg:col-span-1">
{props.isDefault ? (
<div className="inline-block rounded border border-gray-300 bg-gray-200 px-2 py-0.5 pl-1.5 text-sm font-medium text-neutral-800">
<span className="flex items-center">
<BadgeCheckIcon className="mr-1 h-4 w-4" /> {t("default")}
</span>
</div>
) : (
<Controller
name="isDefault"
render={({ field: { onChange, value } }) => (
<Switch label={t("set_to_default")} onCheckedChange={onChange} checked={value} />
)}
/>
)}
<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 shadow-sm focus:ring-black sm:text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
)}
/>
</div>
</div>
<div className="mt-2 rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
<h3 className="text-base font-medium leading-6 text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
</div>
</Form>
);
}
export default function Availability() {
const router = useRouter();
const { i18n } = useLocale();
const query = trpc.useQuery([
"viewer.availability.schedule",
{
scheduleId: parseInt(router.query.schedule as string),
},
]);
const [name, setName] = useState<string>();
return (
<div>
<QueryCell
query={query}
success={({ data }) => {
return (
<Shell
heading={<EditableHeading title={data.schedule.name} onChange={setName} />}
subtitle={data.schedule.availability.map((availability) => (
<>
{availabilityAsString(availability, i18n.language)}
<br />
</>
))}>
<AvailabilityForm
{...{ ...data, schedule: { ...data.schedule, name: name || data.schedule.name } }}
/>
</Shell>
);
}}
/>
</div>
);
}

View File

@ -1,139 +1,115 @@
import { zodResolver } from "@hookform/resolvers/zod";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { ClockIcon } from "@heroicons/react/outline";
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
import { Availability } from "@prisma/client";
import Link from "next/link";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Form } from "@calcom/ui/form/fields";
import { Button } from "@calcom/ui";
import Dropdown, { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@calcom/ui/Dropdown";
import { QueryCell } from "@lib/QueryCell";
import { DEFAULT_SCHEDULE } from "@lib/availability";
import { useLocale } from "@lib/hooks/useLocale";
import { HttpError } from "@lib/core/http/error";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { Schedule as ScheduleType } from "@lib/types/schedule";
import Shell from "@components/Shell";
import Schedule from "@components/ui/form/Schedule";
import { NewScheduleButton } from "@components/availability/NewScheduleButton";
dayjs.extend(utc);
dayjs.extend(timezone);
type FormValues = {
schedule: ScheduleType;
};
export function AvailabilityForm(props: inferQueryOutput<"viewer.availability">) {
const CreateFirstScheduleView = () => {
const { t } = useLocale();
const createSchedule = async ({ schedule }: FormValues) => {
const res = await fetch(`/api/schedule`, {
method: "POST",
body: JSON.stringify({ schedule, timeZone: props.timeZone }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
showToast(t("availability_updated_successfully"), "success");
return responseData.data;
};
const schema = z.object({
schedule: z
.object({
start: z.date(),
end: z.date(),
})
.superRefine((val, ctx) => {
if (dayjs(val.end).isBefore(dayjs(val.start))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid entry: End time can not be before start time",
path: ["end"],
});
}
})
.optional()
.array()
.array(),
});
const days = [
t("sunday_time_error"),
t("monday_time_error"),
t("tuesday_time_error"),
t("wednesday_time_error"),
t("thursday_time_error"),
t("friday_time_error"),
t("saturday_time_error"),
];
const form = useForm({
defaultValues: {
schedule: props.schedule || DEFAULT_SCHEDULE,
},
resolver: zodResolver(schema),
});
return (
<div className="grid grid-cols-3 gap-2">
<Form
form={form}
handleSubmit={async (values) => {
await createSchedule(values);
}}
className="col-span-3 space-y-2 lg:col-span-2">
<div className="divide-y rounded-sm border border-gray-200 bg-white px-4 py-5 sm:p-6">
<h3 className="mb-5 text-base font-medium leading-6 text-gray-900">{t("change_start_end")}</h3>
<Schedule name="schedule" />
</div>
{form.formState.errors.schedule && (
<Alert
className="mt-1"
severity="error"
message={
days[form.formState.errors.schedule.length - 1] + " : " + t("error_end_time_before_start_time")
}
/>
)}
<div className="text-right">
<Button>{t("save")}</Button>
</div>
</Form>
<div className="min-w-40 col-span-3 ltr:ml-2 rtl:mr-2 lg:col-span-1">
<div className="rounded-sm border border-gray-200 px-4 py-5 sm:p-6 ">
<h3 className="text-base font-medium leading-6 text-gray-900">
{t("something_doesnt_look_right")}
</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>{t("troubleshoot_availability")}</p>
</div>
<div className="mt-5">
<Button href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Button>
</div>
</div>
<div className="md:py-20">
<div className="mx-auto block text-center md:max-w-screen-sm">
<ClockIcon className="inline w-12 text-neutral-400" />
<h3 className="mt-2 text-xl font-bold text-neutral-900">{t("new_schedule_heading")}</h3>
<p className="text-md mt-1 mb-2 text-neutral-600">{t("new_schedule_description")}</p>
<NewScheduleButton name="first-new-schedule" />
</div>
</div>
);
};
export function AvailabilityList({ schedules }: inferQueryOutput<"viewer.availability.list">) {
const { t, i18n } = useLocale();
const deleteMutation = trpc.useMutation("viewer.availability.schedule.delete", {
onSuccess: async () => {
showToast(t("schedule_deleted_successfully"), "success");
window.location.reload();
},
onError: (err) => {
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
},
});
return (
<div className="-mx-4 mb-16 overflow-hidden rounded-sm border border-gray-200 bg-white sm:mx-0">
{schedules.length === 0 && <CreateFirstScheduleView />}
<ul className="divide-y divide-neutral-200" data-testid="schedules">
{schedules.map((schedule) => (
<li key={schedule.id}>
<div className="flex items-center justify-between py-5 hover:bg-neutral-50">
<div className="group flex w-full items-center justify-between hover:bg-neutral-50 sm:px-6">
<Link href={"/availability/" + schedule.id}>
<a className="flex-grow truncate text-sm" title={schedule.name}>
<div>
<span className="truncate font-medium text-neutral-900">{schedule.name}</span>
{schedule.isDefault && (
<span className="ml-2 inline items-center rounded-sm bg-yellow-100 px-1.5 py-0.5 text-xs font-medium text-yellow-800">
{t("default")}
</span>
)}
</div>
<p className="mt-1 text-xs text-neutral-500">
{schedule.availability.map((availability: Availability) => (
<>
{availabilityAsString(availability, i18n.language)}
<br />
</>
))}
</p>
</a>
</Link>
</div>
<Dropdown>
<DropdownMenuTrigger className="group mr-5 h-10 w-10 border border-transparent p-0 text-neutral-400 hover:border-gray-200">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<Button
onClick={() =>
deleteMutation.mutate({
scheduleId: schedule.id,
})
}
type="button"
color="minimal"
className="w-full font-normal"
StartIcon={TrashIcon}>
{t("delete_schedule")}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
</li>
))}
</ul>
</div>
);
}
export default function Availability() {
export default function AvailabilityPage() {
const { t } = useLocale();
const query = trpc.useQuery(["viewer.availability"]);
const query = trpc.useQuery(["viewer.availability.list"]);
return (
<div>
<Shell heading={t("availability")} subtitle={t("configure_availability")}>
<QueryCell query={query} success={({ data }) => <AvailabilityForm {...data} />} />
<Shell heading={t("availability")} subtitle={t("configure_availability")} CTA={<NewScheduleButton />}>
<QueryCell query={query} success={({ data }) => <AvailabilityList {...data} />} />
</Shell>
</div>
);

View File

@ -14,28 +14,32 @@ import {
} from "@heroicons/react/solid";
import { zodResolver } from "@hookform/resolvers/zod";
import { MembershipRole } from "@prisma/client";
import { Availability, EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { EventTypeCustomInput, PeriodType, Prisma, SchedulingType } from "@prisma/client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import * as RadioGroup from "@radix-ui/react-radio-group";
import classNames from "classnames";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FormattedNumber, IntlProvider } from "react-intl";
import Select from "react-select";
import Select, { Props as SelectProps } from "react-select";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import showToast from "@calcom/lib/notification";
import { StripeData } from "@calcom/stripe/server";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { Dialog, DialogContent, DialogTrigger } from "@calcom/ui/Dialog";
import Switch from "@calcom/ui/Switch";
import { Form } from "@calcom/ui/form/fields";
import { QueryCell } from "@lib/QueryCell";
import { asStringOrThrow, asStringOrUndefined } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
@ -54,7 +58,6 @@ import Shell from "@components/Shell";
import ConfirmationDialogContent from "@components/dialog/ConfirmationDialogContent";
import CustomInputTypeForm from "@components/pages/eventtypes/CustomInputTypeForm";
import InfoBadge from "@components/ui/InfoBadge";
import { Scheduler } from "@components/ui/Scheduler";
import CheckboxField from "@components/ui/form/CheckboxField";
import CheckedSelect from "@components/ui/form/CheckedSelect";
import { DateRangePicker } from "@components/ui/form/DateRangePicker";
@ -77,7 +80,6 @@ interface NFT extends Token {
// Some OpenSea NFTs have several contracts
contracts: Array<Token>;
}
type AvailabilityInput = Pick<Availability, "days" | "startTime" | "endTime">;
type OptionTypeBase = {
label: string;
@ -98,6 +100,41 @@ const addDefaultLocationOptions = (
});
};
const AvailabilitySelect = ({ className, ...props }: SelectProps) => {
const query = trpc.useQuery(["viewer.availability.list"]);
return (
<QueryCell
query={query}
success={({ data }) => {
const options = data.schedules.map((schedule) => ({
value: schedule.id,
label: schedule.name,
}));
const value = options.find((option) =>
props.value
? option.value === props.value
: option.value === data.schedules.find((schedule) => schedule.isDefault)?.id
);
return (
<Select
{...props}
options={options}
isSearchable={false}
classNamePrefix="react-select"
className={classNames(
"react-select-container focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-sm border border-gray-300 sm:text-sm",
className
)}
value={value}
/>
);
}}
/>
);
};
const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const { t } = useLocale();
const PERIOD_TYPES = [
@ -169,7 +206,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [editIcon, setEditIcon] = useState(true);
const [showLocationModal, setShowLocationModal] = useState(false);
const [selectedTimeZone, setSelectedTimeZone] = useState("");
const [selectedLocation, setSelectedLocation] = useState<OptionTypeBase | undefined>(undefined);
const [selectedCustomInput, setSelectedCustomInput] = useState<EventTypeCustomInput | undefined>(undefined);
const [selectedCustomInputModalOpen, setSelectedCustomInputModalOpen] = useState(false);
@ -185,11 +221,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
const [requirePayment, setRequirePayment] = useState(eventType.price > 0);
const [advancedSettingsVisible, setAdvancedSettingsVisible] = useState(false);
const [availabilityState, setAvailabilityState] = useState<{
openingHours: AvailabilityInput[];
dateOverrides: AvailabilityInput[];
}>({ openingHours: [], dateOverrides: [] });
useEffect(() => {
const fetchTokens = async () => {
// Get a list of most popular ERC20s and ERC777s, combine them into a single list, set as tokensList
@ -225,10 +256,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
fetchTokens();
}, []);
useEffect(() => {
setSelectedTimeZone(eventType.timeZone || "");
}, []);
async function deleteEventTypeHandler(event: React.MouseEvent<HTMLElement, MouseEvent>) {
event.preventDefault();
@ -383,11 +410,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
locations: { type: LocationType; address?: string; link?: string }[];
customInputs: EventTypeCustomInput[];
users: string[];
availability: {
openingHours: AvailabilityInput[];
dateOverrides: AvailabilityInput[];
};
timeZone: string;
schedule: number;
periodType: PeriodType;
periodDays: number;
periodCountCalendarDays: "1" | "0";
@ -403,6 +426,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
}>({
defaultValues: {
locations: eventType.locations || [],
schedule: eventType.schedule?.id,
periodDates: {
startDate: periodDates.startDate,
endDate: periodDates.endDate,
@ -748,7 +772,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
updateMutation.mutate({
...input,
locations,
availability: availabilityState,
periodStartDate: periodDates.startDate,
periodEndDate: periodDates.endDate,
periodCountCalendarDays: periodCountCalendarDays === "1",
@ -1346,8 +1369,6 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
</div>
</div>
<hr className="border-neutral-200" />
<div className="block sm:flex">
<div className="min-w-48 mb-4 sm:mb-0">
<label
@ -1358,33 +1379,27 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
</div>
<div className="w-full">
<Controller
name="availability"
name="schedule"
control={formMethods.control}
render={() => (
<Scheduler
setAvailability={(val) => {
const schedule = {
openingHours: val.openingHours,
dateOverrides: val.dateOverrides,
};
// Updating internal state that would be sent on mutation
setAvailabilityState(schedule);
// Updating form values displayed, but this one doesn't reach form submit scope
formMethods.setValue("availability", schedule);
}}
setTimeZone={(timeZone) => {
formMethods.setValue("timeZone", timeZone);
setSelectedTimeZone(timeZone);
}}
timeZone={selectedTimeZone}
availability={availability.map((schedule) => ({
...schedule,
startTime: new Date(schedule.startTime),
endTime: new Date(schedule.endTime),
}))}
render={({ field }) => (
<AvailabilitySelect
{...field}
onChange={(selected: { label: string; value: number }) =>
field.onChange(selected.value)
}
/>
)}
/>
<Link href="/availability">
<a>
<Alert
className="mt-1 text-xs"
severity="info"
message="You can manage your schedules on the Availability page."
/>
</a>
</Link>
</div>
</div>
@ -1802,6 +1817,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: userSelect,
},
schedulingType: true,
schedule: {
select: {
id: true,
},
},
userId: true,
price: true,
currency: true,

View File

@ -1,6 +1,6 @@
import { ArrowRightIcon } from "@heroicons/react/outline";
import { zodResolver } from "@hookform/resolvers/zod";
import { Prisma, IdentityProvider } from "@prisma/client";
import { IdentityProvider, Prisma } from "@prisma/client";
import classnames from "classnames";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
@ -35,9 +35,9 @@ import { Schedule as ScheduleType } from "@lib/types/schedule";
import { ClientSuspense } from "@components/ClientSuspense";
import Loader from "@components/Loader";
import Schedule from "@components/availability/Schedule";
import { CalendarListContainer } from "@components/integrations/CalendarListContainer";
import Text from "@components/ui/Text";
import Schedule from "@components/ui/form/Schedule";
import getEventTypes from "../lib/queries/event-types/get-event-types";
@ -134,21 +134,11 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
return responseData.data;
};
const createSchedule = async ({ schedule }: ScheduleFormValues) => {
const res = await fetch(`/api/schedule`, {
method: "POST",
body: JSON.stringify({ schedule }),
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error((await res.json()).message);
}
const responseData = await res.json();
return responseData.data;
};
const createSchedule = trpc.useMutation("viewer.availability.schedule.create", {
onError: (err) => {
throw new Error(err.message);
},
});
/** Name */
const nameRef = useRef<HTMLInputElement>(null);
@ -444,7 +434,10 @@ export default function Onboarding(props: inferSSRProps<typeof getServerSideProp
handleSubmit={async (values) => {
try {
setSubmitting(true);
await createSchedule({ ...values });
await createSchedule.mutate({
name: t("default_schedule_name"),
...values,
});
debouncedHandleConfirmStep();
setSubmitting(false);
} catch (error) {

View File

@ -137,8 +137,6 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const localeOptions = useMemo(() => {
return (router.locales || []).map((locale) => ({
value: locale,
// FIXME
// @ts-ignore
label: new Intl.DisplayNames(props.localeProp, { type: "language" }).of(locale) || "",
}));
}, [props.localeProp, router.locales]);

View File

@ -69,6 +69,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
timeZone: true,
slotInterval: true,
metadata: true,
schedule: {
select: {
timeZone: true,
availability: true,
},
},
},
},
},
@ -82,13 +88,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const [eventType] = team.eventTypes;
const timeZone = eventType.schedule?.timeZone || eventType.timeZone || undefined;
const workingHours = getWorkingHours(
{
timeZone: eventType.timeZone || undefined,
timeZone,
},
eventType.availability
eventType.schedule?.availability || eventType.availability
);
eventType.schedule = null;
const eventTypeObject = Object.assign({}, eventType, {
metadata: (eventType.metadata || {}) as JSONObject,
periodStartDate: eventType.periodStartDate?.toString() ?? null,

View File

@ -670,6 +670,16 @@
"prisma_studio_tip_description": "Learn how to set up your first user",
"contact_sales": "Contact Sales",
"error_404": "Error 404",
"default": "Default",
"set_to_default": "Set to Default",
"new_schedule_btn": "New schedule",
"add_new_schedule": "Add a new schedule",
"delete_schedule": "Delete schedule",
"schedule_created_successfully": "{{scheduleName}} schedule created successfully",
"schedule_deleted_successfully": "Schedule deleted successfully",
"default_schedule_name": "Working Hours",
"new_schedule_heading": "Create an availability schedule",
"new_schedule_description": "Creating availability schedules allows you to manage availability across event types. They can be applied to one or more event types.",
"requires_ownership_of_a_token": "Requires ownership of a token belonging to the following address:",
"example_name": "John Doe",
"time_format": "Time format",

View File

@ -520,5 +520,8 @@
"calendar": "Agenda",
"not_installed": "Niet geïnstalleerd",
"error_password_mismatch": "Wachtwoorden komen niet overeen.",
"error_required_field": "Dit veld is verplicht."
"error_required_field": "Dit veld is verplicht.",
"default": "standaard keuze",
"set_to_default": "Zet als standaard keuze",
"click_here_to_add_a_new_schedule": "Klik hier om een nieuwe planning aan te maken"
}

View File

@ -37,6 +37,7 @@ async function getUserFromSession({
weekStart: true,
startTime: true,
endTime: true,
defaultScheduleId: true,
bufferTime: true,
theme: true,
createdDate: true,

View File

@ -19,8 +19,8 @@ import {
samlTenantProduct,
} from "@lib/saml";
import slugify from "@lib/slugify";
import { Schedule } from "@lib/types/schedule";
import { availabilityRouter } from "@server/routers/viewer/availability";
import { eventTypesRouter } from "@server/routers/viewer/eventTypes";
import { TRPCError } from "@trpc/server";
@ -564,48 +564,6 @@ const loggedInViewerRouter = createProtectedRouter()
};
},
})
.query("availability", {
async resolve({ ctx }) {
const { prisma, user } = ctx;
const availabilityQuery = await prisma.availability.findMany({
where: {
userId: user.id,
},
});
const schedule = availabilityQuery.reduce(
(schedule: Schedule, availability) => {
availability.days.forEach((day) => {
schedule[day].push({
start: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.startTime.getUTCHours(),
availability.startTime.getUTCMinutes()
)
),
end: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.endTime.getUTCHours(),
availability.endTime.getUTCMinutes()
)
),
});
});
return schedule;
},
Array.from([...Array(7)]).map(() => [])
);
return {
schedule,
timeZone: user.timeZone,
};
},
})
.mutation("updateProfile", {
input: z.object({
username: z.string().optional(),
@ -840,5 +798,6 @@ export const viewerRouter = createRouter()
.merge(publicViewerRouter)
.merge(loggedInViewerRouter)
.merge("eventTypes.", eventTypesRouter)
.merge("availability.", availabilityRouter)
.merge("teams.", viewerTeamsRouter)
.merge("webhook.", webhookRouter);

View File

@ -0,0 +1,218 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { getAvailabilityFromSchedule } from "@lib/availability";
import { Schedule } from "@lib/types/schedule";
import { createProtectedRouter } from "@server/createRouter";
import { TRPCError } from "@trpc/server";
export const availabilityRouter = createProtectedRouter()
.query("list", {
async resolve({ ctx }) {
const { prisma, user } = ctx;
const schedules = await prisma.schedule.findMany({
where: {
userId: user.id,
},
select: {
id: true,
name: true,
availability: true,
timeZone: true,
},
orderBy: {
id: "asc",
},
});
return {
schedules: schedules.map((schedule) => ({
...schedule,
isDefault: user.defaultScheduleId === schedule.id || schedules.length === 1,
})),
};
},
})
.query("schedule", {
input: z.object({
scheduleId: z.number(),
}),
async resolve({ ctx, input }) {
const { prisma, user } = ctx;
const schedule = await prisma.schedule.findUnique({
where: {
id: input.scheduleId,
},
select: {
id: true,
userId: true,
name: true,
availability: true,
timeZone: true,
},
});
if (!schedule || schedule.userId !== user.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
const availability = schedule.availability.reduce(
(schedule: Schedule, availability) => {
availability.days.forEach((day) => {
schedule[day].push({
start: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.startTime.getUTCHours(),
availability.startTime.getUTCMinutes()
)
),
end: new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
availability.endTime.getUTCHours(),
availability.endTime.getUTCMinutes()
)
),
});
});
return schedule;
},
Array.from([...Array(7)]).map(() => [])
);
return {
schedule,
availability,
timeZone: schedule.timeZone || user.timeZone,
isDefault: !user.defaultScheduleId || user.defaultScheduleId === schedule.id,
};
},
})
.mutation("schedule.create", {
input: z.object({
name: z.string(),
copyScheduleId: z.number().optional(),
schedule: z
.array(
z.array(
z.object({
start: z.date(),
end: z.date(),
})
)
)
.optional(),
}),
async resolve({ input, ctx }) {
const { user, prisma } = ctx;
const data: Prisma.ScheduleCreateInput = {
name: input.name,
user: {
connect: {
id: user.id,
},
},
};
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 schedule = await prisma.schedule.create({
data,
});
return { schedule };
},
})
.mutation("schedule.delete", {
input: z.object({
scheduleId: z.number(),
}),
async resolve({ input, ctx }) {
const { user, prisma } = ctx;
if (user.defaultScheduleId === input.scheduleId) {
// unset default
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId: undefined,
},
});
}
await prisma.schedule.delete({
where: {
id: input.scheduleId,
},
});
},
})
.mutation("schedule.update", {
input: z.object({
scheduleId: z.number(),
timeZone: z.string().optional(),
name: z.string().optional(),
isDefault: z.boolean().optional(),
schedule: z.array(
z.array(
z.object({
start: z.date(),
end: z.date(),
})
)
),
}),
async resolve({ input, ctx }) {
const { user, prisma } = ctx;
const availability = getAvailabilityFromSchedule(input.schedule);
if (input.isDefault) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId: input.scheduleId,
},
});
}
await prisma.schedule.update({
where: {
id: input.scheduleId,
},
data: {
timeZone: input.timeZone,
name: input.name,
availability: {
deleteMany: {
scheduleId: {
equals: input.scheduleId,
},
},
createMany: {
data: availability.map((schedule) => ({
days: schedule.days,
startTime: schedule.startTime,
endTime: schedule.endTime,
})),
},
},
},
});
},
});

View File

@ -63,27 +63,16 @@ function handleCustomInputs(customInputs: EventTypeCustomInput[], eventTypeId: n
};
}
const AvailabilityInput = _AvailabilityModel.pick({
days: true,
startTime: true,
endTime: true,
});
const EventTypeUpdateInput = _EventTypeModel
/** Optional fields */
.extend({
availability: z
.object({
openingHours: z.array(AvailabilityInput).optional(),
dateOverrides: z.array(AvailabilityInput).optional(),
})
.optional(),
customInputs: z.array(_EventTypeCustomInputModel),
destinationCalendar: _DestinationCalendarModel.pick({
integration: true,
externalId: true,
}),
users: z.array(stringOrNumber).optional(),
schedule: z.number().optional(),
})
.partial()
.merge(
@ -190,7 +179,7 @@ export const eventTypesRouter = createProtectedRouter()
.mutation("update", {
input: EventTypeUpdateInput.strict(),
async resolve({ ctx, input }) {
const { availability, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
const { schedule, periodType, locations, destinationCalendar, customInputs, users, id, ...rest } =
input;
const data: Prisma.EventTypeUpdateInput = rest;
data.locations = locations ?? undefined;
@ -211,6 +200,14 @@ export const eventTypesRouter = createProtectedRouter()
data.customInputs = handleCustomInputs(customInputs, id);
}
if (schedule) {
data.schedule = {
connect: {
id: schedule,
},
};
}
if (users) {
data.users = {
set: [],
@ -218,20 +215,6 @@ export const eventTypesRouter = createProtectedRouter()
};
}
if (availability?.openingHours) {
await ctx.prisma.availability.deleteMany({
where: {
eventTypeId: input.id,
},
});
data.availability = {
createMany: {
data: availability.openingHours,
},
};
}
const eventType = await ctx.prisma.eventType.update({
where: { id },
data,

View File

@ -9,6 +9,14 @@
--brand-text-color-dark-mode: #292929;
}
button[role="switch"][data-state="checked"] {
@apply bg-gray-900;
}
button[role="switch"][data-state="checked"] span {
transform: translateX(16px);
}
/* PhoneInput dark-mode overrides (it would add a lot of boilerplate to do this in JavaScript) */
.PhoneInputInput {
@apply border-0 text-sm focus:ring-0;

View File

@ -3,6 +3,7 @@ import customParseFormat from "dayjs/plugin/customParseFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { nameOfDay } from "@calcom/lib/weekday";
import type { Availability } from "@calcom/prisma/client";
import type { Schedule, TimeRange, WorkingHours } from "@calcom/types/schedule";
@ -38,7 +39,9 @@ export function getAvailabilityFromSchedule(schedule: Schedule): Availability[]
let idx;
if (
(idx = availability.findIndex(
(schedule) => schedule.startTime === time.start && schedule.endTime === time.end
(schedule) =>
schedule.startTime.toString() === time.start.toString() &&
schedule.endTime.toString() === time.end.toString()
)) !== -1
) {
availability[idx].days.push(day);
@ -124,3 +127,41 @@ export function getWorkingHours(
return workingHours;
}
export function availabilityAsString(availability: Availability, locale: string) {
const weekSpan = (availability: Availability) => {
const days = availability.days.slice(1).reduce(
(days, day) => {
if (days[days.length - 1].length === 1 && days[days.length - 1][0] === day - 1) {
// append if the range is not complete (but the next day needs adding)
days[days.length - 1].push(day);
} else if (days[days.length - 1][days[days.length - 1].length - 1] === day - 1) {
// range complete, overwrite if the last day directly preceeds the current day
days[days.length - 1] = [days[days.length - 1][0], day];
} else {
// new range
days.push([day]);
}
return days;
},
[[availability.days[0]]] as number[][]
);
return days
.map((dayRange) => dayRange.map((day) => nameOfDay(locale, day, "short")).join(" - "))
.join(", ");
};
const timeSpan = (availability: Availability) => {
return (
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric" }).format(
new Date(availability.startTime.toISOString().slice(0, -1))
) +
" - " +
new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "numeric" }).format(
new Date(availability.endTime.toISOString().slice(0, -1))
)
);
};
return weekSpan(availability) + ", " + timeSpan(availability);
}

8
packages/lib/weekday.ts Normal file
View File

@ -0,0 +1,8 @@
// By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday)
export function weekdayNames(locale: string | string[], weekStart = 0, type: "short" | "long" = "long") {
return Array.from(Array(7).keys()).map((d) => nameOfDay(locale, d + weekStart, type));
}
export function nameOfDay(locale: string | string[], day: number, type: "short" | "long" = "long") {
return new Intl.DateTimeFormat(locale, { weekday: type }).format(new Date(1970, 0, day + 4));
}

View File

@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `label` on the `Availability` table. All the data in the column will be lost.
- You are about to drop the column `freeBusyTimes` on the `Schedule` table. All the data in the column will be lost.
- You are about to drop the column `title` on the `Schedule` table. All the data in the column will be lost.
- A unique constraint covering the columns `[eventTypeId]` on the table `Schedule` will be added. If there are existing duplicate values, this will fail.
- Added the required column `name` to the `Schedule` table without a default value. This is not possible if the table is not empty.
- Made the column `userId` on table `Schedule` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Availability" DROP COLUMN "label",
ADD COLUMN "scheduleId" INTEGER;
-- AlterTable
ALTER TABLE "Schedule" DROP COLUMN "freeBusyTimes",
DROP COLUMN "title",
ADD COLUMN "name" TEXT NOT NULL,
ADD COLUMN "timeZone" TEXT,
ALTER COLUMN "userId" SET NOT NULL;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "defaultScheduleId" INTEGER;
-- CreateIndex
CREATE UNIQUE INDEX "Schedule_eventTypeId_key" ON "Schedule"("eventTypeId");
-- AddForeignKey
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -62,7 +62,7 @@ model EventType {
beforeEventBuffer Int @default(0)
afterEventBuffer Int @default(0)
schedulingType SchedulingType?
Schedule Schedule[]
schedule Schedule?
price Int @default(0)
currency String @default("usd")
slotInterval Int?
@ -128,7 +128,8 @@ model User {
credentials Credential[]
teams Membership[]
bookings Booking[]
availability Availability[]
schedules Schedule[]
defaultScheduleId Int?
selectedCalendars SelectedCalendar[]
completedOnboarding Boolean @default(false)
locale String?
@ -137,9 +138,9 @@ model User {
twoFactorEnabled Boolean @default(false)
identityProvider IdentityProvider @default(CAL)
identityProviderId String?
availability Availability[]
invitedTo Int?
plan UserPlan @default(TRIAL)
Schedule Schedule[]
webhooks Webhook[]
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
@ -255,18 +256,18 @@ model Booking {
}
model Schedule {
id Int @id @default(autoincrement())
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int?
title String?
freeBusyTimes Json?
id Int @id @default(autoincrement())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int
eventType EventType? @relation(fields: [eventTypeId], references: [id])
eventTypeId Int? @unique
name String
timeZone String?
availability Availability[]
}
model Availability {
id Int @id @default(autoincrement())
label String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
eventType EventType? @relation(fields: [eventTypeId], references: [id])
@ -275,6 +276,8 @@ model Availability {
startTime DateTime @db.Time
endTime DateTime @db.Time
date DateTime? @db.Date
Schedule Schedule? @relation(fields: [scheduleId], references: [id])
scheduleId Int?
}
model SelectedCalendar {

View File

@ -29,11 +29,18 @@ async function createUserAndEventType(opts: {
emailVerified: new Date(),
completedOnboarding: opts.user.completedOnboarding ?? true,
locale: "en",
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
},
},
schedules: opts.user.completedOnboarding
? {
create: {
name: "Working Hours",
availability: {
createMany: {
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
},
},
},
}
: undefined,
};
const user = await prisma.user.upsert({
where: { email: opts.user.email },

View File

@ -1,4 +1,4 @@
import { CheckCircleIcon, InformationCircleIcon, XCircleIcon } from "@heroicons/react/solid";
import { CheckCircleIcon, ExclamationIcon, InformationCircleIcon, XCircleIcon } from "@heroicons/react/solid";
import classNames from "classnames";
import { ReactNode } from "react";
@ -7,7 +7,7 @@ export interface AlertProps {
message?: ReactNode;
actions?: ReactNode;
className?: string;
severity: "success" | "warning" | "error";
severity: "success" | "warning" | "error" | "info";
}
export function Alert(props: AlertProps) {
const { severity } = props;
@ -19,6 +19,7 @@ export function Alert(props: AlertProps) {
props.className,
severity === "error" && "border-red-900 bg-red-50 text-red-800",
severity === "warning" && "border-yellow-700 bg-yellow-50 text-yellow-700",
severity === "info" && "border-sky-700 bg-sky-50 text-sky-700",
severity === "success" && "bg-gray-900 text-white"
)}>
<div className="flex">
@ -27,7 +28,10 @@ export function Alert(props: AlertProps) {
<XCircleIcon className={classNames("h-5 w-5 text-red-400")} aria-hidden="true" />
)}
{severity === "warning" && (
<InformationCircleIcon className={classNames("h-5 w-5 text-yellow-400")} aria-hidden="true" />
<ExclamationIcon className={classNames("h-5 w-5 text-yellow-400")} aria-hidden="true" />
)}
{severity === "info" && (
<InformationCircleIcon className={classNames("h-5 w-5 text-sky-400")} aria-hidden="true" />
)}
{severity === "success" && (
<CheckCircleIcon className={classNames("h-5 w-5 text-gray-400")} aria-hidden="true" />

View File

@ -1,37 +1,22 @@
import { useId } from "@radix-ui/react-id";
import * as Label from "@radix-ui/react-label";
import * as PrimitiveSwitch from "@radix-ui/react-switch";
import React, { useState } from "react";
import React from "react";
import classNames from "@calcom/lib/classNames";
type SwitchProps = React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label: string;
};
export default function Switch(props: SwitchProps) {
const { label, onCheckedChange, ...primitiveProps } = props;
const [checked, setChecked] = useState(props.defaultChecked || false);
const onPrimitiveCheckedChange = (change: boolean) => {
if (onCheckedChange) {
onCheckedChange(change);
}
setChecked(change);
};
const Switch = (
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
label: string;
}
) => {
const { label, ...primitiveProps } = props;
const id = useId();
return (
<div className="flex h-[20px] items-center">
<PrimitiveSwitch.Root
className={classNames(checked ? "bg-gray-900" : "bg-gray-400", "h-[20px] w-[36px] rounded-sm p-0.5")}
checked={checked}
onCheckedChange={onPrimitiveCheckedChange}
{...primitiveProps}>
<PrimitiveSwitch.Root className="h-[20px] w-[36px] rounded-sm bg-gray-400 p-0.5" {...primitiveProps}>
<PrimitiveSwitch.Thumb
id={id}
className={classNames(
"block h-[16px] w-[16px] bg-white transition-transform",
checked ? "translate-x-[16px]" : "translate-x-0"
)}
className={"block h-[16px] w-[16px] translate-x-0 bg-white transition-transform"}
/>
</PrimitiveSwitch.Root>
{label && (
@ -43,4 +28,6 @@ export default function Switch(props: SwitchProps) {
)}
</div>
);
}
};
export default Switch;

1080
yarn.lock

File diff suppressed because it is too large Load Diff