手写一个组件

84 阅读2分钟

CalendarView组件

import "react-big-calendar/lib/addons/dragAndDrop/styles.css"
import "react-big-calendar/lib/css/react-big-calendar.css"

import { cn } from "@utils/cn"
import dayjs from "dayjs"
import React, { useCallback, useMemo, useRef, useState } from "react"
import { Calendar, type Components, dayjsLocalizer, type SlotInfo, type View, Views } from "react-big-calendar"
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop"

import { useTimeSlotContext } from "../../../../context/TimeSlotContext"
import { TimeSlotPopover } from "../components/TimeSlotPopover/Popover"
import { checkIsBeforeToday, generateTimeSlots } from "../utils"
import { customCalendarConfig } from "./customCalendarConfig"
import { CustomCalendarToolbar } from "./CustomCalendarToolbar"
import { CustomDateCellWrapper } from "./CustomComponents/MonthView/CustomDateCellWrapper"
import { CustomDateHeader } from "./CustomComponents/MonthView/CustomDateHeader"
import styles from "./TimeSlotScheduleCalendar.module.css"

const DragAndDropCalendar = withDragAndDrop(Calendar)
const localizer = dayjsLocalizer(dayjs)

export const CalendarView = () => {
  const defaultDate = new Date()
  const CalendarRef = useRef<HTMLDivElement>(null)
  const [isOpen, setIsOpened] = useState(false)
  const [selectedSlot, setSelectedSlot] = useState<SlotInfo | null>(null)
  const [selectedDate, setSelectedDate] = useState<Date>(defaultDate)
  const [activeView, setActiveView] = useState<View>(Views.MONTH)
  const { existingTimeSlots, updateExistingTimeSlots } = useTimeSlotContext()

  // custom components to overwrite the default ones
  const components: Components = {
    toolbar: CustomCalendarToolbar,
    dateCellWrapper: (props) => {
      return <CustomDateCellWrapper currentSelectedDate={selectedSlot?.start} {...props} />
    },
    month: {
      dateHeader: CustomDateHeader,
    },
  }

  // filter time slots by selected date
  // for display in the popover
  const selectedTimeSlots = useMemo(() => {
    return existingTimeSlots.filter((timeSlot) => {
      return dayjs(timeSlot.start).isSame(dayjs(selectedDate), "day")
    })
  }, [existingTimeSlots, selectedDate])

  const handleSelectSlot = (slotInfo: SlotInfo) => {
    setIsOpened(false)
    const isBeforeToday = checkIsBeforeToday(slotInfo.start)
    if (isBeforeToday) return
    setSelectedSlot(slotInfo)
    setSelectedDate(slotInfo.start)

    const newlyCalendarTimeSlots = generateTimeSlots(slotInfo, 30)

    // for month view, we don't need to generate time slots
    // when clicking on a day
    if (activeView === Views.WEEK) {
      const isBeforeCurrentTime = dayjs(slotInfo.start).isBefore(dayjs())
      if (isBeforeCurrentTime) return

      updateExistingTimeSlots(newlyCalendarTimeSlots)
    }

    setIsOpened(true)
  }

  // prevent dragging time slots before today
  const handleSelecting = useCallback((range: { start: Date; end: Date }) => {
    const isBeforeToday = checkIsBeforeToday(range.start)
    return !isBeforeToday
  }, [])

  const onView = useCallback((newView: View) => setActiveView(newView), [setActiveView])

  return (
    <>
      <section
        className={cn("h-96 min-w-[300px]", styles.timeSlotScheduleCalendarWrapper)}
        ref={CalendarRef}
        style={{ position: "relative" }}
      >
        <DragAndDropCalendar
          {...customCalendarConfig}
          components={components}
          onSelecting={handleSelecting}
          className={styles.timeSlotScheduleCalendar}
          defaultDate={defaultDate}
          events={existingTimeSlots}
          localizer={localizer}
          view={activeView}
          popup
          selectable
          onView={onView}
          onSelectSlot={handleSelectSlot}
          onRangeChange={(range) => console.log(range)}
          // TODO: handle select event
          onSelectEvent={(event, e) => {
            console.log("onSelectEvent", event)
            console.log(e)
          }}
        ></DragAndDropCalendar>
        <TimeSlotPopover
          CalendarRef={CalendarRef}
          currentSlotInfo={selectedSlot}
          selectedTimeSlots={selectedTimeSlots}
          localizer={localizer}
          isPopoverOpen={isOpen}
          handlePopoverClose={() => {
            setIsOpened(false)
          }}
        />
      </section>
    </>
  )
}

