diff --git a/README.md b/README.md index 2fcdc3eb..a094aef5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,15 @@ # React Scheduler Component -[![npm package](https://img.shields.io/npm/v/@aldabil/react-scheduler/latest.svg)](https://www.npmjs.com/package/@aldabil/react-scheduler) -[![Twitter URL](https://img.shields.io/twitter/url?label=%40aldabil&style=social&url=https%3A%2F%2Ftwitter.com%2Fintent%2Ffollow%3Fscreen_name%3Daldabil21)](https://twitter.com/intent/follow?screen_name=aldabil21) +[![npm package](https://img.shields.io/npm/v/@wearemothership/react-scheduler/latest.svg)](https://www.npmjs.com/package/@wearemothership/react-scheduler) + +Based on the fantastic work of [@aldabil/react-scheduler](https://github.com/aldabil21/react-scheduler) > :warning: **Notice**: This component uses `mui`/`emotion`/`date-fns`. if your project is not already using these libs, this component may not be suitable. ## Installation ```jsx -npm i @aldabil/react-scheduler +npm i @wearemothership/react-scheduler ``` If you plan to use `recurring` events in your scheduler, install `rrule` [package](https://www.npmjs.com/package/rrule) @@ -16,7 +17,7 @@ If you plan to use `recurring` events in your scheduler, install `rrule` [packag ## Usage ```jsx -import { Scheduler } from "@aldabil/react-scheduler"; +import { Scheduler } from "@wearemothership/react-scheduler"; ``` ## Example diff --git a/package.json b/package.json index 90a01c7c..66b9bc4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "@aldabil/react-scheduler", - "version": "3.0.5", + "name": "@wearemothership/react-scheduler", + "version": "3.1.0", "description": "React scheduler component based on Material-UI & date-fns", "files": [ "*" @@ -40,7 +40,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/aldabil21/react-scheduler.git" + "url": "https://github.com/wearemothership/react-scheduler.git" }, "keywords": [ "react", @@ -51,7 +51,7 @@ "author": "Aldabil", "license": "MIT", "bugs": { - "url": "https://github.com/aldabil21/react-scheduler/issues" + "url": "https://github.com/wearemothership/react-scheduler/issues" }, "devDependencies": { "@emotion/react": "^11.14.0", @@ -89,7 +89,7 @@ "prettier": "^3.5.1", "react": ">=19.0.0", "react-dom": "^19.0.0", - "react-router-dom": "^7.3.0", + "react-router": "^7.3.0", "rollup-plugin-peer-deps-external": "^2.2.4", "rrule": "^2.8.1", "ts-jest": "^29.2.5", diff --git a/src/App.tsx b/src/App.tsx index 497560ed..ad042867 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { Scheduler } from "./lib"; import { EVENTS } from "./events"; import { useRef } from "react"; import { SchedulerRef } from "./lib/types"; -import { Link } from "react-router-dom"; +import { Link } from "react-router"; function App() { const calendarRef = useRef(null); diff --git a/src/Page1.tsx b/src/Page1.tsx index 3170f86f..d7a9a61a 100644 --- a/src/Page1.tsx +++ b/src/Page1.tsx @@ -2,7 +2,7 @@ import { Scheduler } from "./lib"; import { EVENTS } from "./events"; import { useRef } from "react"; import { SchedulerRef } from "./lib/types"; -import { Link } from "react-router-dom"; +import { Link } from "react-router"; const events = EVENTS.slice(3, 6); diff --git a/src/events.tsx b/src/events.tsx index efafeb44..b98ee1ea 100644 --- a/src/events.tsx +++ b/src/events.tsx @@ -1,13 +1,28 @@ import { datetime, RRule } from "rrule"; import { ProcessedEvent } from "./lib/types"; +const createDate = ( + startHour: number, + startMinutes: number = 0, + days: number = 0, + months: number = 0 +) => { + const date = new Date(); + date.setHours(startHour); + date.setMinutes(startMinutes); + date.setDate(date.getDate() + days); + date.setMonth(date.getMonth() + months); + + return date; +}; + export const EVENTS: ProcessedEvent[] = [ { event_id: 1, title: "Event 1 (Disabled)", subtitle: "This event is disabled", - start: new Date(new Date(new Date().setHours(9)).setMinutes(0)), - end: new Date(new Date(new Date().setHours(10)).setMinutes(0)), + start: createDate(9), + end: createDate(10), disabled: true, admin_id: [1, 2, 3, 4], }, @@ -15,8 +30,8 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 2, title: "Event 2", subtitle: "This event is draggable", - start: new Date(new Date(new Date().setHours(10)).setMinutes(0)), - end: new Date(new Date(new Date().setHours(12)).setMinutes(0)), + start: createDate(10), + end: createDate(12), admin_id: 2, color: "#50b500", agendaAvatar: "E", @@ -25,8 +40,8 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 3, title: "Event 3", subtitle: "This event is not editable", - start: new Date(new Date(new Date().setHours(11)).setMinutes(0)), - end: new Date(new Date(new Date().setHours(12)).setMinutes(0)), + start: createDate(11), + end: createDate(12), admin_id: 1, editable: false, deletable: false, @@ -34,12 +49,8 @@ export const EVENTS: ProcessedEvent[] = [ { event_id: 4, title: "Event 4", - start: new Date( - new Date(new Date(new Date().setHours(9)).setMinutes(30)).setDate(new Date().getDate() - 2) - ), - end: new Date( - new Date(new Date(new Date().setHours(11)).setMinutes(0)).setDate(new Date().getDate() - 2) - ), + start: createDate(9, 30, -2), + end: createDate(11, 0, -2), admin_id: [2, 3], color: "#900000", allDay: true, @@ -48,12 +59,8 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 5, title: "Event 5", subtitle: "This event is editable", - start: new Date( - new Date(new Date(new Date().setHours(10)).setMinutes(30)).setDate(new Date().getDate() - 2) - ), - end: new Date( - new Date(new Date(new Date().setHours(14)).setMinutes(0)).setDate(new Date().getDate() - 2) - ), + start: createDate(10, 30, -2), + end: createDate(14, 0, -2), admin_id: 2, editable: true, }, @@ -61,10 +68,8 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 6, title: "Event 6", subtitle: "This event is all day", - start: new Date( - new Date(new Date(new Date().setHours(20)).setMinutes(30)).setDate(new Date().getDate() - 3) - ), - end: new Date(new Date(new Date().setHours(23)).setMinutes(0)), + start: createDate(20, 30, -3), + end: createDate(23), admin_id: 2, allDay: true, sx: { color: "purple" }, @@ -73,12 +78,8 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 7, title: "Event 7 (Not draggable)", subtitle: "This event is not draggable", - start: new Date( - new Date(new Date(new Date().setHours(10)).setMinutes(30)).setDate(new Date().getDate() - 3) - ), - end: new Date( - new Date(new Date(new Date().setHours(14)).setMinutes(30)).setDate(new Date().getDate() - 3) - ), + start: createDate(10, 30, -3), + end: createDate(14, 30, -3), admin_id: 1, draggable: false, color: "#8000cc", @@ -87,66 +88,45 @@ export const EVENTS: ProcessedEvent[] = [ event_id: 8, title: "Event 8", subtitle: "This event has a custom color", - start: new Date( - new Date(new Date(new Date().setHours(10)).setMinutes(30)).setDate(new Date().getDate() + 30) - ), - end: new Date( - new Date(new Date(new Date().setHours(14)).setMinutes(30)).setDate(new Date().getDate() + 30) - ), + start: createDate(10, 30, 30), + end: createDate(14, 30, 30), admin_id: 1, color: "#8000cc", }, { event_id: 9, title: "Event 9", - subtitle: `This event is a recurring weekly until ${new Date( - new Date().setMonth( - new Date( - new Date(new Date(new Date().setHours(11)).setMinutes(0)).setDate( - new Date().getDate() + 1 - ) - ).getMonth() + 1 - ) - ).toDateString()}`, - start: new Date( - new Date(new Date(new Date().setHours(10)).setMinutes(0)).setDate(new Date().getDate() + 1) - ), - end: new Date( - new Date(new Date(new Date().setHours(11)).setMinutes(0)).setDate(new Date().getDate() + 1) - ), + subtitle: `This event is a recurring weekly until ${createDate(11, 0, 1, 1).toDateString()}`, + start: createDate(10, 0, 1), + end: createDate(11, 0, 1), recurring: new RRule({ freq: RRule.WEEKLY, - dtstart: convertDateToRRuleDate( - new Date( - new Date(new Date(new Date().setHours(10)).setMinutes(0)).setDate( - new Date().getDate() - 20 - ) - ) - ), - until: new Date( - new Date().setMonth( - new Date( - new Date(new Date(new Date().setHours(11)).setMinutes(0)).setDate( - new Date().getDate() + 1 - ) - ).getMonth() + 1 - ) - ), + dtstart: convertDateToRRuleDate(createDate(11, 0, -20)), + until: createDate(11, 0, 1, 1), }), }, { event_id: 10, title: "Event 10", subtitle: "This event is a recurring hourly 3 times", - start: new Date(new Date(new Date().setHours(14)).setMinutes(15)), - end: new Date(new Date(new Date().setHours(14)).setMinutes(45)), + start: createDate(14, 15), + end: createDate(14, 45), recurring: new RRule({ freq: RRule.HOURLY, count: 3, - dtstart: convertDateToRRuleDate(new Date(new Date(new Date().setHours(14)).setMinutes(15))), + dtstart: convertDateToRRuleDate(createDate(14, 15)), }), color: "#dc4552", }, + { + event_id: 11, + title: "Event 11", + subtitle: "This event is not resizable", + start: createDate(10, 30, -4), + end: createDate(12, 30, -4), + admin_id: 1, + resizable: false, + }, ]; export const RESOURCES = [ diff --git a/src/index.tsx b/src/index.tsx index c09cc6d7..f9a1df50 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"; import { createRoot } from "react-dom/client"; import App from "./App"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Route, Routes } from "react-router"; import Page1 from "./Page1"; const root = createRoot(document.getElementById("root") as HTMLElement); diff --git a/src/lib/SchedulerComponent.tsx b/src/lib/SchedulerComponent.tsx index dbe3e396..a992e87e 100644 --- a/src/lib/SchedulerComponent.tsx +++ b/src/lib/SchedulerComponent.tsx @@ -67,6 +67,7 @@ const SchedulerComponent = forwardRef(function SchedulerC flexDirection: resourceViewMode === "vertical" ? "column" : undefined, }} data-testid="grid" + id="rs__grid" > {Views} diff --git a/src/lib/components/events/EventItem.tsx b/src/lib/components/events/EventItem.tsx index 69abfa75..40df4348 100644 --- a/src/lib/components/events/EventItem.tsx +++ b/src/lib/components/events/EventItem.tsx @@ -1,15 +1,16 @@ -import { Fragment, MouseEvent, useCallback, useMemo, useState } from "react"; -import { Typography, ButtonBase, useTheme } from "@mui/material"; +import { Fragment, MouseEvent, useCallback, useMemo, useRef, useState } from "react"; +import { Typography, ButtonBase, useTheme, Popper } from "@mui/material"; import { format } from "date-fns"; import { ProcessedEvent } from "../../types"; import ArrowRightRoundedIcon from "@mui/icons-material/ArrowRightRounded"; import ArrowLeftRoundedIcon from "@mui/icons-material/ArrowLeftRounded"; -import { EventItemPaper } from "../../styles/styles"; +import { DragHandle, EventItemPaper } from "../../styles/styles"; import { differenceInDaysOmitTime, getHourFormat } from "../../helpers/generals"; import useStore from "../../hooks/useStore"; import useDragAttributes from "../../hooks/useDragAttributes"; import EventItemPopover from "./EventItemPopover"; import useEventPermissions from "../../hooks/useEventPermissions"; +import useResizeAttributes from "../../hooks/useResizeAttributes"; interface EventItemProps { event: ProcessedEvent; @@ -20,9 +21,24 @@ interface EventItemProps { } const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: EventItemProps) => { - const { direction, locale, hourFormat, eventRenderer, onEventClick, view, disableViewer } = - useStore(); + const { + direction, + locale, + hourFormat, + eventRenderer, + onEventClick, + view, + disableViewer, + minuteHeight, + } = useStore(); + const dragHandleRef = useRef(null); + const [dragTime, setDragTime] = useState(); + const onDragMove = useCallback((time: Date | undefined) => { + setDragTime(time); + }, []); + const dragProps = useDragAttributes(event); + const resizeProps = useResizeAttributes(event, onDragMove); const [anchorEl, setAnchorEl] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(false); const theme = useTheme(); @@ -32,7 +48,11 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event const PrevArrow = direction === "rtl" ? ArrowRightRoundedIcon : ArrowLeftRoundedIcon; const hideDates = differenceInDaysOmitTime(event.start, event.end) <= 0 && event.allDay; - const { canDrag } = useEventPermissions(event); + const { canDrag, canResize } = useEventPermissions(event); + const resizable = useMemo( + () => !!canResize && !!minuteHeight && !multiday, + [canResize, minuteHeight, multiday] + ); const triggerViewer = useCallback( (el?: MouseEvent) => { @@ -53,6 +73,7 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event return ( {custom} + ); } @@ -124,9 +145,7 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event if (!disableViewer) { triggerViewer(e); } - if (typeof onEventClick === "function") { - onEventClick(event); - } + onEventClick?.(event); }} focusRipple tabIndex={disableViewer ? -1 : 0} @@ -137,6 +156,7 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event {item} + ); }, [ @@ -159,6 +179,8 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event hasNext, NextArrow, onEventClick, + resizable, + resizeProps, ]); return ( @@ -167,6 +189,16 @@ const EventItem = ({ event, multiday, hasPrev, hasNext, showdate = true }: Event {/* Viewer */} + + + {dragTime ? format(dragTime, hFormat, { locale }) : null} + + ); }; diff --git a/src/lib/components/week/WeekTable.tsx b/src/lib/components/week/WeekTable.tsx index 510604b1..750274be 100644 --- a/src/lib/components/week/WeekTable.tsx +++ b/src/lib/components/week/WeekTable.tsx @@ -6,6 +6,7 @@ import { filterMultiDaySlot, filterTodayEvents, getHourFormat, + preventDragEvent, } from "../../helpers/generals"; import { MULTI_DAY_EVENT_HEIGHT } from "../../helpers/constants"; import { DefaultResource, ProcessedEvent } from "../../types"; @@ -31,7 +32,7 @@ type Props = { daysList: Date[]; hours: Date[]; cellHeight: number; - minutesHeight: number; + minuteHeight: number; resource?: DefaultResource; resourcedEvents: ProcessedEvent[]; }; @@ -40,7 +41,7 @@ const WeekTable = ({ daysList, hours, cellHeight, - minutesHeight, + minuteHeight, resourcedEvents, resource, }: Props) => { @@ -138,6 +139,7 @@ const WeekTable = ({ {daysList.map((date, i) => ( @@ -170,13 +172,18 @@ const WeekTable = ({ const end = addMinutes(start, step); const field = resourceFields.idField; return ( - + {/* Events of each day - run once on the top hour column */} {i === 0 && ( ): View => { if (state.month) { @@ -125,6 +126,10 @@ export const differenceInDaysOmitTime = (start: Date, end: Date) => { return differenceInDays(endOfDay(addSeconds(end, -1)), startOfDay(start)); }; +export const preventDragEvent = (ev: DragEvent) => { + ev.preventDefault(); +}; + export const convertRRuleDateToDate = (rruleDate: Date) => { return new Date( rruleDate.getUTCFullYear(), diff --git a/src/lib/hooks/useCellAttributes.ts b/src/lib/hooks/useCellAttributes.ts index 73654ff4..80407445 100644 --- a/src/lib/hooks/useCellAttributes.ts +++ b/src/lib/hooks/useCellAttributes.ts @@ -18,11 +18,14 @@ export const useCellAttributes = ({ start, end, resourceKey, resourceVal }: Prop setCurrentDragged, editable, timeZone, + view, } = useStore(); const theme = useTheme(); return { tabIndex: editable ? 0 : -1, + "data-start": start, + "data-end": end, disableRipple: !editable, onClick: () => { if (editable) { @@ -39,22 +42,22 @@ export const useCellAttributes = ({ start, end, resourceKey, resourceVal }: Prop }, onDragOver: (e: DragEvent) => { e.preventDefault(); - if (currentDragged) { + if (currentDragged && view === "month") { e.currentTarget.style.backgroundColor = alpha(theme.palette.secondary.main, 0.3); } }, onDragEnter: (e: DragEvent) => { - if (currentDragged) { + if (currentDragged && view === "month") { e.currentTarget.style.backgroundColor = alpha(theme.palette.secondary.main, 0.3); } }, onDragLeave: (e: DragEvent) => { - if (currentDragged) { + if (currentDragged && view === "month") { e.currentTarget.style.backgroundColor = ""; } }, onDrop: (e: DragEvent) => { - if (currentDragged && currentDragged.event_id) { + if (currentDragged && currentDragged.event_id && view === "month") { e.preventDefault(); e.currentTarget.style.backgroundColor = ""; const zonedStart = revertTimeZonedDate(start, timeZone); diff --git a/src/lib/hooks/useDragAttributes.ts b/src/lib/hooks/useDragAttributes.ts index 1e738628..046842a9 100644 --- a/src/lib/hooks/useDragAttributes.ts +++ b/src/lib/hooks/useDragAttributes.ts @@ -2,19 +2,150 @@ import { DragEvent } from "react"; import { ProcessedEvent } from "../types"; import { useTheme } from "@mui/material"; import useStore from "./useStore"; +import { addMinutes, format } from "date-fns"; + +const img = new Image(); +img.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; + +let startPos: [number, number] | undefined; +const timeCell = document.createElement("div"); +timeCell.style.position = "absolute"; +timeCell.style.top = "-20px"; +timeCell.style.width = "100%"; +timeCell.style.textAlign = "center"; +timeCell.style.fontSize = "12px"; + +const findRsCell = ( + bounds: DOMRect, + cells: NodeListOf, + withMiddle = false +): Element | null => { + const middle = withMiddle ? (bounds.right - bounds.left) / 2 + bounds.left : bounds.left; + let found: Element | null = null; + for (const cell of cells) { + const cellRect = cell.getBoundingClientRect(); + if ( + cellRect.top < bounds.top && + cellRect.bottom > bounds.top && + cellRect.left < middle && + cellRect.right > middle + ) { + found = cell; + break; + } + } + return found; +}; const useDragAttributes = (event: ProcessedEvent) => { - const { setCurrentDragged } = useStore(); + const { setCurrentDragged, minuteHeight, currentDragged, onDrop } = useStore(); const theme = useTheme(); + const headerRect = document.querySelector(".rs__header")?.getBoundingClientRect(); + const gridRect = document.querySelector("#rs__grid")?.getBoundingClientRect(); return { draggable: true, onDragStart: (e: DragEvent) => { e.stopPropagation(); setCurrentDragged(event); + e.dataTransfer.setDragImage(img, 0, 0); + startPos = [e.clientX, e.clientY]; e.currentTarget.style.backgroundColor = theme.palette.error.main; }, + onDrag: (e: DragEvent) => { + if (currentDragged && startPos) { + if (currentDragged.allDay) { + const cell = e.currentTarget.closest(".rs__multi_day") as HTMLElement; + if (cell) { + let diff = e.clientX - startPos[0]; + cell.style.transform = `translateX(${diff}px)`; + const header = cell.closest(".rs__header") as HTMLElement; + const headers = header?.parentElement?.querySelectorAll(".rs__header"); + if (headers) { + let rect = cell.getBoundingClientRect(); + const leftmost = headers.item(0).getBoundingClientRect().left; + const rightmost = headers.item(headers.length - 1).getBoundingClientRect().right; + if (rect.left < leftmost) { + diff += leftmost - rect.left; + } + if (rect.right > rightmost) { + diff += rightmost - rect.right; + } + cell.style.transform = `translateX(${diff}px)`; + rect = cell.getBoundingClientRect(); + const found = findRsCell(rect, headers) as HTMLElement; + const dateString = found?.dataset.date; + if (dateString) { + timeCell.dataset.time = dateString; + } + } + } + } else { + const cell = e.currentTarget.closest(".rs__event__item") as HTMLElement; + if (cell && headerRect && gridRect) { + const diff = [e.clientX - startPos[0], e.clientY - startPos[1]]; + cell.style.transform = `translate(${diff[0]}px, ${diff[1]}px)`; + let rect = cell.getBoundingClientRect(); + if (rect.left < headerRect.left) { + diff[0] += headerRect.left - rect.left; + } + if (rect.right > gridRect.right) { + diff[0] += gridRect.right - rect.right; + } + if (rect.top < headerRect.bottom) { + diff[1] += headerRect.bottom - rect.top; + } + if (rect.bottom > gridRect.bottom) { + diff[1] += gridRect.bottom - rect.bottom; + } + + cell.style.transform = `translate(${diff[0]}px, ${diff[1]}px)`; + rect = cell.getBoundingClientRect(); + const timeCells = document.querySelectorAll(".rs__cell:not(.rs__header)"); + const found = findRsCell(rect, timeCells, true); + + if (found && minuteHeight && timeCell) { + cell.appendChild(timeCell); + const button = found.querySelector("& > button") as HTMLButtonElement; + const fRect = found.getBoundingClientRect(); + const dateString = button?.dataset.start; + const topDiff = rect.top - fRect.top; + const mins = topDiff / minuteHeight; + if (dateString) { + const dd = new Date(dateString); + const newDD = addMinutes(dd, mins); + timeCell.dataset.time = newDD.toString(); + timeCell.innerText = format(newDD, "Pp"); + } + } + } + } + } + }, onDragEnd: (e: DragEvent) => { + if (currentDragged) { + if (currentDragged.allDay) { + const cell = e.currentTarget.closest(".rs__multi_day") as HTMLElement; + if (cell) { + cell.style.transform = "unset"; + } + const dateString = timeCell.dataset.time; + if (dateString) { + onDrop(e, currentDragged.event_id.toString(), new Date(dateString)); + } + } else { + const cell = e.currentTarget.closest(".rs__event__item") as HTMLElement; + if (cell) { + cell.removeChild(timeCell); + cell.style.transform = "unset"; + } + const dateString = timeCell.dataset.time; + if (dateString) { + onDrop(e, currentDragged.event_id.toString(), new Date(dateString)); + } + } + } setCurrentDragged(); + startPos = undefined; e.currentTarget.style.backgroundColor = event.color || theme.palette.primary.main; }, onDragOver: (e: DragEvent) => { diff --git a/src/lib/hooks/useEventPermissions.ts b/src/lib/hooks/useEventPermissions.ts index 268f09ab..9f7657c1 100644 --- a/src/lib/hooks/useEventPermissions.ts +++ b/src/lib/hooks/useEventPermissions.ts @@ -2,8 +2,15 @@ import { useMemo } from "react"; import { ProcessedEvent } from "../types"; import useStore from "./useStore"; -const useEventPermissions = (event: ProcessedEvent) => { - const { editable, deletable, draggable } = useStore(); +type UseEventPermissions = { + canEdit?: boolean; + canDelete?: boolean; + canDrag?: boolean; + canResize?: boolean; +}; + +const useEventPermissions = (event: ProcessedEvent): UseEventPermissions => { + const { editable, deletable, draggable, resizable } = useStore(); const canEdit = useMemo(() => { // Priority control to event specific editable value @@ -32,10 +39,22 @@ const useEventPermissions = (event: ProcessedEvent) => { return draggable; }, [draggable, event.draggable, canEdit]); + const canResize = useMemo(() => { + if (!canEdit) { + return; + } + // Priority control to event specific draggable value + if (typeof event.resizable !== "undefined") { + return event.resizable; + } + return resizable; + }, [resizable, event.resizable, canEdit]); + return { canEdit, canDelete, canDrag, + canResize, }; }; diff --git a/src/lib/hooks/useResizeAttributes.ts b/src/lib/hooks/useResizeAttributes.ts new file mode 100644 index 00000000..555ba9a3 --- /dev/null +++ b/src/lib/hooks/useResizeAttributes.ts @@ -0,0 +1,47 @@ +import { DragEvent, useMemo } from "react"; +import { ProcessedEvent } from "../types"; +import useStore from "./useStore"; +import { DRAG_IMAGE } from "../helpers/constants"; + +const useResizeAttributes = ( + event: ProcessedEvent, + onDragMove?: (time: Date | undefined) => void +) => { + const { setCurrentResize, onResize, onResizeEnd, minuteHeight } = useStore(); + const handlers = useMemo( + () => + minuteHeight + ? { + draggable: true, + onDragStart: (e: DragEvent) => { + e.stopPropagation(); + setCurrentResize(event); + e.dataTransfer.setDragImage(DRAG_IMAGE, 0, 0); + }, + onDragEnd: (e: DragEvent) => { + setCurrentResize(); + onResizeEnd(e, event, minuteHeight); + onDragMove?.(undefined); + }, + onDrag: (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + const date = onResize(e, event, minuteHeight); + onDragMove?.(date); + }, + onDragOver: (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, + onDragEnter: (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + }, + } + : { draggable: false }, + [event, minuteHeight, onResize, setCurrentResize, onResizeEnd, onDragMove] + ); + return handlers; +}; + +export default useResizeAttributes; diff --git a/src/lib/store/default.ts b/src/lib/store/default.ts index 472cd4f9..c4ad045a 100644 --- a/src/lib/store/default.ts +++ b/src/lib/store/default.ts @@ -128,6 +128,7 @@ export const defaultProps = (props: Partial) => { locale: enUS, deletable: true, editable: true, + resizable: true, hourFormat: hourFormat || "12", draggable: true, agenda, @@ -151,5 +152,9 @@ export const initialStore = { handleGotoDay: () => {}, confirmEvent: () => {}, setCurrentDragged: () => {}, + setMinuteHeight: () => {}, + setCurrentResize: () => {}, onDrop: () => {}, + onResize: () => undefined, + onResizeEnd: () => {}, }; diff --git a/src/lib/store/provider.tsx b/src/lib/store/provider.tsx index e095f241..f048b629 100644 --- a/src/lib/store/provider.tsx +++ b/src/lib/store/provider.tsx @@ -1,4 +1,4 @@ -import { DragEvent, useEffect, useState } from "react"; +import { DragEvent, useCallback, useEffect, useMemo, useState } from "react"; import { EventActions, ProcessedEvent, SchedulerProps } from "../types"; import { defaultProps, initialStore } from "./default"; import { StoreContext } from "./context"; @@ -20,19 +20,23 @@ export const StoreProvider = ({ children, initial }: Props) => { ...prev, onEventDrop: initial.onEventDrop, customEditor: initial.customEditor, + onEventResize: initial.onEventResize, events: initial.events || [], })); - }, [initial.onEventDrop, initial.customEditor, initial.events]); + }, [initial.onEventDrop, initial.customEditor, initial.events, initial.onEventResize]); - const handleState = (value: SchedulerState[keyof SchedulerState], name: keyof SchedulerState) => { - set((prev) => ({ ...prev, [name]: value })); - }; + const handleState = useCallback( + (value: SchedulerState[keyof SchedulerState], name: keyof SchedulerState) => { + set((prev) => ({ ...prev, [name]: value })); + }, + [] + ); - const getViews = () => { + const getViews = useCallback(() => { return getAvailableViews(state); - }; + }, [state]); - const toggleAgenda = () => { + const toggleAgenda = useCallback(() => { set((prev) => { const newStatus = !prev.agenda; @@ -42,163 +46,254 @@ export const StoreProvider = ({ children, initial }: Props) => { return { ...prev, agenda: newStatus }; }); - }; + }, [state]); - const triggerDialog = (status: boolean, selected?: SelectedRange | ProcessedEvent) => { - const isEvent = selected as ProcessedEvent; + const triggerDialog = useCallback( + (status: boolean, selected?: SelectedRange | ProcessedEvent) => { + const isEvent = selected as ProcessedEvent; - set((prev) => ({ - ...prev, - dialog: status, - selectedRange: isEvent?.event_id - ? undefined - : isEvent || { - start: new Date(), - end: new Date(Date.now() + 60 * 60 * 1000), - }, - selectedEvent: isEvent?.event_id ? isEvent : undefined, - selectedResource: prev.selectedResource || isEvent?.[state.resourceFields?.idField], - })); - }; - - const triggerLoading = (status: boolean) => { - // Trigger if not out-sourced by props - if (typeof initial.loading === "undefined") { - set((prev) => ({ ...prev, loading: status })); - } - }; - - const handleGotoDay = (day: Date) => { - const currentViews = getViews(); - let view: View | undefined; - if (currentViews.includes("day")) { - view = "day"; - set((prev) => ({ ...prev, view: "day", selectedDate: day })); - } else if (currentViews.includes("week")) { - view = "week"; - set((prev) => ({ ...prev, view: "week", selectedDate: day })); - } else { - console.warn("No Day/Week views available"); - } - - if (!!view && state.onViewChange && typeof state.onViewChange === "function") { - state.onViewChange(view, state.agenda); - } - - if (!!view && state.onSelectedDateChange && typeof state.onSelectedDateChange === "function") { - state.onSelectedDateChange(day); - } - }; - - const confirmEvent = (event: ProcessedEvent | ProcessedEvent[], action: EventActions) => { - let updatedEvents: ProcessedEvent[]; - if (action === "edit") { - if (Array.isArray(event)) { - updatedEvents = state.events.map((e) => { - const exist = event.find((ex) => ex.event_id === e.event_id); - return exist ? { ...e, ...exist } : e; - }); + set((prev) => ({ + ...prev, + dialog: status, + selectedRange: isEvent?.event_id + ? undefined + : isEvent || { + start: new Date(), + end: new Date(Date.now() + 60 * 60 * 1000), + }, + selectedEvent: isEvent?.event_id ? isEvent : undefined, + selectedResource: prev.selectedResource || isEvent?.[state.resourceFields?.idField], + })); + }, + [state.resourceFields?.idField] + ); + + const triggerLoading = useCallback( + (status: boolean) => { + // Trigger if not out-sourced by props + if (typeof initial.loading === "undefined") { + set((prev) => ({ ...prev, loading: status })); + } + }, + [initial.loading] + ); + + const handleGotoDay = useCallback( + (day: Date) => { + const currentViews = getViews(); + let view: View | undefined; + if (currentViews.includes("day")) { + view = "day"; + set((prev) => ({ ...prev, view: "day", selectedDate: day })); + } else if (currentViews.includes("week")) { + view = "week"; + set((prev) => ({ ...prev, view: "week", selectedDate: day })); } else { - updatedEvents = state.events.map((e) => - e.event_id === event.event_id ? { ...e, ...event } : e - ); + console.warn("No Day/Week views available"); } - } else { - updatedEvents = state.events.concat(event); - } - set((prev) => ({ ...prev, events: updatedEvents })); - }; - const setCurrentDragged = (event?: ProcessedEvent) => { - set((prev) => ({ ...prev, currentDragged: event })); - }; - - const onDrop = async ( - event: DragEvent, - eventId: string, - startTime: Date, - resKey?: string, - resVal?: string | number - ) => { - // Get dropped event - const droppedEvent = state.events.find((e) => { - if (typeof e.event_id === "number") { - return e.event_id === +eventId; + if (!!view && state.onViewChange && typeof state.onViewChange === "function") { + state.onViewChange(view, state.agenda); } - return e.event_id === eventId; - }) as ProcessedEvent; - - // Check if has resource and if is multiple - const resField = state.fields.find((f) => f.name === resKey); - const isMultiple = !!resField?.config?.multiple; - let newResource = resVal as string | number | string[] | number[]; - if (resField) { - const eResource = droppedEvent[resKey as string]; - const currentRes = arraytizeFieldVal(resField, eResource, droppedEvent).value; - if (isMultiple) { - // if dropped on already owned resource - if (currentRes.includes(resVal)) { - // Omit if dropped on same time slot for multiple event - if (isEqual(droppedEvent.start, startTime)) { - return; - } - newResource = currentRes; + + if ( + !!view && + state.onSelectedDateChange && + typeof state.onSelectedDateChange === "function" + ) { + state.onSelectedDateChange(day); + } + }, + [getViews, state] + ); + + const confirmEvent = useCallback( + (event: ProcessedEvent | ProcessedEvent[], action: EventActions) => { + let updatedEvents: ProcessedEvent[]; + if (action === "edit") { + if (Array.isArray(event)) { + updatedEvents = state.events.map((e) => { + const exist = event.find((ex) => ex.event_id === e.event_id); + return exist ? { ...e, ...exist } : e; + }); } else { - // if have multiple resource ? add other : move to other - newResource = currentRes.length > 1 ? [...currentRes, resVal] : [resVal]; + updatedEvents = state.events.map((e) => + e.event_id === event.event_id ? { ...e, ...event } : e + ); + } + } else { + updatedEvents = state.events.concat(event); + } + set((prev) => ({ ...prev, events: updatedEvents })); + }, + [state.events] + ); + + const setCurrentDragged = useCallback((event?: ProcessedEvent) => { + set((prev) => ({ ...prev, currentDragged: event })); + }, []); + + const setCurrentResize = useCallback((event?: ProcessedEvent) => { + set((prev) => ({ ...prev, currentResize: event })); + }, []); + + const setMinuteHeight = useCallback((height?: number) => { + set((prev) => ({ ...prev, minuteHeight: height })); + }, []); + + const onDrop = useCallback( + async ( + event: DragEvent, + eventId: string, + startTime: Date, + resKey?: string, + resVal?: string | number + ) => { + // Get dropped event + const droppedEvent = state.events.find((e) => { + if (typeof e.event_id === "number") { + return e.event_id === +eventId; + } + return e.event_id === eventId; + }) as ProcessedEvent; + + // Check if has resource and if is multiple + const resField = state.fields.find((f) => f.name === resKey); + const isMultiple = !!resField?.config?.multiple; + let newResource = resVal as string | number | string[] | number[]; + if (resField) { + const eResource = droppedEvent[resKey as string]; + const currentRes = arraytizeFieldVal(resField, eResource, droppedEvent).value; + if (isMultiple) { + // if dropped on already owned resource + if (currentRes.includes(resVal)) { + // Omit if dropped on same time slot for multiple event + if (isEqual(droppedEvent.start, startTime)) { + return; + } + newResource = currentRes; + } else { + // if have multiple resource ? add other : move to other + newResource = currentRes.length > 1 ? [...currentRes, resVal] : [resVal]; + } + } + } + + // Omit if dropped on same time slot for non multiple events + if (isEqual(droppedEvent.start, startTime)) { + if (!newResource || (!isMultiple && newResource === droppedEvent[resKey as string])) { + return; } } - } - // Omit if dropped on same time slot for non multiple events - if (isEqual(droppedEvent.start, startTime)) { - if (!newResource || (!isMultiple && newResource === droppedEvent[resKey as string])) { - return; + // Update event time according to original duration & update resources/owners + const diff = differenceInMinutes(droppedEvent.end, droppedEvent.start); + const updatedEvent: ProcessedEvent = { + ...droppedEvent, + start: startTime, + end: addMinutes(startTime, diff), + recurring: undefined, + [resKey as string]: newResource || "", + }; + + // Local + if (!state.onEventDrop || typeof state.onEventDrop !== "function") { + return confirmEvent(updatedEvent, "edit"); + } + // Remote + try { + triggerLoading(true); + const _event = await state.onEventDrop(event, startTime, updatedEvent, droppedEvent); + if (_event) { + confirmEvent(_event, "edit"); + } + } finally { + triggerLoading(false); } - } - - // Update event time according to original duration & update resources/owners - const diff = differenceInMinutes(droppedEvent.end, droppedEvent.start); - const updatedEvent: ProcessedEvent = { - ...droppedEvent, - start: startTime, - end: addMinutes(startTime, diff), - recurring: undefined, - [resKey as string]: newResource || "", - }; - - // Local - if (!state.onEventDrop || typeof state.onEventDrop !== "function") { - return confirmEvent(updatedEvent, "edit"); - } - // Remote - try { - triggerLoading(true); - const _event = await state.onEventDrop(event, startTime, updatedEvent, droppedEvent); - if (_event) { - confirmEvent(_event, "edit"); + }, + [confirmEvent, state, triggerLoading] + ); + + const onResize = useCallback( + (ev: DragEvent, event: ProcessedEvent, minuteHeight: number): Date | undefined => { + const eventItem = ev.currentTarget.closest("div.rs__event__item") as HTMLDivElement | null; + if (eventItem) { + const { top } = eventItem.getBoundingClientRect(); + const diff = Math.max(ev.clientY - top, minuteHeight); + const minutes = diff / minuteHeight; + eventItem.style.height = `${diff}px`; + return addMinutes(event.start, minutes); } - } finally { - triggerLoading(false); - } - }; - - return ( - - {children} - + }, + [] ); + + const onResizeEnd = useCallback( + (ev: DragEvent, event: ProcessedEvent, minuteHeight: number) => { + const eventItem = ev.currentTarget.closest("div.rs__event__item") as HTMLDivElement | null; + if (eventItem) { + const { height } = eventItem.getBoundingClientRect(); + const minutes = height / minuteHeight; + + const updatedEvent: ProcessedEvent = { + ...event, + end: addMinutes(event.start, minutes), + recurring: undefined, + }; + + // Local + if (!state.onEventResize || typeof state.onEventResize !== "function") { + return confirmEvent(updatedEvent, "edit"); + } + // Remote + try { + triggerLoading(true); + const _event = state.onEventResize(ev, updatedEvent, event); + if (_event) { + confirmEvent(_event, "edit"); + } + } finally { + triggerLoading(false); + } + } + }, + [confirmEvent, state, triggerLoading] + ); + + const value = useMemo( + () => ({ + ...state, + handleState, + getViews, + toggleAgenda, + triggerDialog, + triggerLoading, + handleGotoDay, + confirmEvent, + setCurrentDragged, + setCurrentResize, + setMinuteHeight, + onDrop, + onResize, + onResizeEnd, + }), + [ + confirmEvent, + getViews, + handleGotoDay, + handleState, + onDrop, + onResize, + setCurrentDragged, + setCurrentResize, + setMinuteHeight, + state, + toggleAgenda, + triggerDialog, + triggerLoading, + onResizeEnd, + ] + ); + + return {children}; }; diff --git a/src/lib/store/types.ts b/src/lib/store/types.ts index dd1e0d0b..db48a701 100644 --- a/src/lib/store/types.ts +++ b/src/lib/store/types.ts @@ -6,10 +6,12 @@ export type SelectedRange = { start: Date; end: Date }; export interface SchedulerState extends SchedulerProps { dialog: boolean; + minuteHeight?: number; selectedRange?: SelectedRange; selectedEvent?: ProcessedEvent; selectedResource?: DefaultResource["assignee"]; currentDragged?: ProcessedEvent; + currentResize?: ProcessedEvent; enableAgenda?: boolean; } @@ -22,11 +24,19 @@ export interface Store extends SchedulerState { handleGotoDay(day: Date): void; confirmEvent(event: ProcessedEvent | ProcessedEvent[], action: EventActions): void; setCurrentDragged(event?: ProcessedEvent): void; + setCurrentResize(event?: ProcessedEvent): void; + setMinuteHeight(height?: number): void; onDrop( - event: DragEvent, + event: DragEvent, eventId: string, droppedStartTime: Date, resourceKey?: string, resourceVal?: string | number ): void; + onResize( + ev: DragEvent, + event: ProcessedEvent, + minuteHeight: number + ): Date | undefined; + onResizeEnd(ev: DragEvent, event: ProcessedEvent, minuteHeight: number): void; } diff --git a/src/lib/styles/styles.ts b/src/lib/styles/styles.ts index 041e0cce..2d2fc7ba 100644 --- a/src/lib/styles/styles.ts +++ b/src/lib/styles/styles.ts @@ -177,6 +177,7 @@ export const EventItemPaper = styled(Paper)<{ disabled?: boolean }>(({ disabled display: "block", cursor: disabled ? "not-allowed" : "pointer", overflow: "hidden", + position: "relative", "& .MuiButtonBase-root": { width: "100%", height: "100%", @@ -238,3 +239,13 @@ export const TimeIndicatorBar = styled("div")(({ theme }) => ({ width: "100%", }, })); + +export const DragHandle = styled("div")(({ draggable }) => ({ + position: "absolute", + bottom: 0, + left: 0, + width: "100%", + cursor: draggable ? "ns-resize" : "unset", + backgroundColor: "rgba(0,0,0,0.0001)", + height: "5px", +})); diff --git a/src/lib/types.ts b/src/lib/types.ts index 35e8ad97..b3c6cf33 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -48,7 +48,7 @@ export interface CellRenderedProps { onDragOver(e: DragEvent): void; onDragEnter(e: DragEvent): void; onDragLeave(e: DragEvent): void; - onDrop(e: DragEvent): void; + onDrop(e: DragEvent): void; } interface CalendarEvent { event_id: number | string; @@ -63,6 +63,7 @@ interface CalendarEvent { editable?: boolean; deletable?: boolean; draggable?: boolean; + resizable?: boolean; allDay?: boolean; /** * @default " " @@ -290,11 +291,19 @@ export interface SchedulerProps { * Triggered when event is dropped on time slot. */ onEventDrop?( - event: DragEvent, + event: DragEvent, droppedOn: Date, updatedEvent: ProcessedEvent, originalEvent: ProcessedEvent ): Promise; + /** + * Triggered when event is resized. + */ + onEventResize?( + event: DragEvent, + updatedEvent: ProcessedEvent, + originalEvent: ProcessedEvent + ): ProcessedEvent | void; /** * */ @@ -318,6 +327,11 @@ export interface SchedulerProps { * @default true */ draggable?: boolean; + /** + * If event is resizable, applied to all events globally, overridden by event specific resizable prop + * @default true + */ + resizable?: boolean; /** * Triggered when the `selectedDate` prop changes by navigation date picker or `today` button. */ diff --git a/src/lib/views/Day.tsx b/src/lib/views/Day.tsx index 6e6ad399..dc33a1de 100644 --- a/src/lib/views/Day.tsx +++ b/src/lib/views/Day.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, Fragment, JSX } from "react"; +import { useEffect, useCallback, Fragment, JSX, useMemo } from "react"; import { Typography } from "@mui/material"; import { format, @@ -50,6 +50,7 @@ const Day = () => { getRemoteEvents, triggerLoading, handleState, + setMinuteHeight, resources, resourceFields, resourceViewMode, @@ -63,19 +64,28 @@ const Day = () => { } = useStore(); const { startHour, endHour, step, cellRenderer, headRenderer, hourRenderer } = day!; - const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }); - const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }); - const hours = eachMinuteOfInterval( - { - start: START_TIME, - end: END_TIME, - }, - { step: step } - ); - const CELL_HEIGHT = calcCellHeight(height, hours.length); - const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step); + const hFormat = getHourFormat(hourFormat); + const [hours, CELL_HEIGHT, MINUTE_HEIGHT, START_TIME, END_TIME] = useMemo(() => { + const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }); + const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }); + const hours = eachMinuteOfInterval( + { + start: START_TIME, + end: END_TIME, + }, + { step: step } + ); + const CELL_HEIGHT = calcCellHeight(height, hours.length); + const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step); + return [hours, CELL_HEIGHT, MINUTE_HEIGHT, START_TIME, END_TIME]; + }, [endHour, height, selectedDate, startHour, step]); + + useEffect(() => { + setMinuteHeight(MINUTE_HEIGHT); + }, [MINUTE_HEIGHT, setMinuteHeight]); + const fetchEvents = useCallback(async () => { try { triggerLoading(true); @@ -220,8 +230,8 @@ const Day = () => { }, [ CELL_HEIGHT, - MINUTE_HEIGHT, START_TIME, + MINUTE_HEIGHT, agenda, cellRenderer, direction, diff --git a/src/lib/views/Month.tsx b/src/lib/views/Month.tsx index 4c6454f9..c45affb8 100644 --- a/src/lib/views/Month.tsx +++ b/src/lib/views/Month.tsx @@ -27,6 +27,7 @@ const Month = () => { getRemoteEvents, triggerLoading, handleState, + setMinuteHeight, resources, resourceFields, fields, @@ -36,6 +37,9 @@ const Month = () => { const { weekStartOn, weekDays } = month!; const monthStart = startOfMonth(selectedDate); const monthEnd = endOfMonth(selectedDate); + useEffect(() => { + setMinuteHeight(); + }, [setMinuteHeight]); const eachWeekStart = eachWeekOfInterval( { start: monthStart, diff --git a/src/lib/views/Week.tsx b/src/lib/views/Week.tsx index 62aac917..80a9f527 100644 --- a/src/lib/views/Week.tsx +++ b/src/lib/views/Week.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, JSX } from "react"; +import { useEffect, useCallback, JSX, useMemo } from "react"; import { startOfWeek, addDays, eachMinuteOfInterval, endOfDay, startOfDay, set } from "date-fns"; import { CellRenderedProps, DayHours, DefaultResource } from "../types"; import { WeekDays } from "./Month"; @@ -34,23 +34,31 @@ const Week = () => { resourceFields, fields, agenda, + setMinuteHeight, } = useStore(); const { weekStartOn, weekDays, startHour, endHour, step } = week!; const _weekStart = startOfWeek(selectedDate, { weekStartsOn: weekStartOn }); const daysList = weekDays.map((d) => addDays(_weekStart, d)); const weekStart = startOfDay(daysList[0]); const weekEnd = endOfDay(daysList[daysList.length - 1]); - const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }); - const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }); - const hours = eachMinuteOfInterval( - { - start: START_TIME, - end: END_TIME, - }, - { step } - ); - const CELL_HEIGHT = calcCellHeight(height, hours.length); - const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step); + const [hours, CELL_HEIGHT, MINUTE_HEIGHT] = useMemo(() => { + const START_TIME = set(selectedDate, { hours: startHour, minutes: 0, seconds: 0 }); + const END_TIME = set(selectedDate, { hours: endHour, minutes: -step, seconds: 0 }); + const hours = eachMinuteOfInterval( + { + start: START_TIME, + end: END_TIME, + }, + { step } + ); + const CELL_HEIGHT = calcCellHeight(height, hours.length); + const MINUTE_HEIGHT = calcMinuteHeight(CELL_HEIGHT, step); + return [hours, CELL_HEIGHT, MINUTE_HEIGHT]; + }, [endHour, height, selectedDate, startHour, step]); + + useEffect(() => { + setMinuteHeight(MINUTE_HEIGHT); + }, [MINUTE_HEIGHT, setMinuteHeight]); const fetchEvents = useCallback(async () => { try { @@ -94,7 +102,7 @@ const Week = () => { resource={resource} hours={hours} cellHeight={CELL_HEIGHT} - minutesHeight={MINUTE_HEIGHT} + minuteHeight={MINUTE_HEIGHT} daysList={daysList} /> );