First public commit

This commit is contained in:
Luke Vella 2022-04-12 07:14:28 +01:00
commit e05cd62e53
228 changed files with 17717 additions and 0 deletions

View file

@ -0,0 +1,7 @@
export type { PollDetailsData } from "./poll-details-form";
export { PollDetailsForm } from "./poll-details-form";
export type { PollOptionsData } from "./poll-options-form/poll-options-form";
export { default as PollOptionsForm } from "./poll-options-form/poll-options-form";
export * from "./types";
export type { UserDetailsData } from "./user-details-form";
export { UserDetailsForm } from "./user-details-form";

View file

@ -0,0 +1,76 @@
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation";
import { PollFormProps } from "./types";
export interface PollDetailsData {
title: string;
location: string;
description: string;
}
export const PollDetailsForm: React.VoidFunctionComponent<
PollFormProps<PollDetailsData>
> = ({ name, defaultValues, onSubmit, onChange, className }) => {
const { t } = useTranslation("app");
const {
handleSubmit,
register,
watch,
formState: { errors },
} = useForm<PollDetailsData>({ defaultValues });
React.useEffect(() => {
if (onChange) {
const subscription = watch(onChange);
return () => {
subscription.unsubscribe();
};
}
}, [onChange, watch]);
return (
<form
id={name}
className={clsx("max-w-full", className)}
style={{ width: 500 }}
onSubmit={handleSubmit(onSubmit)}
>
<div className="formField">
<label htmlFor="title">{t("title")}</label>
<input
type="text"
id="title"
className={clsx("input w-full", {
"input-error": errors.title,
})}
placeholder={t("titlePlaceholder")}
{...register("title", { validate: requiredString })}
/>
</div>
<div className="formField">
<label htmlFor="location">{t("location")}</label>
<input
type="text"
id="location"
className="input w-full"
placeholder={t("locationPlaceholder")}
{...register("location")}
/>
</div>
<div className="formField">
<label htmlFor="description">{t("description")}</label>
<textarea
id="description"
className="input w-full"
placeholder={t("descriptionPlaceholder")}
rows={5}
{...register("description")}
/>
</div>
</form>
);
};

View file

@ -0,0 +1,39 @@
import * as React from "react";
import ChevronLeft from "../../icons/chevron-left.svg";
import ChevronRight from "../../icons/chevron-right.svg";
export interface DateNavigationToolbarProps {
year: number;
label: string;
onPrevious: () => void;
onNext: () => void;
onToday: () => void;
}
const DateNavigationToolbar: React.VoidFunctionComponent<DateNavigationToolbarProps> =
({ year, label, onPrevious, onToday, onNext }) => {
return (
<div className="flex border-b items-center w-full px-4 h-14 shrink-0">
<div className="grow">
<span className="text-sm font-bold text-gray-400 mr-2">{year}</span>
<span className="text-lg font-bold text-gray-700">{label}</span>
</div>
<div className="flex space-x-2 items-center">
<div className="segment-button">
<button type="button" onClick={onPrevious}>
<ChevronLeft className="h-5" />
</button>
<button type="button" onClick={onToday}>
Today
</button>
<button type="button" onClick={onNext}>
<ChevronRight className="h-5" />
</button>
</div>
</div>
</div>
);
};
export default DateNavigationToolbar;

View file

@ -0,0 +1,2 @@
export { default } from "./poll-options-form";
export * from "./types";

View file

@ -0,0 +1 @@
export { default } from "./month-calendar";

View file

@ -0,0 +1,426 @@
import clsx from "clsx";
import differenceInMinutes from "date-fns/differenceInMinutes";
import { addMinutes, setHours } from "date-fns/esm";
import isSameDay from "date-fns/isSameDay";
import { usePlausible } from "next-plausible";
import * as React from "react";
import { DateTimeOption } from "..";
import {
expectTimeOption,
getDateProps,
removeAllOptionsForDay,
} from "../../../../utils/date-time-utils";
import Button from "../../../button";
import CompactButton from "../../../compact-button";
import DateCard from "../../../date-card";
import Dropdown, { DropdownItem } from "../../../dropdown";
import { useHeadlessDatePicker } from "../../../headless-date-picker";
import Calendar from "../../../icons/calendar.svg";
import ChevronLeft from "../../../icons/chevron-left.svg";
import ChevronRight from "../../../icons/chevron-right.svg";
import DotsHorizontal from "../../../icons/dots-horizontal.svg";
import Magic from "../../../icons/magic.svg";
import PlusSm from "../../../icons/plus-sm.svg";
import Trash from "../../../icons/trash.svg";
import X from "../../../icons/x.svg";
import Switch from "../../../switch";
import { DateTimePickerProps } from "../types";
import { formatDateWithoutTime, formatDateWithoutTz } from "../utils";
import TimePicker from "./time-picker";
const MonthCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
options,
onNavigate,
date,
onChange,
duration,
onChangeDuration,
}) => {
const isTimedEvent = options.some((option) => option.type === "timeSlot");
const plausible = usePlausible();
const optionsByDay = React.useMemo(() => {
const res: Record<
string,
[
{
option: DateTimeOption;
index: number;
},
]
> = {};
options.forEach((option, index) => {
const dateString =
option.type === "date"
? option.date
: option.start.substring(0, option.start.indexOf("T"));
if (res[dateString]) {
res[dateString].push({ option, index });
} else {
res[dateString] = [{ option, index }];
}
});
return res;
}, [options]);
const datepickerSelection = React.useMemo(() => {
return Object.keys(optionsByDay).map(
(dateString) => new Date(dateString + "T12:00:00"),
);
}, [optionsByDay]);
const datepicker = useHeadlessDatePicker({
selection: datepickerSelection,
onNavigationChange: onNavigate,
date,
});
return (
<div className="lg:flex overflow-hidden">
<div className="p-4 border-b lg:border-r lg:border-b-0 shrink-0">
<div>
<div className="w-full flex flex-col">
<div className="flex space-x-4 items-center justify-center mb-3">
<Button
icon={<ChevronLeft />}
title="Previous month"
onClick={datepicker.prev}
/>
<div className="grow text-center font-medium text-lg">
{datepicker.label}
</div>
<Button
title="Next month"
icon={<ChevronRight />}
onClick={datepicker.next}
/>
</div>
<div className="grid grid-cols-7">
{datepicker.daysOfWeek.map((dayOfWeek) => {
return (
<div
key={dayOfWeek}
className="flex items-center justify-center pb-2 text-slate-400 text-sm font-medium"
>
{dayOfWeek.substring(0, 2)}
</div>
);
})}
</div>
<div className="grid grid-cols-7 grow border shadow-sm rounded-lg overflow-hidden bg-white">
{datepicker.days.map((day, i) => {
return (
<button
type="button"
key={i}
onClick={() => {
if (
datepicker.selection.some((selectedDate) =>
isSameDay(selectedDate, day.date),
)
) {
onChange(removeAllOptionsForDay(options, day.date));
} else {
const selectedDate = setHours(day.date, 12);
const newOption: DateTimeOption = !isTimedEvent
? {
type: "date",
date: formatDateWithoutTime(selectedDate),
}
: {
type: "timeSlot",
start: formatDateWithoutTz(selectedDate),
end: formatDateWithoutTz(
addMinutes(selectedDate, duration),
),
};
onChange([...options, newOption]);
onNavigate(selectedDate);
}
if (day.outOfMonth) {
if (i < 6) {
datepicker.prev();
} else {
datepicker.next();
}
}
}}
className={clsx(
"flex items-center relative lg:w-14 justify-center focus:ring-0 focus:ring-offset-0 hover:bg-slate-50 px-4 py-3 text-sm active:bg-slate-100",
{
"text-slate-400 bg-slate-50": day.outOfMonth,
"font-bold text-indigo-500": day.today,
"border-r": (i + 1) % 7 !== 0,
"border-b": i < datepicker.days.length - 7,
"font-normal after:content-[''] after:animate-popIn after:absolute after:w-8 after:h-8 after:rounded-full after:bg-green-500 after:-z-0 text-white":
day.selected,
},
)}
>
<span className="z-10">{day.day}</span>
</button>
);
})}
</div>
<Button className="mt-3" onClick={datepicker.today}>
Today
</Button>
</div>
</div>
</div>
<div className="grow flex flex-col">
<div
className={clsx("border-b", {
hidden: datepicker.selection.length === 0,
})}
>
<div className="p-4 flex space-x-3 items-center">
<div className="grow">
<div className="font-medium">Specify times</div>
<div className="text-sm text-slate-400">
Include start and end times for each option
</div>
</div>
<div>
<Switch
checked={isTimedEvent}
onChange={(checked) => {
if (checked) {
// convert dates to time slots
onChange(
options.map((option) => {
if (option.type === "timeSlot") {
throw new Error(
"Expected option to be a date but received timeSlot",
);
}
const startDate = new Date(`${option.date}T12:00:00`);
const endDate = addMinutes(startDate, duration);
return {
type: "timeSlot",
start: formatDateWithoutTz(startDate),
end: formatDateWithoutTz(endDate),
};
}),
);
} else {
onChange(
datepicker.selection.map((date) => ({
type: "date",
date: formatDateWithoutTime(date),
})),
);
}
}}
/>
</div>
</div>
</div>
<div className="grow px-4">
{isTimedEvent ? (
<div className="divide-y">
{Object.keys(optionsByDay)
.sort((a, b) => (a > b ? 1 : -1))
.map((dateString) => {
const optionsForDay = optionsByDay[dateString];
return (
<div
key={dateString}
className="py-4 space-y-3 xs:space-y-0 xs:space-x-4 xs:flex"
>
<div>
<DateCard
{...getDateProps(new Date(dateString + "T12:00:00"))}
/>
</div>
<div className="grow space-y-3">
{optionsForDay.map(({ option, index }) => {
if (option.type === "date") {
throw new Error("Expected timeSlot but got date");
}
const startDate = new Date(option.start);
return (
<div
key={index}
className="flex space-x-3 items-center"
>
<TimePicker
value={startDate}
onChange={(newStart) => {
const newEnd = addMinutes(newStart, duration);
// replace enter with updated start time
onChange([
...options.slice(0, index),
{
...option,
start: formatDateWithoutTz(newStart),
end: formatDateWithoutTz(newEnd),
},
...options.slice(index + 1),
]);
onNavigate(newStart);
onChangeDuration(
differenceInMinutes(newEnd, newStart),
);
}}
/>
<TimePicker
value={new Date(option.end)}
startFrom={addMinutes(startDate, 15)}
onChange={(newEnd) => {
onChange([
...options.slice(0, index),
{
...option,
end: formatDateWithoutTz(newEnd),
},
...options.slice(index + 1),
]);
onNavigate(newEnd);
onChangeDuration(
differenceInMinutes(newEnd, startDate),
);
}}
/>
<CompactButton
icon={X}
onClick={() => {
onChange([
...options.slice(0, index),
...options.slice(index + 1),
]);
}}
/>
</div>
);
})}
<div className="flex space-x-3 items-center">
<Button
icon={<PlusSm />}
onClick={() => {
const lastOption = expectTimeOption(
optionsForDay[optionsForDay.length - 1].option,
);
const startTime = lastOption.start;
onChange([
...options,
{
type: "timeSlot",
start: startTime,
end: formatDateWithoutTz(
addMinutes(new Date(startTime), duration),
),
},
]);
}}
>
Add time option
</Button>
<Dropdown
trigger={<CompactButton icon={DotsHorizontal} />}
placement="bottom-start"
>
<DropdownItem
icon={Magic}
disabled={datepicker.selection.length < 2}
label="Apply to all dates"
onClick={() => {
plausible("Applied options to all dates");
const times = optionsForDay.map(
({ option }) => {
if (option.type === "date") {
throw new Error(
"Expected timeSlot but got date",
);
}
return {
startTime: option.start.substring(
option.start.indexOf("T"),
),
endTime: option.end.substring(
option.end.indexOf("T"),
),
};
},
);
const newOptions: DateTimeOption[] = [];
Object.keys(optionsByDay).forEach(
(dateString) => {
times.forEach((time) => {
newOptions.push({
type: "timeSlot",
start: dateString + time.startTime,
end: dateString + time.endTime,
});
});
},
);
onChange(newOptions);
}}
/>
<DropdownItem
label="Delete date"
icon={Trash}
onClick={() => {
onChange(
removeAllOptionsForDay(
options,
new Date(dateString),
),
);
}}
/>
</Dropdown>
</div>
</div>
</div>
);
})}
</div>
) : datepicker.selection.length ? (
<div className="grid grid-cols-[repeat(auto-fill,60px)] gap-5 py-4">
{datepicker.selection
.sort((a, b) => a.getTime() - b.getTime())
.map((selectedDate, i) => {
return (
<DateCard
key={i}
{...getDateProps(selectedDate)}
annotation={
<CompactButton
icon={X}
onClick={() => {
// TODO (Luke Vella) [2022-03-19]: Find cleaner way to manage this state
// Quite tedious right now to remove a single element
onChange(
removeAllOptionsForDay(options, selectedDate),
);
}}
/>
}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center py-12">
<div className="text-center font-medium text-gray-400">
<Calendar className="inline-block h-12 w-12 mb-2" />
<div>No dates selected</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default MonthCalendar;

View file

@ -0,0 +1,107 @@
import { Combobox } from "@headlessui/react";
import clsx from "clsx";
import { addMinutes, format, isSameDay, setHours, setMinutes } from "date-fns";
import * as React from "react";
import ReactDOM from "react-dom";
import { usePopper } from "react-popper";
import ChevronDown from "../../../icons/chevron-down.svg";
import { styleMenuItem } from "../../../menu-styles";
export interface TimePickerProps {
value: Date;
startFrom?: Date;
className?: string;
onChange?: (value: Date) => void;
}
const TimePicker: React.VoidFunctionComponent<TimePickerProps> = ({
value,
onChange,
className,
startFrom = setMinutes(setHours(value, 0), 0),
}) => {
const [referenceElement, setReferenceElement] =
React.useState<HTMLDivElement | null>(null);
const [popperElement, setPopperElement] =
React.useState<HTMLUListElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
modifiers: [
{
name: "offset",
options: {
offset: [0, 5],
},
},
],
});
const [query, setQuery] = React.useState("");
const options: React.ReactNode[] = [];
for (let i = 0; i < 96; i++) {
const optionValue = addMinutes(startFrom, i * 15);
if (!isSameDay(value, optionValue)) {
// we only support event that start and end on the same day for now
// because react-big-calendar does not support events that span days
break;
}
if (query && !format(optionValue, "hhmma").includes(query)) {
continue;
}
options.push(
<Combobox.Option
key={i}
className={styleMenuItem}
value={optionValue.toISOString()}
>
{format(optionValue, "p")}
</Combobox.Option>,
);
}
const portal = document.getElementById("portal");
return (
<Combobox
value={value.toISOString()}
onChange={(newValue) => {
setQuery("");
onChange?.(new Date(newValue));
}}
>
<div ref={setReferenceElement} className={clsx("relative", className)}>
{/* Remove generic params once Combobox.Input can infer the types */}
<Combobox.Input<"input">
className="input w-28 pr-8"
displayValue={() => ""}
onChange={(e) => {
setQuery(e.target.value.toUpperCase().replace(/[\:\s]/g, ""));
}}
/>
<Combobox.Button className="absolute inset-0 flex items-center cursor-default px-2 h-9 text-left">
<span className="grow truncate">
{!query ? format(value, "p") : null}
</span>
<span className="flex pointer-events-none">
<ChevronDown className="w-5 h-5" />
</span>
</Combobox.Button>
{portal &&
ReactDOM.createPortal(
<Combobox.Options
style={styles.popper}
{...attributes.popper}
ref={setPopperElement}
className="z-50 w-32 py-1 overflow-auto bg-white rounded-md shadow-lg max-h-72 ring-1 ring-black ring-opacity-5 focus:outline-none"
>
{options}
</Combobox.Options>,
portal,
)}
</div>
</Combobox>
);
};
export default TimePicker;

View file

@ -0,0 +1,233 @@
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { Controller, useForm } from "react-hook-form";
import { getBrowserTimeZone } from "../../../utils/date-time-utils";
import FullPageLoader from "../../full-page-loader";
import Calendar from "../../icons/calendar.svg";
import Table from "../../icons/table.svg";
import { useModal } from "../../modal";
import TimeZonePicker from "../../time-zone-picker";
import { PollFormProps } from "../types";
import { DateTimeOption } from "./types";
const WeekCalendar = React.lazy(() => import("./week-calendar"));
const MonthCalendar = React.lazy(() => import("./month-calendar"));
export type PollOptionsData = {
navigationDate: string; // used to navigate to the right part of the calendar
duration: number; // duration of the event in minutes
timeZone: string;
view: string;
options: DateTimeOption[];
};
const PollOptionsForm: React.VoidFunctionComponent<
PollFormProps<PollOptionsData> & { title?: string }
> = ({ name, defaultValues, onSubmit, onChange, title, className }) => {
const { t } = useTranslation("app");
const { control, handleSubmit, watch, setValue, formState } =
useForm<PollOptionsData>({
defaultValues: {
options: [],
duration: 30,
timeZone: "",
navigationDate: new Date().toISOString(),
...defaultValues,
},
resolver: (values) => {
return {
values,
errors:
values.options.length === 0
? {
options: true,
}
: {},
};
},
});
const views = React.useMemo(() => {
const res = [
{
label: "Month view",
value: "month",
Component: MonthCalendar,
},
{
label: "Week view",
value: "week",
Component: WeekCalendar,
},
];
return res;
}, []);
const watchView = watch("view");
const selectedView = React.useMemo(
() => views.find((view) => view.value === watchView) ?? views[0],
[views, watchView],
);
const watchOptions = watch("options");
const watchDuration = watch("duration");
const watchTimeZone = watch("timeZone");
const datesOnly = watchOptions.every((option) => option.type === "date");
const [dateOrTimeRangeModal, openDateOrTimeRangeModal] = useModal({
title: "Wait a minute… 🤔",
description:
"You can't have both time and date options in the same poll. Which would you like to keep?",
okText: "Keep time options",
onOk: () => {
setValue(
"options",
watchOptions.filter((option) => option.type === "timeSlot"),
);
if (!watchTimeZone) {
setValue("timeZone", getBrowserTimeZone());
}
},
cancelText: "Keep date options",
onCancel: () => {
setValue(
"options",
watchOptions.filter((option) => option.type === "date"),
);
setValue("timeZone", "");
},
});
React.useEffect(() => {
if (onChange) {
const subscription = watch(({ options = [], ...rest }) => {
// Watch returns a deep partial here which is not really accurate and messes up
// the types a bit. Repackaging it to keep the types sane.
onChange({ options: options as DateTimeOption[], ...rest });
});
return () => {
subscription.unsubscribe();
};
}
}, [watch, onChange]);
React.useEffect(() => {
if (watchOptions.length > 1) {
const optionType = watchOptions[0].type;
// all options needs to be the same type
if (watchOptions.some((option) => option.type !== optionType)) {
openDateOrTimeRangeModal();
}
}
}, [watchOptions, openDateOrTimeRangeModal]);
const watchNavigationDate = watch("navigationDate");
const navigationDate = new Date(watchNavigationDate);
const [calendarHelpModal, openHelpModal] = useModal({
overlayClosable: true,
title: "Forget something?",
description: t("calendarHelp"),
okText: t("ok"),
});
return (
<form
id={name}
className={clsx("max-w-full", className)}
style={{ width: 1024 }}
onSubmit={handleSubmit(onSubmit, openHelpModal)}
>
{calendarHelpModal}
{dateOrTimeRangeModal}
<div className="py-3 space-y-2 lg:space-y-0 border-b w-full lg:space-x-2 bg-slate-50 items-center lg:flex px-4">
<div className="grow">
<Controller
control={control}
name="timeZone"
render={({ field }) => (
<TimeZonePicker
value={field.value}
onBlur={field.onBlur}
onChange={(timeZone) => {
setValue("timeZone", timeZone, { shouldTouch: true });
}}
disabled={datesOnly}
/>
)}
/>
</div>
<div className="flex space-x-3">
<div className="segment-button w-full">
<button
className={clsx({
"segment-button-active": selectedView.value === "month",
})}
onClick={() => {
setValue("view", "month");
}}
type="button"
>
<Calendar className="h-5 w-5 mr-2" /> Month view
</button>
<button
className={clsx({
"segment-button-active": selectedView.value === "week",
})}
type="button"
onClick={() => {
setValue("view", "week");
}}
>
<Table className="h-5 w-5 mr-2" /> Week view
</button>
</div>
</div>
</div>
<div className="w-full relative">
<React.Suspense
fallback={
<FullPageLoader className="h-[400px]">Loading</FullPageLoader>
}
>
<selectedView.Component
title={title}
options={watchOptions}
date={navigationDate}
onNavigate={(date) => {
setValue("navigationDate", date.toISOString());
}}
onChange={(options) => {
setValue("options", options);
if (
options.length === 0 ||
options.every((option) => option.type === "date")
) {
// unset the timeZone if we only have date option
setValue("timeZone", "");
}
if (
options.length > 0 &&
!formState.touchedFields.timeZone &&
options.every((option) => option.type === "timeSlot")
) {
// set timeZone if we are adding time ranges and we haven't touched the timeZone field
setValue("timeZone", getBrowserTimeZone());
}
}}
duration={watchDuration}
onChangeDuration={(duration) => {
setValue("duration", duration);
}}
/>
</React.Suspense>
</div>
</form>
);
};
export default React.memo(PollOptionsForm);

View file

@ -0,0 +1,23 @@
export type DateOption = {
type: "date";
date: string;
};
export type TimeOption = {
type: "timeSlot";
start: string;
end: string;
};
export type DateTimeOption = DateOption | TimeOption;
export interface DateTimePickerProps {
title?: string;
options: DateTimeOption[];
date: Date;
onNavigate: (date: Date) => void;
onChange: (options: DateTimeOption[]) => void;
duration: number;
onChangeDuration: (duration: number) => void;
scrollToTime?: Date;
}

View file

@ -0,0 +1,9 @@
import { format } from "date-fns";
export const formatDateWithoutTz = (date: Date): string => {
return format(date, "yyyy-MM-dd'T'HH:mm:ss");
};
export const formatDateWithoutTime = (date: Date): string => {
return format(date, "yyyy-MM-dd");
};

View file

@ -0,0 +1,193 @@
import clsx from "clsx";
import {
addMinutes,
differenceInMinutes,
format,
getDay,
parse,
startOfWeek,
} from "date-fns";
import React from "react";
import { Calendar, dateFnsLocalizer } from "react-big-calendar";
import { useMount } from "react-use";
import DateNavigationToolbar from "./date-navigation-toolbar";
import { DateTimeOption, DateTimePickerProps } from "./types";
import { formatDateWithoutTime, formatDateWithoutTz } from "./utils";
const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: (date: Date | number) => startOfWeek(date, { weekStartsOn: 1 }),
getDay,
locales: {},
});
const WeekCalendar: React.VoidFunctionComponent<DateTimePickerProps> = ({
title,
options,
onNavigate,
date,
onChange,
duration,
onChangeDuration,
}) => {
const [scrollToTime, setScrollToTime] = React.useState<Date>();
useMount(() => {
// Bit of a hack to force rbc to scroll to the right time when we close/open a modal
setScrollToTime(addMinutes(date, -60));
});
return (
<Calendar
events={options.map((option) => {
if (option.type === "date") {
return { title, start: new Date(option.date) };
} else {
return {
title,
start: new Date(option.start),
end: new Date(option.end),
};
}
})}
onNavigate={onNavigate}
date={date}
className="h-[calc(100vh-220px)] max-h-[800px] min-h-[400px] w-full"
defaultView="week"
views={["week"]}
selectable={true}
localizer={localizer}
onSelectEvent={(event) => {
onChange(
options.filter(
(option) =>
!(
option.type === "timeSlot" &&
option.start === formatDateWithoutTz(event.start) &&
event.end &&
option.end === formatDateWithoutTz(event.end)
),
),
);
}}
components={{
toolbar: (props) => {
return (
<DateNavigationToolbar
year={props.date.getFullYear()}
label={props.label}
onPrevious={() => {
props.onNavigate("PREV");
}}
onToday={() => {
props.onNavigate("TODAY");
}}
onNext={() => {
props.onNavigate("NEXT");
}}
/>
);
},
eventWrapper: (props) => {
return (
<div
// onClick prop doesn't work properly. Seems like some other element is cancelling the event before it reaches this element
onMouseUp={props.onClick}
className="absolute p-1 ml-1 max-h-full hover:bg-opacity-50 transition-colors cursor-pointer overflow-hidden bg-green-100 bg-opacity-80 text-green-500 rounded-md text-xs"
style={{
top: `calc(${props.style?.top}% + 4px)`,
height: `calc(${props.style?.height}% - 8px)`,
left: `${props.style?.xOffset}%`,
width: `calc(${props.style?.width}%)`,
}}
>
<div>{format(props.event.start, "p")}</div>
<div className="font-bold w-full truncate">
{props.event.title}
</div>
</div>
);
},
week: {
header: ({ date }: any) => {
const dateString = formatDateWithoutTime(date);
const selectedOption = options.find((option) => {
return option.type === "date" && option.date === dateString;
});
return (
<span
onClick={() => {
if (!selectedOption) {
onChange([
...options,
{
type: "date",
date: formatDateWithoutTime(date),
},
]);
} else {
onChange(
options.filter((option) => option !== selectedOption),
);
}
}}
className={clsx(
"inline-flex w-full justify-center hover:text-gray-700 hover:bg-slate-50 rounded-md items-center text-sm py-2",
{
"bg-green-50 text-green-600 hover:bg-opacity-75 hover:bg-green-50 hover:text-green-600":
!!selectedOption,
},
)}
>
<span className="font-normal opacity-50 mr-1">
{format(date, "E")}
</span>
<span className="font-medium">{format(date, "dd")}</span>
</span>
);
},
},
timeSlotWrapper: ({ children }) => {
return <div className="h-12 text-xs text-gray-500">{children}</div>;
},
}}
step={15}
onSelectSlot={({ start, end, action }) => {
// on select slot
const startDate = new Date(start);
const endDate = new Date(end);
const newEvent: DateTimeOption = {
type: "timeSlot",
start: formatDateWithoutTz(startDate),
end: formatDateWithoutTz(endDate),
};
if (action === "select") {
const diff = differenceInMinutes(endDate, startDate);
if (diff < 60 * 24) {
onChangeDuration(diff);
}
} else {
newEvent.end = formatDateWithoutTz(addMinutes(startDate, duration));
}
const alreadyExists = options.some(
(option) =>
option.type === "timeSlot" &&
option.start === newEvent.start &&
option.end === newEvent.end,
);
if (!alreadyExists) {
onChange([...options, newEvent]);
}
}}
scrollToTime={scrollToTime}
/>
);
};
export default WeekCalendar;

17
components/forms/types.ts Normal file
View file

@ -0,0 +1,17 @@
import { PollDetailsData } from "./poll-details-form";
import { PollOptionsData } from "./poll-options-form/poll-options-form";
import { UserDetailsData } from "./user-details-form";
export interface NewEventData {
currentStep: number;
eventDetails?: Partial<PollDetailsData>;
options?: Partial<PollOptionsData>;
userDetails?: Partial<UserDetailsData>;
}
export interface PollFormProps<T extends Record<string, any>> {
onSubmit: (data: T) => void;
onChange?: (data: Partial<T>) => void;
defaultValues?: Partial<T>;
name?: string;
className?: string;
}

View file

@ -0,0 +1,71 @@
import clsx from "clsx";
import { useTranslation } from "next-i18next";
import * as React from "react";
import { useForm } from "react-hook-form";
import { requiredString } from "../../utils/form-validation";
import { PollFormProps } from "./types";
export interface UserDetailsData {
name: string;
contact: string;
}
export const UserDetailsForm: React.VoidFunctionComponent<
PollFormProps<UserDetailsData>
> = ({ name, defaultValues, onSubmit, onChange, className }) => {
const { t } = useTranslation("app");
const {
handleSubmit,
register,
watch,
formState: { errors },
} = useForm<UserDetailsData>({ defaultValues });
React.useEffect(() => {
if (onChange) {
const subscription = watch(onChange);
return () => {
subscription.unsubscribe();
};
}
}, [watch, onChange]);
return (
<form
id={name}
className={className}
style={{ width: 400 }}
onSubmit={handleSubmit(onSubmit)}
>
<div className="formField">
<label htmlFor="name">{t("name")}</label>
<input
type="text"
id="name"
className={clsx("input w-full", {
"input-error": errors.name,
})}
placeholder={t("namePlaceholder")}
{...register("name", { validate: requiredString })}
/>
</div>
<div className="formField">
<label htmlFor="contact">{t("email")}</label>
<input
id="contact"
className={clsx("input w-full", {
"input-error": errors.contact,
})}
placeholder={t("emailPlaceholder")}
{...register("contact", {
validate: (value) => {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value);
},
})}
/>
</div>
</form>
);
};