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 { DuplicateIcon } from "@heroicons/react/solid"; | ||||
| import classNames from "classnames"; | ||||
| import dayjs, { Dayjs, ConfigType } from "dayjs"; | ||||
| import timezone from "dayjs/plugin/timezone"; | ||||
| import utc from "dayjs/plugin/utc"; | ||||
| import React, { useCallback, useState } from "react"; | ||||
| import { Controller, useFieldArray } from "react-hook-form"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { Controller, useFieldArray, useFormContext } from "react-hook-form"; | ||||
| import { GroupBase, Props, SingleValue } from "react-select"; | ||||
| 
 | ||||
| import { useLocale } from "@calcom/lib/hooks/useLocale"; | ||||
| import Button from "@calcom/ui/Button"; | ||||
| import Dropdown, { DropdownMenuTrigger, DropdownMenuContent } from "@calcom/ui/Dropdown"; | ||||
| 
 | ||||
| import { defaultDayRange } from "@lib/availability"; | ||||
| import { weekdayNames } from "@lib/core/i18n/weekday"; | ||||
|  | @ -20,84 +24,111 @@ dayjs.extend(timezone); | |||
| 
 | ||||
| /** Begin Time Increments For Select */ | ||||
| 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 = { | ||||
|   readonly label: string; | ||||
|   readonly value: number; | ||||
| }; | ||||
| 
 | ||||
| type TimeRangeFieldProps = { | ||||
|   name: string; | ||||
| }; | ||||
| 
 | ||||
