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
	
	 Alex van Andel
						Alex van Andel