Popover组件

import { CloseOutlined, EditOutlined } from "@ant-design/icons"
import { Button, Card } from "antd"
import dayjs, { Dayjs } from "dayjs"
import React, { useEffect, useMemo, useState } from "react"
import type { DateLocalizer, SlotInfo } from "react-big-calendar"

import { type CalendarTimeSlot, useTimeSlotContext } from "../../../../../context/TimeSlotContext"
import { formatTimeSlot, generateTimeSlotUniqueId } from "../../utils"
import { EditTimeSlotForm } from "./EditTimeSlotForm"
import { TimeSlotsPopoverContent } from "./TimeSlotsPopoverContent"

interface TimeSlotPopoverProps {
  localizer: DateLocalizer
  selectedTimeSlots?: CalendarTimeSlot[]
  currentSlotInfo: SlotInfo | null
  isPopoverOpen: boolean
  handlePopoverClose: () => void
  CalendarRef: React.RefObject<HTMLDivElement>
  children?: React.ReactNode
}

enum POPOVER_MODE {
  VIEW = "VIEW",
  EDIT = "EDIT",
}

// TODO: need to enhance popup placement
export const TimeSlotPopover = ({
  isPopoverOpen,
  selectedTimeSlots,
  handlePopoverClose,
  currentSlotInfo,
  localizer,
  CalendarRef,
  children,
}: TimeSlotPopoverProps) => {
  const [popoverMode, setPopoverMode] = useState<POPOVER_MODE>(POPOVER_MODE.VIEW)
  const { partialUpdateTimeSlotBySelectedDate } = useTimeSlotContext()
  const [initialTimeSlots, setInitialTimeSlots] = useState<{
    start: Dayjs
    end: Dayjs
    key: string
  }>()

  const renderPopoverHeader = () => {
    const shouldRenderEditButton = popoverMode === POPOVER_MODE.VIEW && selectedTimeSlots.length > 0
    const formattedDate = localizer.format(currentSlotInfo.start, "ddd DD MMMM YYYY")

    return (
      <header className="flex flex-row items-center">
        <Button type="text" className="mr-2 !px-0" onClick={handlePopoverClose}>
          <CloseOutlined />
        </Button>
        {formattedDate}
        {shouldRenderEditButton && (
          <Button type="text" className="ml-auto !px-0" onClick={() => setPopoverMode(POPOVER_MODE.EDIT)}>
            <EditOutlined />
          </Button>
        )}
      </header>
    )
  }

  const renderPopoverContent = () => {
    if (popoverMode === POPOVER_MODE.EDIT) {
      return (
        <EditTimeSlotForm
          // TODO: will replace to real duration
          duration={30}
          selectedDate={currentSlotInfo.start}
          initialValues={{
            timeSlots: [
              initialTimeSlots,
              ...selectedTimeSlots.map((timeSlot) => ({
                start: dayjs(timeSlot.start),
                end: dayjs(timeSlot.end),
                key: timeSlot.id,
              })),
            ].filter((item) => item !== undefined),
          }}
          onCancelButtonClick={() => {
            setPopoverMode(POPOVER_MODE.VIEW)
            setInitialTimeSlots(undefined)
          }}
          onConfirmButtonClick={(timeSlots) => {
            const calendarTimeSlots = timeSlots.map((timeSlot) =>
              formatTimeSlot({
                start: timeSlot.start.toISOString(),
                end: timeSlot.end.toISOString(),
              })
            )
            partialUpdateTimeSlotBySelectedDate(currentSlotInfo.start, calendarTimeSlots)
            setInitialTimeSlots(undefined)
            setPopoverMode(POPOVER_MODE.VIEW)
          }}
        />
      )
    }

    const handleAddTimeSlot = () => {
      const isToday = dayjs(currentSlotInfo.start).isSame(dayjs(), "day")
      const defaultStartHour = isToday ? dayjs().hour() + 1 : 9
      const startTime = dayjs(currentSlotInfo.start).hour(defaultStartHour)

      setInitialTimeSlots({
        start: startTime,
        end: dayjs(startTime).add(30, "minute"),
        key: generateTimeSlotUniqueId({
          start: dayjs(currentSlotInfo.start).toISOString(),
          end: dayjs(currentSlotInfo.start).add(30, "minute").toISOString(),
        }),
      })
    }

    return (
      <TimeSlotsPopoverContent
        onAddTimeSlotButtonClick={() => {
          setPopoverMode(POPOVER_MODE.EDIT)
          handleAddTimeSlot()
        }}
        selectedTimeSlots={selectedTimeSlots}
        onDeleteButtonClick={(timeSlotId: string) => {
          const updatedTimeSlots = selectedTimeSlots.filter((timeSlot) => timeSlot.id !== timeSlotId)
          partialUpdateTimeSlotBySelectedDate(currentSlotInfo.start, updatedTimeSlots)
        }}
      />
    )
  }

  // reset popover mode to view when popover is open
  useEffect(() => {
    if (currentSlotInfo?.start && isPopoverOpen) {
      setPopoverMode(POPOVER_MODE.VIEW)
    }
  }, [currentSlotInfo?.start, isPopoverOpen])

  // TODO: need to enhance popup placement
  const offset = useMemo(() => {
    if (!currentSlotInfo) return null
    const { right, bottom, width, height } = CalendarRef.current.getBoundingClientRect()

    const x = currentSlotInfo?.box?.x || currentSlotInfo?.bounds?.x
    const y = currentSlotInfo?.box?.y || currentSlotInfo?.bounds?.y
    if (x > right - 330) {
      return y > bottom - 200
        ? { right: right - x, bottom: bottom - y }
        : { right: right - x, top: height - (bottom - y) }
    } else {
      return y > bottom - 200
        ? { left: width - (right - x), bottom: bottom - y }
        : { left: width - (right - x), top: height - (bottom - y) }
    }

    // if (x > window.innerWidth / 2) {
    //   x = x - 300
    // }

    // if (y > window.innerHeight / 2) {
    //   y = y - 200
    // }

    // return {
    //   left: x,
    //   top: y,
    // }
  }, [currentSlotInfo])

  if (!currentSlotInfo) return

  return (
    <>
      {isPopoverOpen ? (
        <div
          style={{
            position: "absolute",
            top: `${offset.top}px`,
            left: `${offset.left}px`,
            bottom: `${offset.bottom}px`,
            right: `${offset.right}px`,
            zIndex: 10000,
          }}
        >
          <Card title={renderPopoverHeader()} style={{ width: 330 }}>
            {renderPopoverContent()}
          </Card>
        </div>
      ) : null}
    </>

    // <Popover
    //   open={isPopoverOpen}
    //   content={renderPopoverContent}
    //   title={renderPopoverHeader}
    //   trigger="click"
    //   overlayStyle={{
    //     // position: "absolute",
    //     top: `${offset.top}px`,
    //     left: `${offset.left}px`,
    //   }}
    //   destroyTooltipOnHide
    // >
    //   {children}
    // </Popover>
  )
}