From ca47a589baa9f8295067a2898c7ab3d44937cb9b Mon Sep 17 00:00:00 2001 From: Jeroen Reumkens Date: Fri, 9 Jun 2023 11:38:18 +0200 Subject: [PATCH] feat: weekly view for new booker. (#9389) * WIP on weekly view for new booker. * Set responsive grid height for booker weekly view. * Fixed types * Ensure you can't click on disabled dates in weekly calendar * CAL-1916: Improvements for blocked out parts background blocks, made the lines connect by using a shared background. * Margin, padding and color tweaks for new booker CAL-1911 * Made week view have more days visible by default * Fix to prevent layout switch from breaking when clicking same layout twice. --- packages/features/bookings/Booker/Booker.tsx | 27 ++++----- .../bookings/Booker/components/Header.tsx | 29 +++++----- .../Booker/components/LargeCalendar.tsx | 58 ++++++++++++------- packages/features/bookings/Booker/config.ts | 24 ++++++++ .../bookings/components/AvailableTimes.tsx | 16 +++-- .../weeklyview/components/Calendar.tsx | 55 ++++++++++++++---- .../components/DateValues/index.tsx | 7 ++- .../components/blocking/BlockedList.tsx | 2 +- .../components/blocking/BlockedTimeCell.tsx | 8 ++- .../weeklyview/components/event/Empty.tsx | 47 +++++++++++---- .../weeklyview/components/event/EventList.tsx | 2 +- .../weeklyview/components/grid/index.tsx | 10 +++- .../components/horizontalLines/index.tsx | 4 +- .../components/verticalLines/index.tsx | 4 +- .../calendars/weeklyview/types/state.ts | 18 +++++- .../calendars/weeklyview/utils/index.ts | 18 +++++- .../features/form-builder/FormBuilder.tsx | 5 +- 17 files changed, 236 insertions(+), 98 deletions(-) diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 44a2598efb..4a33308973 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -16,7 +16,7 @@ import { Header } from "./components/Header"; import { LargeCalendar } from "./components/LargeCalendar"; import { BookerSection } from "./components/Section"; import { Away, NotFound } from "./components/Unavailable"; -import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; +import { extraDaysConfig, fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config"; import { useBookerStore, useInitializeBookerStore } from "./store"; import type { BookerProps } from "./types"; import { useEvent } from "./utils/event"; @@ -48,9 +48,9 @@ const BookerComponent = ({ (state) => [state.selectedTimeslot, state.setSelectedTimeslot], shallow ); - const extraDays = layout === BookerLayouts.COLUMN_VIEW ? (isTablet ? 2 : 4) : 0; - const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings; + const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop; + const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings; const animationScope = useBookerResizeAnimation(layout, bookerState); useBrandColors({ @@ -125,7 +125,7 @@ const BookerComponent = ({ {layout !== BookerLayouts.MONTH_VIEW && !(layout === "mobile" && bookerState === "booking") && ( -
+
)} @@ -135,9 +135,9 @@ const BookerComponent = ({ + visible={bookerState === "booking" && layout === BookerLayouts.MONTH_VIEW}> setSelectedTimeslot(null)} /> @@ -147,20 +147,17 @@ const BookerComponent = ({ visible={bookerState !== "booking" && layout === BookerLayouts.MONTH_VIEW} {...fadeInLeft} initial="visible" - className="md:border-subtle ml-[-1px] h-full flex-shrink p-5 md:border-l lg:w-[var(--booker-main-width)]"> + className="md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]"> - + setSelectedTimeslot(null)} /> diff --git a/packages/features/bookings/Booker/components/Header.tsx b/packages/features/bookings/Booker/components/Header.tsx index ef8e2bd4fa..51799f4a86 100644 --- a/packages/features/bookings/Booker/components/Header.tsx +++ b/packages/features/bookings/Booker/components/Header.tsx @@ -28,8 +28,11 @@ export function Header({ const selectedDate = dayjs(selectedDateString); const onLayoutToggle = useCallback( - (newLayout: string) => setLayout(newLayout as BookerLayout), - [setLayout] + (newLayout: string) => { + if (layout === newLayout || !newLayout) return; + setLayout(newLayout as BookerLayout); + }, + [setLayout, layout] ); if (isMobile || !enabledLayouts) return null; @@ -51,7 +54,7 @@ export function Header({ } return ( -
+

{selectedDate.format("MMM D")}-{selectedDate.add(extraDays, "days").format("D")},{" "} @@ -74,24 +77,22 @@ export function Header({ />

- {enabledLayouts.length > 1 && ( -
- -
- -
- {/* +
+ +
+ +
+ {/* This second layout toggle is hidden, but needed to reserve the correct spot in the DIV for the fixed toggle above to fit into. If we wouldn't make it fixed in this view, the transition would be really weird, because the element is positioned fixed in the month view, and then when switching layouts wouldn't anymmore, causing it to animate from the center to the top right, while it actuall already was on place. That's why we have this element twice. */} -
- -
+
+
- )} +
); } diff --git a/packages/features/bookings/Booker/components/LargeCalendar.tsx b/packages/features/bookings/Booker/components/LargeCalendar.tsx index 527f6a5ad4..d935923151 100644 --- a/packages/features/bookings/Booker/components/LargeCalendar.tsx +++ b/packages/features/bookings/Booker/components/LargeCalendar.tsx @@ -1,30 +1,48 @@ -import { shallow } from "zustand/shallow"; +import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; +import { Calendar } from "@calcom/features/calendars/weeklyview"; +import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state"; import { useBookerStore } from "../store"; +import { useScheduleForEvent } from "../utils/event"; -export const LargeCalendar = () => { - const [setSelectedDate, setSelectedTimeslot] = useBookerStore( - (state) => [state.setSelectedDate, state.setSelectedTimeslot], - shallow - ); +export const LargeCalendar = ({ extraDays }: { extraDays: number }) => { + const selectedDate = useBookerStore((state) => state.selectedDate); + const date = selectedDate || dayjs().format("YYYY-MM-DD"); + const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot); + const schedule = useScheduleForEvent({ + prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(), + }); + + const availableSlots = useMemo(() => { + const availableTimeslots: CalendarAvailableTimeslots = {}; + if (!schedule.data) return availableTimeslots; + if (!schedule.data.slots) return availableTimeslots; + for (const day in schedule.data.slots) { + availableTimeslots[day] = schedule.data.slots[day].map((slot) => ({ + start: dayjs(slot.time).toDate(), + end: dayjs(slot.time).add(30, "minutes").toDate(), + })); + } + + return availableTimeslots; + }, [schedule]); return ( -
- Something big is coming... -
- +
+ setSelectedTimeslot(date.toString())} + gridCellsPerHour={2} + hoverEventDuration={30} + hideHeader + />
); }; diff --git a/packages/features/bookings/Booker/config.ts b/packages/features/bookings/Booker/config.ts index 7c0a9834d1..03b4f2d9b8 100644 --- a/packages/features/bookings/Booker/config.ts +++ b/packages/features/bookings/Booker/config.ts @@ -184,3 +184,27 @@ export const useBookerResizeAnimation = (layout: BookerLayout, state: BookerStat return animationScope; }; + +/** + * These configures the amount of days that are shown on top of the selected date. + */ +export const extraDaysConfig = { + mobile: { + // Desktop tablet feels weird on mobile layout, + // but this is simply here to make the types a lot easier.. + desktop: 0, + tablet: 0, + }, + [BookerLayouts.MONTH_VIEW]: { + desktop: 0, + tablet: 0, + }, + [BookerLayouts.WEEK_VIEW]: { + desktop: 7, + tablet: 4, + }, + [BookerLayouts.COLUMN_VIEW]: { + desktop: 4, + tablet: 2, + }, +}; diff --git a/packages/features/bookings/components/AvailableTimes.tsx b/packages/features/bookings/components/AvailableTimes.tsx index 01fc438a80..4279a4aada 100644 --- a/packages/features/bookings/components/AvailableTimes.tsx +++ b/packages/features/bookings/components/AvailableTimes.tsx @@ -36,19 +36,25 @@ export const AvailableTimes = ({ const hasTimeSlots = !!seatsPerTimeslot; const [layout] = useBookerStore((state) => [state.layout], shallow); const isColumnView = layout === BookerLayouts.COLUMN_VIEW; + const isMonthView = layout === BookerLayouts.MONTH_VIEW; const isToday = dayjs().isSame(date, "day"); return (
-
- - +
+ + {nameOfDay(i18n.language, Number(date.format("d")), "short")} {date.format("DD")} diff --git a/packages/features/calendars/weeklyview/components/Calendar.tsx b/packages/features/calendars/weeklyview/components/Calendar.tsx index fa05568469..5b808d2606 100644 --- a/packages/features/calendars/weeklyview/components/Calendar.tsx +++ b/packages/features/calendars/weeklyview/components/Calendar.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useMemo, useRef } from "react"; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCalendarStore } from "../state/store"; import "../styles/styles.css"; import type { CalendarComponentProps } from "../types/state"; -import { getDaysBetweenDates, getHoursToDisplay } from "../utils"; +import { calculateHourSizeInPx, getDaysBetweenDates, getHoursToDisplay } from "../utils"; import { DateValues } from "./DateValues"; -import { BlockedList } from "./blocking/BlockedList"; import { EmptyCell } from "./event/Empty"; import { EventList } from "./event/EventList"; import { SchedulerColumns } from "./grid"; @@ -17,6 +16,7 @@ export function Calendar(props: CalendarComponentProps) { const container = useRef(null); const containerNav = useRef(null); const containerOffset = useRef(null); + const schedulerGrid = useRef(null); const initalState = useCalendarStore((state) => state.initState); const startDate = useCalendarStore((state) => state.startDate); @@ -24,12 +24,30 @@ export function Calendar(props: CalendarComponentProps) { const startHour = useCalendarStore((state) => state.startHour || 0); const endHour = useCalendarStore((state) => state.endHour || 23); const usersCellsStopsPerHour = useCalendarStore((state) => state.gridCellsPerHour || 4); + const availableTimeslots = useCalendarStore((state) => state.availableTimeslots); + const hideHeader = useCalendarStore((state) => state.hideHeader); const days = useMemo(() => getDaysBetweenDates(startDate, endDate), [startDate, endDate]); const hours = useMemo(() => getHoursToDisplay(startHour || 0, endHour || 23), [startHour, endHour]); const numberOfGridStopsPerDay = hours.length * usersCellsStopsPerHour; + const [hourSize, setHourSize] = useState(calculateHourSizeInPx(schedulerGrid?.current, startHour, endHour)); + + // Reset one hour size on window resize. + useLayoutEffect(() => { + const onResize = () => { + setHourSize(calculateHourSizeInPx(schedulerGrid?.current, startHour, endHour)); + }; + window.addEventListener("resize", onResize); + + // By running this set function also one time in the uselayouteffect, instead of + // only in the default value of useState, we make sure that the ref is rendered + // to the screen and we read the correct value. + setHourSize(calculateHourSizeInPx(schedulerGrid?.current, startHour, endHour)); + + return () => window.removeEventListener("resize", onResize); + }, [startHour, endHour]); // Initalise State on inital mount useEffect(() => { @@ -41,13 +59,18 @@ export function Calendar(props: CalendarComponentProps) {
- -
+ {hideHeader !== true && } +
+ className="flex h-full max-w-full flex-none flex-col sm:max-w-none md:max-w-full"> {/* TODO: Implement this at a later date. */} {/* */}
-
-
+
+
<> {[...Array(days.length)].map((_, i) => (
  • {/* While startDate < endDate: */} {[...Array(numberOfGridStopsPerDay)].map((_, j) => { @@ -89,6 +119,7 @@ export function Calendar(props: CalendarComponentProps) { totalGridCells={numberOfGridStopsPerDay} selectionLength={endHour - startHour} startHour={startHour} + availableSlots={availableTimeslots} /> ); })} @@ -105,7 +136,7 @@ export function Calendar(props: CalendarComponentProps) { return (
  • - + {/* */}
  • ); })} @@ -127,7 +158,7 @@ const MobileNotSupported = ({ children }: { children: React.ReactNode }) => {

    Mobile not supported yet

    Please use a desktop browser to view this page

    -
    {children}
    +
    {children}
    ); }; diff --git a/packages/features/calendars/weeklyview/components/DateValues/index.tsx b/packages/features/calendars/weeklyview/components/DateValues/index.tsx index c2086d7d41..c104b8abb0 100644 --- a/packages/features/calendars/weeklyview/components/DateValues/index.tsx +++ b/packages/features/calendars/weeklyview/components/DateValues/index.tsx @@ -12,7 +12,7 @@ export function DateValues({ days, containerNavRef }: Props) { return (
    + className="bg-default dark:bg-muted border-b-subtle sticky top-0 z-30 flex-none border-b sm:pr-8">
    {days.map((day) => { const isToday = dayjs().isSame(day, "day"); @@ -40,7 +40,10 @@ export function DateValues({ days, containerNavRef }: Props) { return (
    + className={classNames( + "flex flex-1 items-center justify-center py-3 text-xs font-medium uppercase", + isToday && "font-bold" + )}> {day.format("ddd")}{" "} ); diff --git a/packages/features/calendars/weeklyview/components/event/Empty.tsx b/packages/features/calendars/weeklyview/components/event/Empty.tsx index cd3e1713ad..2cd4cd5edd 100644 --- a/packages/features/calendars/weeklyview/components/event/Empty.tsx +++ b/packages/features/calendars/weeklyview/components/event/Empty.tsx @@ -1,10 +1,18 @@ -import shallow from "zustand/shallow"; +import { shallow } from "zustand/shallow"; + +import dayjs from "@calcom/dayjs"; +import { classNames } from "@calcom/lib"; import { useCalendarStore } from "../../state/store"; +import type { CalendarAvailableTimeslots } from "../../types/state"; import type { GridCellToDateProps } from "../../utils"; import { gridCellToDateTime } from "../../utils"; -export function EmptyCell(props: GridCellToDateProps) { +type EmptyCellProps = GridCellToDateProps & { + availableSlots?: CalendarAvailableTimeslots; +}; + +export function EmptyCell(props: EmptyCellProps) { const { onEmptyCellClick, hoverEventDuration } = useCalendarStore( (state) => ({ onEmptyCellClick: state.onEmptyCellClick, @@ -21,22 +29,41 @@ export function EmptyCell(props: GridCellToDateProps) { startHour: props.startHour, }); + // Empty cell won't show it self (be disabled) when + // availableslots is passed in, and it's curren time is not part of the available slots + const isDisabled = + props.availableSlots && + !props.availableSlots[dayjs(props.day).format("YYYY-MM-DD")]?.find( + (slot) => slot.start.getTime() === cellToDate.toDate().getTime() + ); + return (
    onEmptyCellClick && onEmptyCellClick(cellToDate.toDate())}> - {hoverEventDuration !== 0 && ( + {!isDisabled && hoverEventDuration !== 0 && (
    -
    {cellToDate.format("HH:mm")}
    +
    + {cellToDate.format("HH:mm")} + Click to select +
    )}
    diff --git a/packages/features/calendars/weeklyview/components/event/EventList.tsx b/packages/features/calendars/weeklyview/components/event/EventList.tsx index e08fd8f0dd..5e3c6ea62f 100644 --- a/packages/features/calendars/weeklyview/components/event/EventList.tsx +++ b/packages/features/calendars/weeklyview/components/event/EventList.tsx @@ -1,4 +1,4 @@ -import shallow from "zustand/shallow"; +import { shallow } from "zustand/shallow"; import dayjs from "@calcom/dayjs"; diff --git a/packages/features/calendars/weeklyview/components/grid/index.tsx b/packages/features/calendars/weeklyview/components/grid/index.tsx index 9130015be4..682e84f26d 100644 --- a/packages/features/calendars/weeklyview/components/grid/index.tsx +++ b/packages/features/calendars/weeklyview/components/grid/index.tsx @@ -7,13 +7,17 @@ type Props = { zIndex?: number; }; -export function SchedulerColumns({ offsetHeight, gridStopsPerDay, children, zIndex }: Props) { +export const SchedulerColumns = React.forwardRef(function SchedulerColumns( + { offsetHeight, gridStopsPerDay, children, zIndex }, + ref +) { return (
      {children}
    ); -} +}); diff --git a/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx index 50cf66fef6..f549212059 100644 --- a/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx +++ b/packages/features/calendars/weeklyview/components/horizontalLines/index.tsx @@ -15,9 +15,9 @@ export const HorizontalLines = ({ const id = useId(); return (
    {hours.map((hour) => ( diff --git a/packages/features/calendars/weeklyview/components/verticalLines/index.tsx b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx index f72b7415bd..a08c19888b 100644 --- a/packages/features/calendars/weeklyview/components/verticalLines/index.tsx +++ b/packages/features/calendars/weeklyview/components/verticalLines/index.tsx @@ -3,8 +3,8 @@ import type dayjs from "@calcom/dayjs"; export const VeritcalLines = ({ days }: { days: dayjs.Dayjs[] }) => { return (
    + className="divide-default pointer-events-none relative z-[60] col-start-1 col-end-2 row-start-1 grid + auto-cols-auto grid-rows-1 divide-x sm:pr-8"> {days.map((_, i) => (
    void; }; +export type CalendarAvailableTimeslots = { + // Key is the date in YYYY-MM-DD format + [key: string]: TimeRange[]; +}; + export type CalendarState = { /** @NotImplemented This in future will change the view to be daily/weekly/monthly DAY/WEEK are supported currently however WEEK is the most adv.*/ view?: View; @@ -62,6 +67,11 @@ export type CalendarState = { * @Note Ideally you should pass in a sorted array from the DB however, pass the prop `sortEvents` if this is not possible and we will sort this for you.. */ events: CalendarEvent[]; + /** + * Instead of letting users choose any option, this will only show these timeslots. + * Users can not pick any time themselves but are restricted to the available options. + */ + availableTimeslots?: CalendarAvailableTimeslots; /** Any time ranges passed in here will display as blocked on the users calendar. Note: Anything < than the current date automatically gets blocked. */ blockingDates?: TimeRange[]; /** Loading will only expect events to be loading. */ @@ -104,6 +114,10 @@ export type CalendarState = { * @Note It is recommended to sort the events before passing them into the scheduler - e.g. On DB level. */ sortEvents?: boolean; + /** + * Optional boolean to hide the main header. Default the header will be visible. + */ + hideHeader?: boolean; }; export type CalendarComponentProps = CalendarPublicActions & CalendarState; diff --git a/packages/features/calendars/weeklyview/utils/index.ts b/packages/features/calendars/weeklyview/utils/index.ts index d22732934c..7131c99845 100644 --- a/packages/features/calendars/weeklyview/utils/index.ts +++ b/packages/features/calendars/weeklyview/utils/index.ts @@ -1,5 +1,5 @@ import dayjs from "@calcom/dayjs"; -import { TimeRange } from "@calcom/types/schedule"; +import type { TimeRange } from "@calcom/types/schedule"; // By default starts on Sunday (Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday) export function weekdayDates(weekStart = 0, startDate: Date, length = 6) { @@ -34,7 +34,7 @@ export function gridCellToDateTime({ // Add startHour since we use StartOfDay for day props. This could be improved by changing the getDaysBetweenDates function // To handle the startHour+endHour - const cellDateTime = dayjs(day).add(minutesIntoSelection, "minutes").add(startHour, "hours"); + const cellDateTime = dayjs(day).startOf("day").add(minutesIntoSelection, "minutes").add(startHour, "hours"); return cellDateTime; } @@ -90,3 +90,17 @@ export function mergeOverlappingDateRanges(dateRanges: TimeRange[]) { } return mergedDateRanges; } + +export function calculateHourSizeInPx( + gridElementRef: HTMLOListElement | null, + startHour: number, + endHour: number +) { + // Gap added at bottom to give calendar some breathing room. + // I guess we could come up with a better way to do this in the future. + const gapOnBottom = 50; + // In case the calendar has for example a header above it. We take a look at the + // distance the grid is rendered from the top, and subtract that from the height. + const offsetFromTop = gridElementRef?.getBoundingClientRect().top ?? 65; + return (window.innerHeight - offsetFromTop - gapOnBottom) / (endHour - startHour); +} diff --git a/packages/features/form-builder/FormBuilder.tsx b/packages/features/form-builder/FormBuilder.tsx index 1f395cac84..9666e45335 100644 --- a/packages/features/form-builder/FormBuilder.tsx +++ b/packages/features/form-builder/FormBuilder.tsx @@ -426,10 +426,7 @@ export const FormBuilder = function FormBuilder({ }>
    - +