| const TimeRangeField = ({ name }: TimeRangeFieldProps) => { | ||||
| /** | ||||
|  * 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 useOptions = () => { | ||||
|   // Get user so we can determine 12/24 hour format preferences
 | ||||
|   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 [options, setOptions] = useState<Option[]>([]); | ||||
|   const [selected, setSelected] = useState<number | undefined>(); | ||||
|   // const { i18n } = useLocale();
 | ||||
|   const [filteredOptions, setFilteredOptions] = useState<Option[]>([]); | ||||
| 
 | ||||
|   const handleSelected = (value: number | undefined) => { | ||||
|     setSelected(value); | ||||
|   }; | ||||
|   const options = useMemo(() => { | ||||
|     const end = dayjs().utc().endOf("day"); | ||||
|     let t: Dayjs = dayjs().utc().startOf("day"); | ||||
| 
 | ||||
|   const getOption = (time: ConfigType) => ({ | ||||
|     value: dayjs(time).toDate().valueOf(), | ||||
|     label: dayjs(time) | ||||
|     const options: Option[] = []; | ||||
|     while (t.isBefore(end)) { | ||||
|       options.push({ | ||||
|         value: t.toDate().valueOf(), | ||||
|         label: dayjs(t) | ||||
|           .utc() | ||||
|       .format(user && user.timeFormat === 12 ? "h:mma" : "HH:mm"), | ||||
|     // .toLocaleTimeString(i18n.language, { minute: "numeric", hour: "numeric" }),
 | ||||
|           .format(timeFormat === 12 ? "h:mma" : "HH:mm"), | ||||
|       }); | ||||
|       t = t.add(increment, "minutes"); | ||||
|     } | ||||
|     return options; | ||||
|   }, []); | ||||
| 
 | ||||
|   const timeOptions = useCallback( | ||||
|     (offsetOrLimitorSelected: { offset?: number; limit?: number; selected?: number } = {}) => { | ||||
|       const { limit, offset, selected } = offsetOrLimitorSelected; | ||||
|       return TIMES.filter( | ||||
|         (time) => | ||||
|           (!limit || time.isBefore(limit)) && | ||||
|           (!offset || time.isAfter(offset)) && | ||||
|           (!selected || time.isAfter(selected)) | ||||
|       ).map((t) => getOption(t)); | ||||
|   const filter = useCallback( | ||||
|     ({ offset, limit, current }: { offset?: ConfigType; limit?: ConfigType; current?: ConfigType }) => { | ||||
|       if (current) { | ||||
|         setFilteredOptions([options.find((option) => option.value === dayjs(current).toDate().valueOf())!]); | ||||
|       } else | ||||
|         setFilteredOptions( | ||||
|           options.filter((option) => { | ||||
|             const time = dayjs(option.value); | ||||
|             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 ( | ||||
|     <> | ||||
|     <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 | ||||
|         name={`${name}.start`} | ||||
|         render={({ field: { onChange, value } }) => { | ||||
|           handleSelected(value); | ||||
|           return ( | ||||
|             <Select | ||||
|               className="w-30" | ||||
|               options={options} | ||||
|               onFocus={() => setOptions(timeOptions())} | ||||
|               onBlur={() => setOptions([])} | ||||
|               defaultValue={getOption(value)} | ||||
|             <LazySelect | ||||
|               className="w-[120px]" | ||||
|               value={value} | ||||
|               max={maxStart} | ||||
|               onChange={(option) => { | ||||
|                 onChange(new Date(option?.value as number)); | ||||
|                 handleSelected(option?.value); | ||||
|               }} | ||||
|             /> | ||||
|           ); | ||||
|  | @ -107,17 +138,17 @@ const TimeRangeField = ({ name }: TimeRangeFieldProps) => { | |||
|       <Controller | ||||
|         name={`${name}.end`} | ||||
|         render={({ field: { onChange, value } }) => ( | ||||
|           <Select | ||||
|             className="w-30" | ||||
|             options={options} | ||||
|             onFocus={() => setOptions(timeOptions({ selected }))} | ||||
|             onBlur={() => setOptions([])} | ||||
|             defaultValue={getOption(value)} | ||||
|             onChange={(option) => onChange(new Date(option?.value as number))} | ||||
|           <LazySelect | ||||
|             className="flex-grow sm:w-[120px]" | ||||
|             value={value} | ||||
|             min={minEnd} | ||||
|             onChange={(option) => { | ||||
|               onChange(new Date(option?.value as number)); | ||||
|             }} | ||||
|           /> | ||||
|         )} | ||||
|       /> | ||||
|     </> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
|  | @ -127,12 +158,65 @@ type ScheduleBlockProps = { | |||
|   name: string; | ||||
| }; | ||||
| 
 | ||||
| const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => { | ||||
|   const { t } = useLocale(); | ||||
|   const { fields, append, remove, replace } = useFieldArray({ | ||||
|     name: `${name}.${day}`, | ||||
| const CopyTimes = ({ disabled, onApply }: { disabled: number[]; onApply: (selected: number[]) => void }) => { | ||||
|   const [selected, setSelected] = useState<number[]>([]); | ||||
|   const { i18n, t } = useLocale(); | ||||
|   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 = () => { | ||||
|     // 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); | ||||
|  | @ -147,47 +231,95 @@ const ScheduleBlock = ({ name, day, weekday }: ScheduleBlockProps) => { | |||
|   }; | ||||
| 
 | ||||
|   return ( | ||||
|     <fieldset className="flex flex-col justify-between space-y-2 py-5 sm:flex-row sm:space-y-0"> | ||||
|       <div className="w-1/3"> | ||||
|         <label className="flex items-center space-x-2 rtl:space-x-reverse"> | ||||
|           <input | ||||
|             type="checkbox" | ||||
|             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"> | ||||
|     <div className="space-y-2"> | ||||
|       {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> | ||||
|         <div key={field.id} className="flex items-center rtl:space-x-reverse"> | ||||
|           <div className="flex flex-grow sm:flex-grow-0"> | ||||
|             <TimeRangeField name={`${name}.${index}`} /> | ||||
|             <Button | ||||
|               size="icon" | ||||
|               color="minimal" | ||||
|               StartIcon={TrashIcon} | ||||
|               aria-label={t("remove")} | ||||
|               type="button" | ||||
|               onClick={() => remove(index)} | ||||
|             /> | ||||
|           </div> | ||||
|         ))} | ||||
|         <span className="block text-sm text-gray-500">{!fields.length && t("no_availability")}</span> | ||||
|       </div> | ||||
|       <div> | ||||
|           {index === 0 && ( | ||||
|             <div className="absolute top-2 right-0 text-right sm:relative sm:top-0 sm:flex-grow"> | ||||
|               <Button | ||||
|                 className="text-neutral-400" | ||||
|                 type="button" | ||||
|                 color="minimal" | ||||
|                 size="icon" | ||||
|                 StartIcon={PlusIcon} | ||||
|                 onClick={handleAppend} | ||||
|               /> | ||||
|               <Dropdown> | ||||
|                 <DropdownMenuTrigger asChild> | ||||
|                   <Button | ||||
|                     type="button" | ||||
|                     color="minimal" | ||||
|                     size="icon" | ||||
|           className={fields.length > 0 ? "visible" : "invisible"} | ||||
|           StartIcon={PlusIcon} | ||||
|           aria-label={t("add")} | ||||
|                     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> | ||||
|   ); | ||||
| }; | ||||
|  |  | |||
|  | @ -445,6 +445,7 @@ | |||
|   "danger_zone": "Danger Zone", | ||||
|   "back": "Back", | ||||
|   "cancel": "Cancel", | ||||
|   "apply": "Apply", | ||||
|   "cancel_event": "Cancel this event", | ||||
|   "continue": "Continue", | ||||
|   "confirm": "Confirm", | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Alex van Andel
						Alex van Andel