diff --git a/src/Calendar.js b/src/Calendar.js index 5426f3d02..6025cda32 100644 --- a/src/Calendar.js +++ b/src/Calendar.js @@ -1,28 +1,28 @@ +import clsx from 'clsx' import PropTypes from 'prop-types' import React from 'react' import { uncontrollable } from 'uncontrollable' -import clsx from 'clsx' import { accessor, + views as componentViews, dateFormat, dateRangeFormat, DayLayoutAlgorithmPropType, - views as componentViews, } from './utils/propTypes' -import { notify } from './utils/helpers' -import { navigate, views } from './utils/constants' import { mergeWithDefaults } from './localizer' +import NoopWrapper from './NoopWrapper' +import Toolbar from './Toolbar' +import { navigate, views } from './utils/constants' +import { notify } from './utils/helpers' import message from './utils/messages' import moveDate from './utils/move' import VIEWS from './Views' -import Toolbar from './Toolbar' -import NoopWrapper from './NoopWrapper' -import omit from 'lodash/omit' import defaults from 'lodash/defaults' -import transform from 'lodash/transform' import mapValues from 'lodash/mapValues' +import omit from 'lodash/omit' +import transform from 'lodash/transform' import { wrapAccessor } from './utils/accessors' function viewNames(_views) { @@ -632,6 +632,13 @@ class Calendar extends React.Component { */ enableAutoScroll: PropTypes.bool, + /** + * Determines the layout of resource groups in the calendar. + * When `true`, resources will be grouped by date in the week view. + * When `false`, resources will be grouped by week. + */ + resourceGroupingLayout: PropTypes.bool, + /** * Specify a specific culture code for the Calendar. * @@ -1008,6 +1015,7 @@ class Calendar extends React.Component { toolbar, events, backgroundEvents, + resourceGroupingLayout, style, className, elementProps, @@ -1071,6 +1079,7 @@ class Calendar extends React.Component { onSelectSlot={this.handleSelectSlot} onShowMore={onShowMore} doShowMoreDrillDown={doShowMoreDrillDown} + resourceGroupingLayout={resourceGroupingLayout} /> ) diff --git a/src/TimeGrid.js b/src/TimeGrid.js index 4ae83354b..ae0cff36b 100644 --- a/src/TimeGrid.js +++ b/src/TimeGrid.js @@ -1,21 +1,21 @@ -import React, { Component, createRef } from 'react' -import PropTypes from 'prop-types' import clsx from 'clsx' import * as animationFrame from 'dom-helpers/animationFrame' import memoize from 'memoize-one' +import PropTypes from 'prop-types' +import React, { Component, createRef } from 'react' +import getPosition from 'dom-helpers/position' +import getWidth from 'dom-helpers/width' import DayColumn from './DayColumn' -import TimeGutter from './TimeGutter' -import TimeGridHeader from './TimeGridHeader' import PopOverlay from './PopOverlay' - -import getWidth from 'dom-helpers/width' -import getPosition from 'dom-helpers/position' +import TimeGridHeader from './TimeGridHeader' +import TimeGridHeaderResources from './TimeGridHeaderResources' +import TimeGutter from './TimeGutter' import { views } from './utils/constants' import { inRange, sortEvents } from './utils/eventLevels' import { notify } from './utils/helpers' -import Resources from './utils/Resources' import { DayLayoutAlgorithmPropType } from './utils/propTypes' +import Resources from './utils/Resources' export default class TimeGrid extends Component { constructor(props) { @@ -131,56 +131,159 @@ export default class TimeGrid extends Component { }) } - renderEvents(range, events, backgroundEvents, now) { - let { min, max, components, accessors, localizer, dayLayoutAlgorithm } = - this.props - - const resources = this.memoizedResources(this.props.resources, accessors) - const groupedEvents = resources.groupEvents(events) - const groupedBackgroundEvents = resources.groupEvents(backgroundEvents) + renderDayColumn( + date, + id, + resource, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + components, + dayLayoutAlgorithm, + now + ) { + let { min, max } = this.props + + let daysEvents = (groupedEvents.get(id) || []).filter((event) => + localizer.inRange( + date, + accessors.start(event), + accessors.end(event), + 'day' + ) + ) - return resources.map(([id, resource], i) => - range.map((date, jj) => { - let daysEvents = (groupedEvents.get(id) || []).filter((event) => - localizer.inRange( - date, - accessors.start(event), - accessors.end(event), - 'day' - ) + let daysBackgroundEvents = (groupedBackgroundEvents.get(id) || []).filter( + (event) => + localizer.inRange( + date, + accessors.start(event), + accessors.end(event), + 'day' ) + ) - let daysBackgroundEvents = ( - groupedBackgroundEvents.get(id) || [] - ).filter((event) => - localizer.inRange( - date, - accessors.start(event), - accessors.end(event), - 'day' - ) - ) + return ( + + ) + } - return ( - + renderResourcesFirst( + range, + resources, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + now, + components, + dayLayoutAlgorithm + ) { + return resources.map(([id, resource]) => + range.map((date) => + this.renderDayColumn( + date, + id, + resource, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + components, + dayLayoutAlgorithm, + now ) - }) + ) ) } + renderRangeFirst( + range, + resources, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + now, + components, + dayLayoutAlgorithm + ) { + return range.map((date) => ( +
+ {resources.map(([id, resource]) => ( +
+ {this.renderDayColumn( + date, + id, + resource, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + components, + dayLayoutAlgorithm, + now + )} +
+ ))} +
+ )) + } + + renderEvents(range, events, backgroundEvents, now) { + let { + accessors, + localizer, + resourceGroupingLayout, + components, + dayLayoutAlgorithm, + } = this.props + + const resources = this.memoizedResources(this.props.resources, accessors) + const groupedEvents = resources.groupEvents(events) + const groupedBackgroundEvents = resources.groupEvents(backgroundEvents) + + if (!resourceGroupingLayout) { + return this.renderResourcesFirst( + range, + resources, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + now, + components, + dayLayoutAlgorithm + ) + } else { + return this.renderRangeFirst( + range, + resources, + groupedEvents, + groupedBackgroundEvents, + localizer, + accessors, + now, + components, + dayLayoutAlgorithm + ) + } + } + render() { let { events, @@ -200,6 +303,7 @@ export default class TimeGrid extends Component { showMultiDayTimes, longPressThreshold, resizable, + resourceGroupingLayout, } = this.props width = width || this.state.gutterWidth @@ -238,6 +342,35 @@ export default class TimeGrid extends Component { allDayEvents.sort((a, b) => sortEvents(a, b, accessors, localizer)) + const headerProps = { + range, + events: allDayEvents, + width, + rtl, + getNow, + localizer, + selected, + allDayMaxRows: this.props.showAllEvents + ? Infinity + : this.props.allDayMaxRows ?? Infinity, + resources: this.memoizedResources(resources, accessors), + selectable: this.props.selectable, + accessors, + getters, + components, + scrollRef: this.scrollRef, + isOverflowing: this.state.isOverflowing, + longPressThreshold, + onSelectSlot: this.handleSelectAllDaySlot, + onSelectEvent: this.handleSelectEvent, + onShowMore: this.handleShowMore, + onDoubleClickEvent: this.props.onDoubleClickEvent, + onKeyPressEvent: this.props.onKeyPressEvent, + onDrillDown: this.props.onDrillDown, + getDrilldownView: this.props.getDrilldownView, + resizable, + } + return (
- + {resources && resources.length > 1 && resourceGroupingLayout ? ( + + ) : ( + + )} {this.props.popup && this.renderOverlay()}
{ + e.preventDefault() + notify(this.props.onDrillDown, [date, view]) + } + + renderHeaderCells(range) { + let { + localizer, + getDrilldownView, + getNow, + getters: { dayProp }, + components: { + header: HeaderComponent = Header, + resourceHeader: ResourceHeaderComponent = ResourceHeader, + }, + resources, + accessors, + events, + rtl, + selectable, + components, + getters, + resizable, + } = this.props + + const today = getNow() + + const groupedEvents = resources.groupEvents(events) + + return range.map((date, idx) => { + let drilldownView = getDrilldownView(date) + let label = localizer.format(date, 'dayFormat') + + const { className, style } = dayProp(date) + + let header = ( + + ) + + return ( +
+
+
+ {drilldownView ? ( + + ) : ( + {header} + )} +
+
+ +
+ {resources.map(([id, resource], idx) => { + return ( +
+ +
+ ) + })} +
+ +
+ {resources.map(([id, resource], idx) => { + // Filter the grouped events by the current date. + const filteredEvents = (groupedEvents.get(id) || []).filter( + (event) => + localizer.isSameDate(event.start, date) || + localizer.isSameDate(event.end, date) + ) + + return ( + + ) + })} +
+
+ ) + }) + } + + render() { + let { + width, + rtl, + range, + scrollRef, + isOverflowing, + components: { timeGutterHeader: TimeGutterHeader }, + } = this.props + + let style = {} + if (isOverflowing) { + style[rtl ? 'marginLeft' : 'marginRight'] = `${scrollbarSize() - 1}px` + } + + return ( +
+
+ {TimeGutterHeader && } +
+ + {this.renderHeaderCells(range)} +
+ ) + } +} + +TimeGridHeaderResources.propTypes = { + range: PropTypes.array.isRequired, + events: PropTypes.array.isRequired, + resources: PropTypes.object, + getNow: PropTypes.func.isRequired, + isOverflowing: PropTypes.bool, + + rtl: PropTypes.bool, + resizable: PropTypes.bool, + width: PropTypes.number, + + localizer: PropTypes.object.isRequired, + accessors: PropTypes.object.isRequired, + components: PropTypes.object.isRequired, + getters: PropTypes.object.isRequired, + + selected: PropTypes.object, + selectable: PropTypes.oneOf([true, false, 'ignoreEvents']), + longPressThreshold: PropTypes.number, + + allDayMaxRows: PropTypes.number, + + onSelectSlot: PropTypes.func, + onSelectEvent: PropTypes.func, + onDoubleClickEvent: PropTypes.func, + onKeyPressEvent: PropTypes.func, + onDrillDown: PropTypes.func, + onShowMore: PropTypes.func, + getDrilldownView: PropTypes.func.isRequired, + scrollRef: PropTypes.any, +} + +export default TimeGridHeaderResources diff --git a/src/sass/month.scss b/src/sass/month.scss index 95d1379a7..4177283dd 100644 --- a/src/sass/month.scss +++ b/src/sass/month.scss @@ -91,6 +91,7 @@ flex-direction: row; flex: 1 0 0; overflow: hidden; + right: 1px; } .rbc-day-bg { diff --git a/src/sass/styles.scss b/src/sass/styles.scss index 9610737c3..de31d6606 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -9,6 +9,14 @@ align-items: stretch; } +.rbc-m-b-negative-3 { + margin-bottom: -3px; +} + +.rbc-h-full { + height: 100%; +} + .rbc-calendar *, .rbc-calendar *:before, .rbc-calendar *:after { diff --git a/src/sass/time-grid.scss b/src/sass/time-grid.scss index ebb171b5f..e795a5103 100644 --- a/src/sass/time-grid.scss +++ b/src/sass/time-grid.scss @@ -141,3 +141,14 @@ background-color: $current-time-color; pointer-events: none; } + +.rbc-resource-grouping { + &.rbc-time-header-content { + display: flex; + flex-direction: column; + } + + .rbc-row .rbc-header { + width: 141px; + } +} \ No newline at end of file diff --git a/stories/demos/exampleCode/resource.js b/stories/demos/exampleCode/resource.js index fb74796a6..93d8cc663 100644 --- a/stories/demos/exampleCode/resource.js +++ b/stories/demos/exampleCode/resource.js @@ -1,49 +1,35 @@ -import React, { Fragment, useMemo } from 'react' +import LinkTo from '@storybook/addon-links/react' import PropTypes from 'prop-types' -import { Calendar, Views, DateLocalizer } from 'react-big-calendar' +import React, { Fragment, useMemo, useState, useCallback } from 'react' +import { Calendar, DateLocalizer, Views } from 'react-big-calendar' import DemoLink from '../../DemoLink.component' -import LinkTo from '@storybook/addon-links/react' +import withDragAndDrop from '../../../src/addons/dragAndDrop' -const events = [ - { - id: 0, - title: 'Board meeting', - start: new Date(2018, 0, 29, 9, 0, 0), - end: new Date(2018, 0, 29, 13, 0, 0), - resourceId: 1, - }, - { - id: 1, - title: 'MS training', - allDay: true, - start: new Date(2018, 0, 29, 14, 0, 0), - end: new Date(2018, 0, 29, 16, 30, 0), - resourceId: 2, - }, - { - id: 2, - title: 'Team lead meeting', - start: new Date(2018, 0, 29, 8, 30, 0), - end: new Date(2018, 0, 29, 12, 30, 0), - resourceId: [2, 3], - }, - { - id: 11, - title: 'Birthday Party', - start: new Date(2018, 0, 30, 7, 0, 0), - end: new Date(2018, 0, 30, 10, 30, 0), - resourceId: 4, - }, -] - -const resourceMap = [ +const DragAndDropCalendar = withDragAndDrop(Calendar) +const resources = [ { resourceId: 1, resourceTitle: 'Board room' }, { resourceId: 2, resourceTitle: 'Training room' }, { resourceId: 3, resourceTitle: 'Meeting room 1' }, { resourceId: 4, resourceTitle: 'Meeting room 2' }, ] +let eventId = 0 +const events = Array.from({ length: 20 }, (_, k) => k).flatMap((i) => { + const currentResource = resources[i % resources.length] + const dayDiff = i % 7 + + return Array.from({ length: 5 }, (_, j) => ({ + id: eventId++, + title: `Event ${i + j} _ ${currentResource.resourceTitle}`, + start: new Date(2018, 0, 29 + dayDiff, 9 + (j % 4), 0, 0), + end: new Date(2018, 0, 29 + dayDiff, 11 + (j % 4), 0, 0), + resourceId: currentResource.resourceId, + })) +}) + export default function Resource({ localizer }) { + const [groupResourcesOnWeek, setGroupResourcesOnWeek] = useState(false) + const { defaultDate, views } = useMemo( () => ({ defaultDate: new Date(2018, 0, 29), @@ -52,25 +38,102 @@ export default function Resource({ localizer }) { [] ) + const [myEvents, setEvents] = useState(events) + + const handleSelectSlot = useCallback( + ({ start, end, resourceId }) => { + const title = window.prompt('New Event Name') + if (title) { + setEvents((prev) => [...prev, { start, end, title, resourceId }]) + } + }, + [setEvents] + ) + + const handleSelectEvent = useCallback( + (event) => window.alert(event.title), + [] + ) + const moveEvent = useCallback( + ({ + event, + start, + end, + resourceId, + isAllDay: droppedOnAllDaySlot = false, + }) => { + const { allDay } = event + if (!allDay && droppedOnAllDaySlot) { + event.allDay = true + } + + setEvents((prev) => { + const existing = prev.find((ev) => ev.id === event.id) ?? {} + const filtered = prev.filter((ev) => ev.id !== event.id) + return [...filtered, { ...existing, start, end, resourceId, allDay }] + }) + }, + [setEvents] + ) + + const resizeEvent = useCallback( + ({ event, start, end }) => { + setEvents((prev) => { + const existing = prev.find((ev) => ev.id === event.id) ?? {} + const filtered = prev.filter((ev) => ev.id !== event.id) + return [...filtered, { ...existing, start, end }] + }) + }, + [setEvents] + ) + return ( - The calendar below uses the resourceIdAccessor, resourceTitleAccessor and resources props to show events scheduled for different resources. -
+ The calendar below uses the{' '} + + resourceIdAccessor + + ,{' '} + + resourceTitleAccessor + {' '} + and{' '} + + resources + {' '} + props to show events scheduled for different resources. +
Events can be mapped to a single resource, or multiple resources.
+
+ +
-
diff --git a/stories/props/resourceGroupingLayout.mdx b/stories/props/resourceGroupingLayout.mdx new file mode 100644 index 000000000..03336652a --- /dev/null +++ b/stories/props/resourceGroupingLayout.mdx @@ -0,0 +1,46 @@ +import { Story } from '@storybook/addon-docs' + +# resourceGroupingLayout + +- type: `boolean` +- default: 'false' + +Determines whether grouped resources should be displayed in a layout that organizes them from Resource > Day to Day > Resource. + +For example + +```md +resourceGroupingLayout={false} +Resource 1 + Day 1 + Event 1 + Event 2 + Day 2 + Event 3 + Event 4 +Resource 2 + Day 3 + Event 5 + Event 6 + Day 4 + Event 7 + Event 8 + +resourceGroupingLayout={true} +Day 1 + Resource 1 + Event 1 + Event 2 + Resource 2 + Event 3 + Event 4 +Day 2 + Resource 1 + Event 5 + Event 6 + Resource 2 + Event 7 + Event 8 +``` + + diff --git a/stories/props/resourceGroupingLayout.stories.js b/stories/props/resourceGroupingLayout.stories.js new file mode 100644 index 000000000..712626581 --- /dev/null +++ b/stories/props/resourceGroupingLayout.stories.js @@ -0,0 +1,57 @@ +import React from 'react' +import { Calendar } from '../../src' +import { resourceAccessorStoryArgs } from './storyDefaults' +import mdx from './resourceGroupingLayout.mdx' + +export default { + title: 'props', + component: Calendar, + argTypes: { + localizer: { control: { type: null } }, + events: { control: { type: null } }, + defaultDate: { control: { type: null } }, + }, + parameters: { + docs: { + page: mdx, + }, + }, +} + +const Template = (args) => ( +
+ +
+) + +const resources = [ + { resourceId: 1, resourceTitle: 'Board room' }, + { resourceId: 2, resourceTitle: 'Training room' }, + { resourceId: 3, resourceTitle: 'Meeting room 1' }, + { resourceId: 4, resourceTitle: 'Meeting room 2' }, +] + +let eventId = 0 +const events = Array.from({ length: 20 }, (_, k) => k).flatMap((i) => { + const currentResource = resources[i % resources.length] + const dayDiff = i % 7 + + return Array.from({ length: 5 }, (_, j) => ({ + id: eventId++, + title: `Event ${i + j} _ ${currentResource.resourceTitle}`, + start: new Date(2018, 0, 29 + dayDiff, 9 + (j % 4), 0, 0), + end: new Date(2018, 0, 29 + dayDiff, 11 + (j % 4), 0, 0), + resourceId: currentResource.resourceId, + })) +}) +export const ResourceGroupingLayout = Template.bind({}) +ResourceGroupingLayout.storyName = 'resourceGroupingLayout' +ResourceGroupingLayout.args = { + ...resourceAccessorStoryArgs, + defaultDate: new Date(2018, 0, 29), + resourceGroupingLayout: true, + resourceIdAccessor: 'resourceId', + resourceTitleAccessor: 'resourceTitle', + resources, + events, +}