Implements copy day functionality into Availability (#2273)
This commit is contained in:
parent
14ba410352
commit
b1d804405b
2 changed files with 236 additions and 103 deletions
|
@ -1,12 +1,16 @@
|
||||||
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
import { PlusIcon, TrashIcon } from "@heroicons/react/outline";
|
||||||
|
import { DuplicateIcon } from "@heroicons/react/solid";
|
||||||
|
import classNames from "classnames";
|
||||||
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
import dayjs, { Dayjs, ConfigType } from "dayjs";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import React, { useCallback, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Controller, useFieldArray } from "react-hook-form";
|
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||||
|
import { GroupBase, Props, SingleValue } from "react-select";
|
||||||
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import Button from "@calcom/ui/Button";
|
import Button from "@calcom/ui/Button";
|
||||||
|
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown";
|
||||||
|
|
||||||
import { defaultDayRange } from "@lib/availability";
|
import { defaultDayRange } from "@lib/availability";
|
||||||
import { weekdayNames } from "@lib/core/i18n/weekday";
|
import { weekdayNames } from "@lib/core/i18n/weekday";
|
||||||
|
@ -20,84 +24,111 @@ dayjs.extend(timezone);
|
||||||
|
|
||||||
/** Begin Time Increments For Select */
|
/** Begin Time Increments For Select */
|
||||||
const increment = 15;
|
const increment = 15;
|
||||||
/**
|
|
||||||
* Creates an array of times on a 15 minute interval from
|
|
||||||
* 00:00:00 (Start of day) to
|
|
||||||
* 23:45:00 (End of day with enough time for 15 min booking)
|
|
||||||
*/
|
|
||||||
const TIMES = (() => {
|
|
||||||
const end = dayjs().utc().endOf("day");
|
|
||||||
let t: Dayjs = dayjs().utc().startOf("day");
|
|
||||||
|
|
||||||
const times: Dayjs[] = [];
|
|
||||||
while (t.isBefore(end)) {
|
|
||||||
times.push(t);
|
|
||||||
t = t.add(increment, "minutes");
|
|
||||||
}
|
|
||||||
return times;
|
|
||||||
})();
|
|
||||||
/** End Time Increments For Select */
|
|
||||||
|
|
||||||
type Option = {
|
type Option = {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly value: number;
|
readonly value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimeRangeFieldProps = {
|
/**
|
||||||
name: string;
|
* Creates an array of times on a 15 minute interval from
|
||||||
};
|
* 00:00:00 (Start of day) to
|
||||||
|
* 23:45:00 (End of day with enough time for 15 min booking)
|
||||||
const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
*/
|
||||||
|
const useOptions = () => {
|
||||||
// Get user so we can determine 12/24 hour format preferences
|
// Get user so we can determine 12/24 hour format preferences
|
||||||
const query = useMeQuery();
|
const query = useMeQuery();
|
||||||
const user = query.data;
|
const { timeFormat } = query.data || { timeFormat: null };
|
||||||
|
|
||||||
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
const [filteredOptions, setFilteredOptions] = useState<Option[]>([]);
|
||||||
const [options, setOptions] = useState<Option[]>([]);
|
|
||||||
const [selected, setSelected] = useState<number | undefined>();
|
|
||||||
// const { i18n } = useLocale();
|
|
||||||
|
|
||||||
const handleSelected = (value: number | undefined) => {
|
const options = useMemo(() => {
|
||||||
setSelected(value);
|
const end = dayjs().utc().endOf("day");
|
||||||
};
|
let t: Dayjs = dayjs().utc().startOf("day");
|
||||||
|
|
||||||
const getOption = (time: ConfigType) => ({
|
const options: Option[] = [];
|
||||||
value: dayjs(time).toDate().valueOf(),
|
while (t.isBefore(end)) {
|
||||||
label: dayjs(time)
|
options.push({
|
||||||
.utc()
|
value: t.toDate().valueOf(),
|
||||||
.format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"),
|
label: dayjs(t)
|
||||||
// .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
|
.utc()
|
||||||
});
|
.format(timeFormat === 12 ? "h:mma" : "HH:mm"),
|
||||||
|
});
|
||||||
|
t = t.add(increment, "minutes");
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const timeOptions = useCallback(
|
const filter = useCallback(
|
||||||
(offsetOrLimitorSelected: { offset?: number; limit?: number; selected?: number } = {}) => {
|
({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => {
|
||||||
const { limit, offset, selected } = offsetOrLimitorSelected;
|
if (current) {
|
||||||
return TIMES.filter(
|
setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]);
|
||||||
(time) =>
|
} else
|
||||||
(!limit || time.isBefore(limit)) &&
|
setFilteredOptions(
|
||||||
(!offset || time.isAfter(offset)) &&
|
options.filter((option) => {
|
||||||
(!selected || time.isAfter(selected))
|
const time = dayjs(option.value);
|
||||||
).map((t) => getOption(t));
|
return (!limit || time.isBefore(limit)) && (!offset || time.isAfter(offset));
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[]
|
[options]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return { options: filteredOptions, filter };
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimeRangeFieldProps = {
|
||||||
|
name: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LazySelect = ({
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
...props
|
||||||
|
}: Omit<Props<Option, false, GroupBase<Option>>, "value"> & {
|
||||||
|
value: ConfigType;
|
||||||
|
min?: ConfigType;
|
||||||
|
max?: ConfigType;
|
||||||
|
}) => {
|
||||||
|
// Lazy-loaded options, otherwise adding a field has a noticable redraw delay.
|
||||||
|
const { options, filter } = useOptions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filter({ current: value });
|
||||||
|
}, [filter, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Select
|
||||||
|
options={options}
|
||||||
|
onMenuOpen={() => {
|
||||||
|
if (min) filter({ offset: min });
|
||||||
|
if (max) filter({ limit: max });
|
||||||
|
}}
|
||||||
|
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
||||||
|
onMenuClose={() => filter({ current: value })}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TimeRangeField = ({ name, className }: TimeRangeFieldProps) => {
|
||||||
|
const { watch } = useFormContext();
|
||||||
|
const minEnd = watch(`${name}.start`);
|
||||||
|
const maxStart = watch(`${name}.end`);
|
||||||
|
return (
|
||||||
|
<div className={classNames("flex flex-grow items-center space-x-3", className)}>
|
||||||
<Controller
|
<Controller
|
||||||
name={`${name}.start`}
|
name={`${name}.start`}
|
||||||
render={({ field: { onChange, value } }) => {
|
render={({ field: { onChange, value } }) => {
|
||||||
handleSelected(value);
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<LazySelect
|
||||||
className="w-30"
|
className="w-[120px]"
|
||||||
options={options}
|
value={value}
|
||||||
onFocus={() => setOptions(timeOptions())}
|
max={maxStart}
|
||||||
onBlur={() => setOptions([])}
|
|
||||||
defaultValue={getOption(value)}
|
|
||||||
onChange={(option) => {
|
onChange={(option) => {
|
||||||
onChange(new Date(option?.value as number));
|
onChange(new Date(option?.value as number));
|
||||||
handleSelected(option?.value);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -107,17 +138,17 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => {
|
||||||
<Controller
|
<Controller
|
||||||
name={`${name}.end`}
|
name={`${name}.end`}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<Select
|
<LazySelect
|
||||||
className="w-30"
|
className="flex-grow sm:w-[120px]"
|
||||||
options={options}
|
value={value}
|
||||||
onFocus={() => setOptions(timeOptions({ selected }))}
|
min={minEnd}
|
||||||
onBlur={() => setOptions([])}
|
onChange={(option) => {
|
||||||
defaultValue={getOption(value)}
|
onChange(new Date(option?.value as number));
|
||||||
onChange={(option) => onChange(new Date(option?.value as number))}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -127,12 +158,65 @@ type ScheduleBlockProps = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => {
|
||||||
const { t } = useLocale();
|
const [selected, setSelected] = useState<number[]>([]);
|
||||||
const { fields, append, remove, replace } = useFieldArray({
|
const { i18n, t } = useLocale();
|
||||||
name: `${name}.${day}`,
|
return (
|
||||||
|
<div className="m-4 space-y-2 py-4">
|
||||||
|
<p className="h6 text-xs font-medium uppercase text-neutral-400">Copy times to</p>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{weekdayNames(i18n.language).map((weekday, num) => (
|
||||||
|
<li key={weekday}>
|
||||||
|
<label className="flex w-full items-center justify-between">
|
||||||
|
<span>{weekday}</span>
|
||||||
|
<input
|
||||||
|
value={num}
|
||||||
|
defaultChecked={disabled.includes(num)}
|
||||||
|
disabled={disabled.includes(num)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked && !selected.includes(num)) {
|
||||||
|
setSelected(selected.concat([num]));
|
||||||
|
} else if (!e.target.checked && selected.includes(num)) {
|
||||||
|
setSelected(selected.slice(selected.indexOf(num), 1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="checkbox"
|
||||||
|
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500 disabled:text-neutral-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button className="w-full justify-center" color="primary" onClick={() => onApply(selected)}>
|
||||||
|
{t("apply")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DayRanges = ({
|
||||||
|
name,
|
||||||
|
defaultValue = [defaultDayRange],
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
defaultValue?: TimeRange[];
|
||||||
|
}) => {
|
||||||
|
const { setValue, watch } = useFormContext();
|
||||||
|
// XXX: Hack to make copying times work; `fields` is out of date until save.
|
||||||
|
const watcher = watch(name);
|
||||||
|
|
||||||
|
const { fields, replace, append, remove } = useFieldArray({
|
||||||
|
name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultValue.length && !fields.length) {
|
||||||
|
replace(defaultValue);
|
||||||
|
}
|
||||||
|
}, [replace, defaultValue, fields.length]);
|
||||||
|
|
||||||
const handleAppend = () => {
|
const handleAppend = () => {
|
||||||
// FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
|
// FIXME: Fix type-inference, can't get this to work. @see https://github.com/react-hook-form/react-hook-form/issues/4499
|
||||||
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
const nextRangeStart = dayjs((fields[fields.length - 1] as unknown as TimeRange).end);
|
||||||
|
@ -147,47 +231,95 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset className="flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
<div className="space-y-2">
|
||||||
<div className="w-1/3">
|
{fields.map((field, index) => (
|
||||||
<label className="flex items-center space-x-2 rtl:space-x-reverse">
|
<div key={field.id} className="flex items-center rtl:space-x-reverse">
|
||||||
<input
|
<div className="flex flex-grow sm:flex-grow-0">
|
||||||
type="checkbox"
|
<TimeRangeField name={`${name}.${index}`} />
|
||||||
checked={fields.length > 0}
|
|
||||||
onChange={(e) => (e.target.checked ? replace([defaultDayRange]) : replace([]))}
|
|
||||||
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
|
||||||
/>
|
|
||||||
<span className="inline-block text-sm capitalize">{weekday}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex-grow">
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<div key={field.id} className="mb-1 flex justify-between">
|
|
||||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
|
||||||
<TimeRangeField name={`${name}.${day}.${index}`} />
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
color="minimal"
|
color="minimal"
|
||||||
StartIcon={TrashIcon}
|
StartIcon={TrashIcon}
|
||||||
aria-label={t("remove")}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove(index)}
|
onClick={() => remove(index)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{index === 0 && (
|
||||||
<span className="block text-sm text-gray-500">{!fields.length && t("no_availability")}</span>
|
<div className="absolute top-2 right-0 text-right sm:relative sm:top-0 sm:flex-grow">
|
||||||
</div>
|
<Button
|
||||||
<div>
|
className="text-neutral-400"
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
color="minimal"
|
||||||
color="minimal"
|
size="icon"
|
||||||
size="icon"
|
StartIcon={PlusIcon}
|
||||||
className={fields.length > 0 ? "visible" : "invisible"}
|
onClick={handleAppend}
|
||||||
StartIcon={PlusIcon}
|
/>
|
||||||
aria-label={t("add")}
|
<Dropdown>
|
||||||
onClick={handleAppend}
|
<DropdownMenuTrigger asChild>
|
||||||
/>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
|
color="minimal"
|
||||||
|
size="icon"
|
||||||
|
StartIcon={DuplicateIcon}
|
||||||
|
onClick={handleAppend}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<CopyTimes
|
||||||
|
disabled={[parseInt(name.substring(name.lastIndexOf(".") + 1), 10)]}
|
||||||
|
onApply={(selected) =>
|
||||||
|
selected.forEach((day) => {
|
||||||
|
// TODO: Figure out why this is different?
|
||||||
|
// console.log(watcher, fields);
|
||||||
|
setValue(name.substring(0, name.lastIndexOf(".") + 1) + day, watcher);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const form = useFormContext();
|
||||||
|
const watchAvailable = form.watch(`${name}.${day}`, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="relative flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0">
|
||||||
|
<label
|
||||||
|
className={classNames(
|
||||||
|
"flex space-x-2 rtl:space-x-reverse",
|
||||||
|
!watchAvailable.length ? "w-full" : "w-1/3"
|
||||||
|
)}>
|
||||||
|
<div className={classNames(!watchAvailable.length ? "w-1/3" : "w-full")}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={watchAvailable.length}
|
||||||
|
onChange={(e) => {
|
||||||
|
form.setValue(`${name}.${day}`, e.target.checked ? [defaultDayRange] : []);
|
||||||
|
}}
|
||||||
|
className="inline-block rounded-sm border-gray-300 text-neutral-900 focus:ring-neutral-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 inline-block text-sm capitalize">{weekday}</span>
|
||||||
|
</div>
|
||||||
|
{!watchAvailable.length && (
|
||||||
|
<div className="flex-grow text-right text-sm text-gray-500 sm:flex-shrink">
|
||||||
|
{t("no_availability")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{!!watchAvailable.length && (
|
||||||
|
<div className="flex-grow">
|
||||||
|
<DayRanges name={`${name}.${day}`} defaultValue={[]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -445,6 +445,7 @@
|
||||||
"danger_zone": "Danger Zone",
|
"danger_zone": "Danger Zone",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"apply": "Apply",
|
||||||
"cancel_event": "Cancel this event",
|
"cancel_event": "Cancel this event",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|
Loading…
Reference in a new issue