mirror of
https://github.com/lukevella/rallly.git
synced 2025-05-10 23:46:49 +02:00
First public commit
This commit is contained in:
commit
e05cd62e53
228 changed files with 17717 additions and 0 deletions
7
components/forms/index.ts
Normal file
7
components/forms/index.ts
Normal 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";
|
76
components/forms/poll-details-form.tsx
Normal file
76
components/forms/poll-details-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
2
components/forms/poll-options-form/index.ts
Normal file
2
components/forms/poll-options-form/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default } from "./poll-options-form";
|
||||
export * from "./types";
|
|
@ -0,0 +1 @@
|
|||
export { default } from "./month-calendar";
|
|
@ -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;
|
|
@ -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;
|
233
components/forms/poll-options-form/poll-options-form.tsx
Normal file
233
components/forms/poll-options-form/poll-options-form.tsx
Normal 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);
|
23
components/forms/poll-options-form/types.ts
Normal file
23
components/forms/poll-options-form/types.ts
Normal 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;
|
||||
}
|
9
components/forms/poll-options-form/utils.ts
Normal file
9
components/forms/poll-options-form/utils.ts
Normal 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");
|
||||
};
|
193
components/forms/poll-options-form/week-calendar.tsx
Normal file
193
components/forms/poll-options-form/week-calendar.tsx
Normal 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
17
components/forms/types.ts
Normal 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;
|
||||
}
|
71
components/forms/user-details-form.tsx
Normal file
71
components/forms/user-details-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue