From e2fd1b34e8d930e70b420208ea9d4d7d5485e3d2 Mon Sep 17 00:00:00 2001 From: Eric Hahn Date: Mon, 19 Mar 2018 07:33:01 -0700 Subject: [PATCH] DnD support for custom event components (#716) * mostly new DnD implementation * comment/docs * use native storybook actions * Preserve dates on non-allDay DnD, improve docs * added alert() in resize example --- examples/demos/dnd.js | 2 + src/DayColumn.js | 8 +- src/EventCell.js | 21 +- .../dragAndDrop/DraggableEventWrapper.js | 165 +++++++++++--- src/addons/dragAndDrop/DropWrappers.js | 205 ++++++++++++++++++ src/addons/dragAndDrop/ResizableEvent.js | 112 ---------- src/addons/dragAndDrop/ResizableMonthEvent.js | 61 ------ src/addons/dragAndDrop/backgroundWrapper.js | 176 --------------- src/addons/dragAndDrop/index.js | 123 +---------- src/addons/dragAndDrop/styles.less | 63 +++--- src/addons/dragAndDrop/withDragAndDrop.js | 164 ++++++++++++++ stories/Calendar.js | 106 +++++++-- 12 files changed, 647 insertions(+), 559 deletions(-) create mode 100644 src/addons/dragAndDrop/DropWrappers.js delete mode 100644 src/addons/dragAndDrop/ResizableEvent.js delete mode 100644 src/addons/dragAndDrop/ResizableMonthEvent.js delete mode 100644 src/addons/dragAndDrop/backgroundWrapper.js create mode 100644 src/addons/dragAndDrop/withDragAndDrop.js diff --git a/examples/demos/dnd.js b/examples/demos/dnd.js index 940b99c97..7d57749e8 100644 --- a/examples/demos/dnd.js +++ b/examples/demos/dnd.js @@ -47,6 +47,8 @@ class Dnd extends React.Component { this.setState({ events: nextEvents, }) + + alert(`${event.title} was resized to ${start}-${end}`) } render() { diff --git a/src/DayColumn.js b/src/DayColumn.js index a070dab0e..e60112ff3 100644 --- a/src/DayColumn.js +++ b/src/DayColumn.js @@ -224,8 +224,14 @@ class DayColumn extends React.Component { let { height, top, width, xOffset } = style + let wrapperProps = { + event, + continuesPrior: _continuesPrior, + continuesAfter: _continuesAfter, + } + return ( - +
1, continuesPrior = dates.lt(start, slotStart, 'day'), continuesAfter = dates.gte(end, slotEnd, 'day') @@ -65,13 +67,22 @@ class EventCell extends React.Component { selected ) + let wrapperProps = { + event, + allDay, + continuesPrior, + continuesAfter, + } + return ( - + // give EventWrapper some extra info to help it determine whether it + // it's in a row, etc. Useful for dnd, etc. +
{Event ? ( - + ) : ( title )} diff --git a/src/addons/dragAndDrop/DraggableEventWrapper.js b/src/addons/dragAndDrop/DraggableEventWrapper.js index e7eec747a..3ee557491 100644 --- a/src/addons/dragAndDrop/DraggableEventWrapper.js +++ b/src/addons/dragAndDrop/DraggableEventWrapper.js @@ -1,51 +1,166 @@ import PropTypes from 'prop-types' import React from 'react' import { DragSource } from 'react-dnd' +import { getEmptyImage } from 'react-dnd-html5-backend' import cn from 'classnames' +import compose from './compose' import BigCalendar from '../../index' +const EventWrapper = BigCalendar.components.eventWrapper -/* drag sources */ +class DraggableEventWrapper extends React.Component { + static propTypes = { + event: PropTypes.object.isRequired, -let eventSource = { - beginDrag(props) { - return props.event - }, -} + connectDragSource: PropTypes.func.isRequired, + connectTopDragPreview: PropTypes.func.isRequired, + connectTopDragSource: PropTypes.func.isRequired, + connectBottomDragPreview: PropTypes.func.isRequired, + connectBottomDragSource: PropTypes.func.isRequired, + connectLeftDragPreview: PropTypes.func.isRequired, + connectLeftDragSource: PropTypes.func.isRequired, + connectRightDragPreview: PropTypes.func.isRequired, + connectRightDragSource: PropTypes.func.isRequired, -function collectSource(connect, monitor) { - return { - connectDragSource: connect.dragSource(), - isDragging: monitor.isDragging(), + allDay: PropTypes.bool, + isRow: PropTypes.bool, + continuesPrior: PropTypes.bool, + continuesAfter: PropTypes.bool, + isDragging: PropTypes.bool, + isResizing: PropTypes.bool, } -} -const propTypes = { - connectDragSource: PropTypes.func.isRequired, - isDragging: PropTypes.bool.isRequired, - event: PropTypes.object.isRequired, -} + componentDidMount() { + // this is needed to prevent the backend from + // screenshot'ing the event during a resize which + // would be very confusing visually + const emptyImage = getEmptyImage() + const previewOptions = { captureDraggingState: true } + this.props.connectTopDragPreview(emptyImage, previewOptions) + this.props.connectBottomDragPreview(emptyImage, previewOptions) + this.props.connectLeftDragPreview(emptyImage, previewOptions) + this.props.connectRightDragPreview(emptyImage, previewOptions) + } -class DraggableEventWrapper extends React.Component { render() { - let { connectDragSource, isDragging, children, event } = this.props - let EventWrapper = BigCalendar.components.eventWrapper + let { + connectDragSource, + connectTopDragSource, + connectBottomDragSource, + connectLeftDragSource, + connectRightDragSource, + isDragging, + isResizing, + children, + event, + allDay, + isRow, + continuesPrior, + continuesAfter, + } = this.props + + let StartAnchor = null, + EndAnchor = null + + /* + * The resizability of events depends on whether they are + * allDay events and how they are displayed. + * + * 1. If the event is being shown in an event row (because + * it is an allDay event shown in the header row or because as + * in month view the view is showing all events as rows) then we + * allow east-west resizing. + * + * 2. Otherwise the event is being displayed + * normally, we can drag it north-south to resize the times. + * + * See `DropWrappers` for handling of the drop of such events. + * + * Notwithstanding the above, we never show drag anchors for + * events which continue beyond current component. This happens + * in the middle of events when showMultiDay is true, and to + * events at the edges of the calendar's min/max location. + */ + if (isRow || allDay) { + const anchor = ( +
+
+
+ ) + StartAnchor = !continuesPrior && connectLeftDragSource(anchor) + EndAnchor = !continuesAfter && connectRightDragSource(anchor) + } else { + const anchor = ( +
+
+
+ ) + StartAnchor = !continuesPrior && connectTopDragSource(anchor) + EndAnchor = !continuesAfter && connectBottomDragSource(anchor) + } + + /* + * props.children is the singular component. + * BigCalendar positions the Event abolutely and we + * need the anchors to be part of that positioning. + * So we insert the anchors inside the Event's children + * rather than wrap the Event here as the latter approach + * would lose the positioning. + */ + const childrenWithAnchors = ( +
+ {StartAnchor} + {children.props.children} + {EndAnchor} +
+ ) children = React.cloneElement(children, { className: cn( children.props.className, - isDragging && 'rbc-addons-dnd-dragging' + isDragging && 'rbc-addons-dnd-dragging', + isResizing && 'rbc-addons-dnd-resizing' ), + children: childrenWithAnchors, // replace original event child with anchor-embellished child }) return ( - {connectDragSource(children)} + + {connectDragSource(children)} + ) } } -DraggableEventWrapper.propTypes = propTypes +/* drag sources */ +const makeEventSource = anchor => ({ + beginDrag: ({ event }) => ({ event, anchor }), + // canDrag: ({ event }) => event.draggable === undefined || event.draggable - e.g. support per-event dragability/sizability +}) -export default DragSource('event', eventSource, collectSource)( - DraggableEventWrapper -) +export default compose( + DragSource('event', makeEventSource('drop'), (connect, monitor) => ({ + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging(), + })), + DragSource('event', makeEventSource('resizeTop'), (connect, monitor) => ({ + connectTopDragSource: connect.dragSource(), + connectTopDragPreview: connect.dragPreview(), + isResizing: monitor.isDragging(), + })), + DragSource('event', makeEventSource('resizeBottom'), (connect, monitor) => ({ + connectBottomDragSource: connect.dragSource(), + connectBottomDragPreview: connect.dragPreview(), + isResizing: monitor.isDragging(), + })), + DragSource('event', makeEventSource('resizeLeft'), (connect, monitor) => ({ + connectLeftDragSource: connect.dragSource(), + connectLeftDragPreview: connect.dragPreview(), + isResizing: monitor.isDragging(), + })), + DragSource('event', makeEventSource('resizeRight'), (connect, monitor) => ({ + connectRightDragSource: connect.dragSource(), + connectRightDragPreview: connect.dragPreview(), + isResizing: monitor.isDragging(), + })) +)(DraggableEventWrapper) diff --git a/src/addons/dragAndDrop/DropWrappers.js b/src/addons/dragAndDrop/DropWrappers.js new file mode 100644 index 000000000..06b3cdf90 --- /dev/null +++ b/src/addons/dragAndDrop/DropWrappers.js @@ -0,0 +1,205 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { DropTarget } from 'react-dnd' +import cn from 'classnames' +import noop from 'lodash/noop' + +import { accessor } from '../../utils/propTypes' +import { accessor as get } from '../../utils/accessors' +import dates from '../../utils/dates' +import BigCalendar from '../../index' + +function getEventDropProps(start, end, dropDate, droppedInAllDay) { + // Calculate duration between original start and end dates + const duration = dates.diff(start, end) + + /* + * If the event is dropped in a "Day" cell, preserve an event's start time by extracting the hours and minutes off + * the original start date and add it to newDate.value + * + * note: this behavior remains for backward compatibility, but might be counter-intuitive to some: + * dragging an event from the grid to the day header might more commonly mean "make this an allDay event + * on that day" - but the behavior here implements "keep the times of the event, but move it to the + * new day". + * + * To permit either interpretation, we embellish a new `allDay` parameter which determines whether the + * event was dropped on the day header or not. + */ + + const nextStart = droppedInAllDay ? dates.merge(dropDate, start) : dropDate + const nextEnd = dates.add(nextStart, duration, 'milliseconds') + + return { + start: nextStart, + end: nextEnd, + allDay: droppedInAllDay, + } +} + +class DropWrapper extends React.Component { + static propTypes = { + connectDropTarget: PropTypes.func.isRequired, + type: PropTypes.string, + isOver: PropTypes.bool, + } + + static contextTypes = { + onEventDrop: PropTypes.func, + onEventResize: PropTypes.func, + dragDropManager: PropTypes.object, + startAccessor: accessor, + endAccessor: accessor, + allDayAccessor: accessor, + step: PropTypes.number, + } + + // TODO: this is WIP to retain the drag offset so the + // drag target better tracks the mouseDown location, not + // just the top of the event. + // + // constructor(...args) { + // super(...args); + // this.state = { isOver: false }; + // } + // + // componentWillMount() { + // let monitor = this.context.dragDropManager.getMonitor() + // + // this.monitor = monitor + // + // this.unsubscribeToStateChange = monitor + // .subscribeToStateChange(this.handleStateChange) + // + // this.unsubscribeToOffsetChange = monitor + // .subscribeToOffsetChange(this.handleOffsetChange) + // } + // + // componentWillUnmount() { + // this.monitor = null + // this.unsubscribeToStateChange() + // this.unsubscribeToOffsetChange() + // } + // + // handleStateChange = () => { + // const event = this.monitor.getItem(); + // if (!event && this.state.isOver) { + // this.setState({ isOver: false }); + // } + // } + // + // handleOffsetChange = () => { + // const { value } = this.props; + // const { start, end } = this.monitor.getItem(); + // + // const isOver = dates.inRange(value, start, end, 'minute'); + // if (this.state.isOver !== isOver) { + // this.setState({ isOver }); + // } + // }; + + render() { + const { connectDropTarget, children, type, isOver } = this.props + const BackgroundWrapper = BigCalendar.components[type] + + let resultingChildren = children + if (isOver) + resultingChildren = React.cloneElement(children, { + className: cn(children.props.className, 'rbc-addons-dnd-over'), + }) + + return ( + + {connectDropTarget(resultingChildren)} + + ) + } +} + +function createDropWrapper(type) { + function collectTarget(connect, monitor) { + return { + type, + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + } + } + + const dropTarget = { + drop(_, monitor, { props, context }) { + const itemType = monitor.getItemType() + if (itemType !== 'event') return + + const item = monitor.getItem() + const { event, anchor } = item + const { value } = props + const { + onEventDrop = noop, + onEventResize = noop, + startAccessor, + endAccessor, + allDayAccessor, + step, + } = context + + let start = get(event, startAccessor) + let end = get(event, endAccessor) + let allDay = get(event, allDayAccessor) + let droppedInAllDay = type === 'dateCellWrapper' + + switch (anchor) { + case 'drop': + onEventDrop({ + event, + ...getEventDropProps(start, end, value, droppedInAllDay), + }) + return // all the other cases issue resize action... + + // the remaining cases are all resizes... + + case 'resizeTop': + // dragging the top means the event isn't an allDay + // dropping into the header changes the date, preserves the time + // dropping elsewhere is just a normal resize + start = droppedInAllDay ? dates.merge(value, start) : value + break + + case 'resizeBottom': + // dragging the bottom means the event isn't an allDay + // dropping into the header changes the date, preserves the time + // dropping elsewhere is just a normal resize + // ... but end dates are exclusive so advance it the next slot (e.g. just past the end of this one) + end = droppedInAllDay + ? dates.merge(value, end) + : dates.add(value, step, 'minutes') + break + + case 'resizeLeft': + // dragging the left means we're dragging something from an event row + // all cases are the same: + // preserve its start time, but change the date (works for both allDay and non-allDay) + start = dates.merge(value, start) + break + + case 'resizeRight': + // dragging the right means we're dragging something from an event row + // this case is tricky: for non-allDay events, we just want to change + // the end date (preserving the end time). For allDay events, we want to change + // the end date to one day later than the drop date because end dates are exclusive + end = allDay ? dates.add(value, 1, 'day') : dates.merge(value, end) + break + + default: + return // don't issue resize + } + + // fall here for all of the resize cases + // note: the 'drop' param is here for backward compatibility - maybe remove in future? + onEventResize('drop', { event, start, end, allDay: droppedInAllDay }) + }, + } + + return DropTarget('event', dropTarget, collectTarget)(DropWrapper) +} + +export const DroppableDateCellWrapper = createDropWrapper('dateCellWrapper') +export const DroppableDayWrapper = createDropWrapper('dayWrapper') diff --git a/src/addons/dragAndDrop/ResizableEvent.js b/src/addons/dragAndDrop/ResizableEvent.js deleted file mode 100644 index 940e180d8..000000000 --- a/src/addons/dragAndDrop/ResizableEvent.js +++ /dev/null @@ -1,112 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { DragSource } from 'react-dnd' -import { getEmptyImage } from 'react-dnd-html5-backend' -import compose from './compose' - -class ResizableEvent extends React.Component { - componentDidMount() { - this.props.connectTopDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - this.props.connectBottomDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - this.props.connectLeftDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - this.props.connectRightDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - } - - render() { - const { - title, - event, - connectTopDragSource, - connectBottomDragSource, - connectLeftDragSource, - connectRightDragSource, - } = this.props - const [Top, Bottom] = [connectTopDragSource, connectBottomDragSource].map( - connectDragSource => { - return connectDragSource( -
-
-
- ) - } - ) - const [Left, Right] = [connectLeftDragSource, connectRightDragSource].map( - connectDragSource => { - return connectDragSource( -
- ) - } - ) - - return event.allDay || this.props.isAllDay ? ( -
- {Left} - {title} - {Right} -
- ) : ( -
- {Top} - {title} - {Bottom} -
- ) - } -} - -ResizableEvent.propTypes = { - connectBottomDragPreview: PropTypes.func, - connectBottomDragSource: PropTypes.func, - connectLeftDragPreview: PropTypes.func, - connectLeftDragSource: PropTypes.func, - connectRightDragPreview: PropTypes.func, - connectRightDragSource: PropTypes.func, - connectTopDragPreview: PropTypes.func, - connectTopDragSource: PropTypes.func, - event: PropTypes.object, - title: PropTypes.string, - isAllDay: PropTypes.bool, -} - -const eventSourceTop = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeTop' }), -} - -const eventSourceBottom = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeBottom' }), -} - -const eventSourceLeft = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeLeft' }), -} - -const eventSourceRight = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeRight' }), -} - -export default compose( - DragSource('resize', eventSourceTop, connect => ({ - connectTopDragPreview: connect.dragPreview(), - connectTopDragSource: connect.dragSource(), - })), - DragSource('resize', eventSourceBottom, connect => ({ - connectBottomDragSource: connect.dragSource(), - connectBottomDragPreview: connect.dragPreview(), - })), - DragSource('resize', eventSourceLeft, connect => ({ - connectLeftDragSource: connect.dragSource(), - connectLeftDragPreview: connect.dragPreview(), - })), - DragSource('resize', eventSourceRight, connect => ({ - connectRightDragSource: connect.dragSource(), - connectRightDragPreview: connect.dragPreview(), - })) -)(ResizableEvent) diff --git a/src/addons/dragAndDrop/ResizableMonthEvent.js b/src/addons/dragAndDrop/ResizableMonthEvent.js deleted file mode 100644 index a64dbd09e..000000000 --- a/src/addons/dragAndDrop/ResizableMonthEvent.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { DragSource } from 'react-dnd' -import { getEmptyImage } from 'react-dnd-html5-backend' -import compose from './compose' - -class ResizableMonthEvent extends React.Component { - componentDidMount() { - this.props.connectLeftDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - this.props.connectRightDragPreview(getEmptyImage(), { - captureDraggingState: true, - }) - } - - render() { - const { title, connectLeftDragSource, connectRightDragSource } = this.props - const [Left, Right] = [connectLeftDragSource, connectRightDragSource].map( - connectDragSource => { - return connectDragSource( -
- ) - } - ) - return ( -
- {Left} - {title} - {Right} -
- ) - } -} - -const eventSourceLeft = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeLeft' }), -} - -const eventSourceRight = { - beginDrag: ({ event }) => ({ ...event, type: 'resizeRight' }), -} - -ResizableMonthEvent.propTypes = { - connectLeftDragPreview: PropTypes.func, - connectLeftDragSource: PropTypes.func, - connectRightDragPreview: PropTypes.func, - connectRightDragSource: PropTypes.func, - title: PropTypes.string, -} - -export default compose( - DragSource('resize', eventSourceLeft, connect => ({ - connectLeftDragSource: connect.dragSource(), - connectLeftDragPreview: connect.dragPreview(), - })), - DragSource('resize', eventSourceRight, connect => ({ - connectRightDragSource: connect.dragSource(), - connectRightDragPreview: connect.dragPreview(), - })) -)(ResizableMonthEvent) diff --git a/src/addons/dragAndDrop/backgroundWrapper.js b/src/addons/dragAndDrop/backgroundWrapper.js deleted file mode 100644 index d3362d3ba..000000000 --- a/src/addons/dragAndDrop/backgroundWrapper.js +++ /dev/null @@ -1,176 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { DropTarget } from 'react-dnd' -import cn from 'classnames' - -import { accessor } from '../../utils/propTypes' -import { accessor as get } from '../../utils/accessors' -import dates from '../../utils/dates' -import BigCalendar from '../../index' - -export function getEventTimes(start, end, dropDate, type) { - // Calculate duration between original start and end dates - const duration = dates.diff(start, end) - - // If the event is dropped in a "Day" cell, preserve an event's start time by extracting the hours and minutes off - // the original start date and add it to newDate.value - const nextStart = - type === 'dateCellWrapper' ? dates.merge(dropDate, start) : dropDate - - const nextEnd = dates.add(nextStart, duration, 'milliseconds') - - return { - start: nextStart, - end: nextEnd, - } -} - -const propTypes = { - connectDropTarget: PropTypes.func.isRequired, - type: PropTypes.string, - isOver: PropTypes.bool, -} - -class DraggableBackgroundWrapper extends React.Component { - // constructor(...args) { - // super(...args); - // this.state = { isOver: false }; - // } - // - // componentWillMount() { - // let monitor = this.context.dragDropManager.getMonitor() - // - // this.monitor = monitor - // - // this.unsubscribeToStateChange = monitor - // .subscribeToStateChange(this.handleStateChange) - // - // this.unsubscribeToOffsetChange = monitor - // .subscribeToOffsetChange(this.handleOffsetChange) - // } - // - // componentWillUnmount() { - // this.monitor = null - // this.unsubscribeToStateChange() - // this.unsubscribeToOffsetChange() - // } - // - // handleStateChange = () => { - // const event = this.monitor.getItem(); - // if (!event && this.state.isOver) { - // this.setState({ isOver: false }); - // } - // } - // - // handleOffsetChange = () => { - // const { value } = this.props; - // const { start, end } = this.monitor.getItem(); - // - // const isOver = dates.inRange(value, start, end, 'minute'); - // if (this.state.isOver !== isOver) { - // this.setState({ isOver }); - // } - // }; - - render() { - const { connectDropTarget, children, type, isOver } = this.props - const BackgroundWrapper = BigCalendar.components[type] - - let resultingChildren = children - if (isOver) - resultingChildren = React.cloneElement(children, { - className: cn(children.props.className, 'rbc-addons-dnd-over'), - }) - - return ( - - {connectDropTarget(resultingChildren)} - - ) - } -} -DraggableBackgroundWrapper.propTypes = propTypes - -DraggableBackgroundWrapper.contextTypes = { - onEventDrop: PropTypes.func, - onEventResize: PropTypes.func, - dragDropManager: PropTypes.object, - startAccessor: accessor, - endAccessor: accessor, -} - -function createWrapper(type) { - function collectTarget(connect, monitor) { - return { - type, - connectDropTarget: connect.dropTarget(), - isOver: monitor.isOver(), - } - } - - const dropTarget = { - drop(_, monitor, { props, context }) { - const event = monitor.getItem() - const { value } = props - const { onEventDrop, onEventResize, startAccessor, endAccessor } = context - const start = get(event, startAccessor) - const end = get(event, endAccessor) - - if (monitor.getItemType() === 'event') { - onEventDrop({ - event, - ...getEventTimes(start, end, value, type), - }) - } - - if (monitor.getItemType() === 'resize') { - switch (event.type) { - case 'resizeTop': { - return onEventResize('drop', { - event, - start: value, - end: event.end, - }) - } - case 'resizeBottom': { - const nextEnd = dates.add(value, 30, 'minutes') - return onEventResize('drop', { - event, - start: event.start, - end: nextEnd, - }) - } - case 'resizeLeft': { - return onEventResize('drop', { - event, - start: value, - end: event.end, - }) - } - case 'resizeRight': { - const nextEnd = dates.add(value, 1, 'day') - return onEventResize('drop', { - event, - start: event.start, - end: nextEnd, - }) - } - } - - // Catch all - onEventResize('drop', { - event, - start: event.start, - end: value, - }) - } - }, - } - - return DropTarget(['event', 'resize'], dropTarget, collectTarget)( - DraggableBackgroundWrapper - ) -} - -export const DateCellWrapper = createWrapper('dateCellWrapper') -export const DayWrapper = createWrapper('dayWrapper') diff --git a/src/addons/dragAndDrop/index.js b/src/addons/dragAndDrop/index.js index ad982508f..6ca53ca1d 100644 --- a/src/addons/dragAndDrop/index.js +++ b/src/addons/dragAndDrop/index.js @@ -1,121 +1,2 @@ -import PropTypes from 'prop-types' -import React from 'react' -import { DragDropContext } from 'react-dnd' -import cn from 'classnames' - -import { accessor } from '../../utils/propTypes' -import DraggableEventWrapper from './DraggableEventWrapper' -import ResizableEvent from './ResizableEvent' -import ResizableMonthEvent from './ResizableMonthEvent' -import { DayWrapper, DateCellWrapper } from './backgroundWrapper' - -let html5Backend - -try { - html5Backend = require('react-dnd-html5-backend') -} catch (err) { - /* optional dep missing */ -} - -export default function withDragAndDrop( - Calendar, - { backend = html5Backend } = {} -) { - class DragAndDropCalendar extends React.Component { - static propTypes = { - selectable: PropTypes.oneOf([true, false, 'ignoreEvents']).isRequired, - components: PropTypes.object, - } - getChildContext() { - return { - onEventDrop: this.props.onEventDrop, - onEventResize: this.props.onEventResize, - startAccessor: this.props.startAccessor, - endAccessor: this.props.endAccessor, - } - } - - constructor(...args) { - super(...args) - this.state = { isDragging: false } - } - - componentWillMount() { - let monitor = this.context.dragDropManager.getMonitor() - this.monitor = monitor - this.unsubscribeToStateChange = monitor.subscribeToStateChange( - this.handleStateChange - ) - } - - componentWillUnmount() { - this.monitor = null - this.unsubscribeToStateChange() - } - - handleStateChange = () => { - const isDragging = !!this.monitor.getItem() - - if (isDragging !== this.state.isDragging) { - setTimeout(() => this.setState({ isDragging })) - } - } - - render() { - const { selectable, components, resizable, ...props } = this.props - - delete props.onEventDrop - delete props.onEventResize - - props.selectable = selectable ? 'ignoreEvents' : false - - props.className = cn( - props.className, - 'rbc-addons-dnd', - this.state.isDragging && 'rbc-addons-dnd-is-dragging' - ) - - props.components = { - ...components, - dateCellWrapper: DateCellWrapper, - day: { event: resizable && ResizableEvent }, - dayWrapper: DayWrapper, - eventWrapper: DraggableEventWrapper, - month: { event: resizable && ResizableMonthEvent }, - week: { event: resizable && ResizableEvent }, - } - - return - } - } - - DragAndDropCalendar.propTypes = { - onEventDrop: PropTypes.func.isRequired, - resizable: PropTypes.bool, - onEventResize: PropTypes.func, - startAccessor: accessor, - endAccessor: accessor, - } - - DragAndDropCalendar.defaultProps = { - startAccessor: 'start', - endAccessor: 'end', - } - - DragAndDropCalendar.contextTypes = { - dragDropManager: PropTypes.object, - } - - DragAndDropCalendar.childContextTypes = { - onEventDrop: PropTypes.func, - onEventResize: PropTypes.func, - startAccessor: accessor, - endAccessor: accessor, - } - - if (backend === false) { - return DragAndDropCalendar - } else { - return DragDropContext(backend)(DragAndDropCalendar) - } -} +import withDragAndDrop from './withDragAndDrop' +export default withDragAndDrop diff --git a/src/addons/dragAndDrop/styles.less b/src/addons/dragAndDrop/styles.less index 307a9d8b3..7f790a185 100644 --- a/src/addons/dragAndDrop/styles.less +++ b/src/addons/dragAndDrop/styles.less @@ -26,6 +26,10 @@ .rbc-event { transition: opacity 150ms; pointer-events: all; + + &:hover { + .rbc-addons-dnd-resize-ns-icon, .rbc-addons-dnd-resize-ew-icon { display: block; } + } } &.rbc-addons-dnd-is-dragging .rbc-event { @@ -33,51 +37,42 @@ opacity: .50; } - .rbc-addons-dnd-resize-anchor { + .rbc-addons-dnd-resizable { + position: relative; + width: 100%; + height: 100%; + } + + .rbc-addons-dnd-resize-ns-anchor { width: 100%; - height: 10px; text-align: center; - margin-left: -5px; - &:first-child { - position: absolute; - top: 0; - } - &:last-child { - position: absolute; - bottom: 0; - } + position: absolute; + &:first-child { top: 0; } + &:last-child { bottom: 0; } - .rbc-addons-dnd-resize-icon { + .rbc-addons-dnd-resize-ns-icon { display: none; border-top: 3px double; margin: 0 auto; width: 10px; - } - &:hover { - .rbc-addons-dnd-resize-icon {display: block;} cursor: ns-resize; } - } - .rbc-addons-dnd-resizable-month-event { - position: relative; - user-drag: none; - .rbc-addons-dnd-resize-month-event-anchor { - width: 20px; - height: 20px; - top: 0; - &:hover { - cursor: ew-resize; - } - &:first-child { - position: absolute; - left: -5px; - } - &:last-child { - position: absolute; - right: -5px; - } + .rbc-addons-dnd-resize-ew-anchor { + position: absolute; + top: 4px; + bottom: 0; + &:first-child { left: 0; } + &:last-child { right: 0; } + + .rbc-addons-dnd-resize-ew-icon { + display: none; + border-left: 3px double; + margin-top: auto; + margin-bottom: auto; + height: 10px; + cursor: ew-resize; } } } diff --git a/src/addons/dragAndDrop/withDragAndDrop.js b/src/addons/dragAndDrop/withDragAndDrop.js new file mode 100644 index 000000000..08595dc9d --- /dev/null +++ b/src/addons/dragAndDrop/withDragAndDrop.js @@ -0,0 +1,164 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { DragDropContext } from 'react-dnd' +import cn from 'classnames' + +import { accessor } from '../../utils/propTypes' +import DraggableEventWrapper from './DraggableEventWrapper' +import { DroppableDayWrapper, DroppableDateCellWrapper } from './DropWrappers' + +let html5Backend + +try { + html5Backend = require('react-dnd-html5-backend') +} catch (err) { + /* optional dep missing */ +} + +/** + * Creates a higher-order component (HOC) supporting drag & drop and optionally resizing + * of events: + * + * ```js + * import Calendar from 'react-big-calendar' + * import withDragAndDrop from 'react-big-calendar/lib/addons/dragAndDrop' + * export default withDragAndDrop(Calendar) + * ``` + * (you can optionally pass any dnd backend as an optional second argument to `withDragAndDrop`. + * It defaults to `react-dnd-html5-backend` which you should probably include in + * your project if using this default). + * + * Set `resizable` to true in your calendar if you want events to be resizable. + * + * The HOC adds `onEventDrop` and `onEventResize` callback properties if the events are + * moved or resized. They are called with these signatures: + * + * ```js + * function onEventDrop({ event, start, end, allDay }) {...} + * function onEventResize(type, { event, start, end, allDay }) {...} // type is always 'drop' + * ``` + * + * Moving and resizing of events has some subtlety which one should be aware of. + * + * In some situations, non-allDay events are displayed in "row" format where they + * are rendered horizontally. This is the case for ALL events in a month view. It + * is also occurs with multi-day events in a day or week view (unless `showMultiDayTimes` + * is set). + * + * When dropping or resizing non-allDay events into a the header area or when + * resizing them horizontally because they are displayed in row format, their + * times are preserved, only their date is changed. + * + * If you care about these corner cases, you can examine the `allDay` param suppled + * in the callback to determine how the user dropped or resized the event. + * + * Note: you cannot use custom `EventWrapper`, `DayWrapper` or `DateCellWrapper` + * components when using this HOC as they are overwritten here. + * + * @param {*} Calendar + * @param {*} backend + */ +export default function withDragAndDrop( + Calendar, + { backend = html5Backend } = {} +) { + class DragAndDropCalendar extends React.Component { + static propTypes = { + onEventDrop: PropTypes.func, + onEventResize: PropTypes.func, + startAccessor: accessor, + endAccessor: accessor, + allDayAccessor: accessor, + selectable: PropTypes.oneOf([true, false, 'ignoreEvents']), + resizable: PropTypes.bool, + components: PropTypes.object, + step: PropTypes.number, + } + + static defaultProps = { + // TODO: pick these up from Calendar.defaultProps + startAccessor: 'start', + endAccessor: 'end', + allDayAccessor: 'allDay', + step: 30, + } + + static contextTypes = { + dragDropManager: PropTypes.object, + } + + static childContextTypes = { + onEventDrop: PropTypes.func, + onEventResize: PropTypes.func, + startAccessor: accessor, + endAccessor: accessor, + step: PropTypes.number, + } + + getChildContext() { + return { + onEventDrop: this.props.onEventDrop, + onEventResize: this.props.onEventResize, + startAccessor: this.props.startAccessor, + endAccessor: this.props.endAccessor, + step: this.props.step, + } + } + + constructor(...args) { + super(...args) + this.state = { isDragging: false } + } + + componentWillMount() { + let monitor = this.context.dragDropManager.getMonitor() + this.monitor = monitor + this.unsubscribeToStateChange = monitor.subscribeToStateChange( + this.handleStateChange + ) + } + + componentWillUnmount() { + this.monitor = null + this.unsubscribeToStateChange() + } + + handleStateChange = () => { + const isDragging = !!this.monitor.getItem() + + if (isDragging !== this.state.isDragging) { + setTimeout(() => this.setState({ isDragging })) + } + } + + render() { + const { selectable, components, ...props } = this.props + + delete props.onEventDrop + delete props.onEventResize + + props.selectable = selectable ? 'ignoreEvents' : false + + props.className = cn( + props.className, + 'rbc-addons-dnd', + this.state.isDragging && 'rbc-addons-dnd-is-dragging' + ) + + props.components = { + ...components, + dateCellWrapper: DroppableDateCellWrapper, + dayWrapper: DroppableDayWrapper, + eventWrapper: DraggableEventWrapper, + } + + return + } + } + + if (backend === false) { + return DragAndDropCalendar + } else { + return DragDropContext(backend)(DragAndDropCalendar) + } +} diff --git a/stories/Calendar.js b/stories/Calendar.js index bfb93611f..b4500d123 100644 --- a/stories/Calendar.js +++ b/stories/Calendar.js @@ -1,8 +1,6 @@ import { storiesOf, action } from '@storybook/react' import moment from 'moment' import React from 'react' -import HTML5Backend from 'react-dnd-html5-backend' -import { DragDropContext } from 'react-dnd' import Calendar from '../src' import momentLocalizer from '../src/localizers/moment.js' @@ -13,6 +11,8 @@ import createEvents from './createEvents' import resources from './resourceEvents' import withDragAndDrop from '../src/addons/dragAndDrop' +/* eslint-disable react/prop-types */ + // Setup the localizer by providing the moment (or globalize) Object // to the correct localizer. momentLocalizer(moment) // or globalizeLocalizer @@ -57,33 +57,38 @@ const events = [ }, { title: 'test all day', - start: moment().toDate(), - end: moment().toDate(), + start: moment() + .startOf('day') + .toDate(), + end: moment() + .startOf('day') + .add(1, 'day') + .toDate(), + allDay: true, + }, + { + title: 'test 2 days', + start: moment() + .startOf('day') + .toDate(), + end: moment() + .startOf('day') + .add(2, 'days') + .toDate(), allDay: true, }, + { + title: 'test multi-day', + start: moment().toDate(), + end: moment() + .add(3, 'days') + .toDate(), + allDay: false, + }, ] const DragAndDropCalendar = withDragAndDrop(Calendar) -const DragCalendar = () => { - return ( - { - action(event) - }} - onSelectEvent={action('event selected')} - onSelectSlot={action('slot selected')} - defaultDate={new Date(2015, 3, 1)} - /> - ) -} - -const DragableCalendar = DragDropContext(HTML5Backend)(DragCalendar) - storiesOf('module.Calendar.week', module) .add('demo', () => { return ( @@ -126,7 +131,15 @@ storiesOf('module.Calendar.week', module) .add('resource', () => { return (
- +
) }) @@ -469,3 +482,48 @@ storiesOf('module.Calendar.week', module)
) }) + .add('draggable and resizable', () => { + return ( +
+ +
+ ) + }) + .add('draggable and resizable with non-default steps and timeslots', () => { + return ( +
+ +
+ ) + }) + .add('draggable and resizable with showMultiDayTimes', () => { + return ( +
+ +
+ ) + })