├── .babelrc ├── .eslintrc ├── .gitignore ├── .node-version ├── .npmignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── circle.yml ├── example ├── basic.jsx ├── index.html ├── webpack.config.build.js └── webpack.config.js ├── mocha.opts ├── package.json ├── src ├── actions │ ├── cells.js │ ├── events.js │ └── range.js ├── cell.jsx ├── chart.jsx ├── constants.js ├── css │ └── default.scss ├── date_range.jsx ├── event.jsx ├── header.jsx ├── index.js ├── layout.jsx ├── partial_event.jsx ├── range_date.js ├── range_selector.jsx ├── reducers │ ├── cells.js │ ├── event.js │ ├── index.js │ └── range.js ├── resources.jsx ├── scheduler.jsx └── styles.js ├── tests ├── date_range.jsx ├── range_date.jsx ├── scheduler.jsx └── setup.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ['es2015', 'react', 'stage-0'], 3 | plugins: ['transform-decorators-legacy', 'react-hot-loader/babel'] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "ecmaVersion": 6, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "blockBindings": true, 8 | "forOf": true, 9 | "jsx": true 10 | } 11 | }, 12 | "rules": { 13 | "no-underscore-dangle": 0, 14 | "indent": ["error", 2, { "VariableDeclarator": { "var": 2, "let": 2, "const": 3 } }], 15 | "max-len": 0, 16 | "semi": 0, 17 | "quotes": 0, 18 | "no-console": 0, 19 | "no-trailing-spaces": 0, 20 | "no-shadow": 0, 21 | "curly": 0, 22 | "camelcase": 0, 23 | "react/jsx-boolean-value": 1, 24 | "react/jsx-quotes": 1, 25 | "react/jsx-no-undef": 1, 26 | "react/jsx-uses-react": 1, 27 | "react/jsx-uses-vars": 1, 28 | "react/no-did-mount-set-state": 1, 29 | "react/no-did-update-set-state": 1, 30 | "react/no-multi-comp": 1, 31 | "react/no-unknown-property": 1, 32 | "react/prop-types": 1, 33 | "react/react-in-jsx-scope": 1, 34 | "react/self-closing-comp": 1, 35 | "react/sort-comp": 1, 36 | "react/wrap-multilines": 1, 37 | "new-cap": [1, {newIsCap: true, capIsNew: false}], 38 | }, 39 | "plugins": [ 40 | "react" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.swp 3 | *.log 4 | example/main.js 5 | main.js 6 | index.html 7 | lib/ 8 | 9 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 5.7.0 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | src/ 3 | mocha.opts 4 | example/ 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 0.2.2 (12 Jul 2016) 6 | ### New Features 7 | - Adds a few minor performance enhancements to further speed up rendering. 8 | 9 | ### Bug Fixes 10 | - Fixes a bug where events that spilled over to the left side of the scheduler 11 | were displaying on top of the resource. 12 | 13 | ## 0.2.1 (12 Jul 2016) 14 | ### New Features 15 | - Adds a performance enhancement when rendering the chart component. 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Darin Haener 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Codetree](https://codetree.com/images/managed-with-codetree.svg)](https://codetree.com/projects/Q6Rz) 2 | 3 | # Legit Scheduler 4 | A pure React implementation of a drag and drop scheduler 5 | 6 | ## Usage 7 | 8 | Install it: 9 | ```bash 10 | $ npm install --save legit-scheduler 11 | ``` 12 | 13 | Import it: 14 | ```js 15 | import Scheduler from 'legit-scheduler' 16 | ``` 17 | 18 | The scheduler component has three required props: 19 | `events` - An array of event objects 20 | `resources` - An array of resources 21 | `width` - The width of the scheduler container, in pixels. An integer. 22 | 23 | The resources array is just strings: 24 | ``` 25 | ['Resource 1', 'Resource 2', 'Resource 3'] 26 | ``` 27 | 28 | The events array is an array of objects: 29 | ``` 30 | { 31 | title: 'A great event', // Required: The title of the event 32 | startDate: '2016-01-24', // Required: The start date, must be in the format of "YYYY-MM-DD" 33 | duration: 4, // Required: The duration of the event in days 34 | resource: 'Resource 1', // Required: The name of the resource the event belongs to. Must match the resource name from the resources prop 35 | id: '3829-fds89', // Required: A unique identifier. This can be anything you want as long as it's unique 36 | disabled: false, // Optional: Whether or not this event can be moved (it can still be resized). Defaults to false. 37 | styles: {} // Optional: An object of styles to apply to the event object 38 | } 39 | ``` 40 | 41 | The scheduler component also takes more optional props: 42 | 43 | `onEventChanged` - A call back that is fired when the event is moved. It receives an object containing the new event props 44 | `onEventResized` - A call back that is fired when the event is resized. It receives an object containing the new event props 45 | `onEventClicked` - A call back that is fired when the event is clicked. It receives an object containing the event props 46 | `onCellClicked` - A call back that is fired when an empty cell on the scheduler is clicked. It receives the date and resource name as props 47 | `onRangeChanged` - A call back that is fired when the date range is changed. It receives a `DateRange` object with the new range. 48 | `from` - Either a date string or a `RangeDate` object defining the start date for the range. 49 | `to` - Either a date string or a `RangeDate` object defining the end date for the range. 50 | 51 | ## Development 52 | ```bash 53 | $ npm install 54 | $ npm run example 55 | ``` 56 | 57 | Visit: `localhost:8080/example` 58 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | node: 3 | version: 5.7.0 4 | -------------------------------------------------------------------------------- /example/basic.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import Scheduler from '../src/scheduler'; 4 | import RangeDate from '../src/range_date'; 5 | import DateRange from '../src/date_range'; 6 | import { whyDidYouUpdate } from 'why-did-you-update'; 7 | 8 | // Uncomment this to examine where you can get performance boosts 9 | //whyDidYouUpdate(React); 10 | 11 | var resources = ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten'], 12 | today = new RangeDate(new Date()), 13 | events = [ 14 | { 15 | id: 'foobar', 16 | title: 'Do this', 17 | startDate: today.advance('days', 1).toRef(), 18 | duration: 5, 19 | resource: 'One' 20 | }, 21 | { 22 | id: 'barfoo', 23 | title: 'Do that', 24 | startDate: today.advance('days', 3).toRef(), 25 | duration: 4, 26 | resource: 'Two' 27 | }, 28 | { 29 | id: 'barfoobaz', 30 | title: 'I am disabled', 31 | startDate: today.advance('days', 2).toRef(), 32 | duration: 7, 33 | resource: 'Three', 34 | disabled: true 35 | }, 36 | { 37 | id: 'foobah', 38 | title: 'Do another thing', 39 | startDate: today.advance('days', 6).toRef(), 40 | duration: 14, 41 | resource: 'Seven' 42 | }, 43 | { 44 | id: 'foobaz', 45 | title: 'Do another thing next month', 46 | startDate: today.advance('days', 36).toRef(), 47 | duration: 14, 48 | resource: 'Seven' 49 | } 50 | ] 51 | 52 | class Basic extends React.Component { 53 | constructor(props) { 54 | super(props) 55 | let from = new RangeDate() 56 | let to = from.advance('weeks', 2) 57 | 58 | this.state = { 59 | events: props.events, 60 | range: new DateRange(from, to) 61 | } 62 | } 63 | 64 | eventChanged(props) { 65 | const index = this.state.events.findIndex(event => event.id === props.id) 66 | const newEvents = this.state.events 67 | newEvents[index] = props 68 | this.setState({ ...props, events: newEvents }) 69 | console.log(props) 70 | } 71 | 72 | eventResized(props) { 73 | const index = this.state.events.findIndex(event => event.id === props.id) 74 | const newEvents = this.state.events 75 | newEvents[index] = props 76 | this.setState({ ...props, events: newEvents }) 77 | console.log(props) 78 | } 79 | 80 | eventClicked(props) { 81 | alert(`${props.title} clicked!`) 82 | console.log(props) 83 | } 84 | 85 | cellClicked(resource, date) { 86 | alert(`You clicked on ${resource} - ${date}`) 87 | console.log(resource, date) 88 | } 89 | 90 | rangeChanged(range) { 91 | this.setState({ range: range }) 92 | } 93 | 94 | render() { 95 | const { events, range, title, startDate, duration, resource } = this.state, 96 | { from, to } = range 97 | 98 | return ( 99 |
100 | 112 |
113 |
114 |

Current Event

115 |
    116 |
  • Title: {title}
  • 117 |
  • Start Date: {startDate}
  • 118 |
  • Duration: {duration} days
  • 119 |
  • Resource: {resource}
  • 120 |
121 |
122 |
123 | ) 124 | } 125 | } 126 | 127 | require('../src/css/default.scss') 128 | render(, document.getElementById('react')) 129 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | React Resource Scheduler Example 4 | 5 | 6 | 7 |
8 |
9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /example/webpack.config.build.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | entry: './basic.jsx', 6 | output: { 7 | path: __dirname, 8 | filename: "[name].js", 9 | }, 10 | resolve: { 11 | extensions: ['', '.js', '.jsx', '.es6'], 12 | modulesDirectories: ['node_modules'] 13 | }, 14 | module: { 15 | loaders: [ 16 | { test: /\.jsx$|\.es6$|\.js$/, loaders: ['react-hot-loader', 'babel-loader?stage=0'], exclude: /node_modules/ }, 17 | { test: /\.scss$|\.css$/, loader: 'style-loader!style!css!sass' }, 18 | { test: /\.(jpe?g|png|gif)$/i, loader: 'url?limit=10000!img?progressive=true' }, 19 | { test: /\.json$/, loader: 'json' } 20 | ] 21 | }, 22 | plugins: [ 23 | new webpack.NoEmitOnErrorsPlugin() 24 | ], 25 | devtool: "eval-source-map" 26 | }; 27 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | 4 | module.exports = { 5 | entry: { 6 | 'basic': [ 7 | 'webpack-dev-server/client?http://localhost:8080/', 8 | 'webpack/hot/only-dev-server', 9 | './example/basic.jsx' 10 | ] 11 | }, 12 | output: { 13 | path: __dirname, 14 | filename: "[name].js", 15 | publicPath: 'http://localhost:8080/', 16 | chunkFilename: '[id].chunk.js', 17 | sourceMapFilename: '[name].map' 18 | }, 19 | resolve: { 20 | extensions: ['*', '.js', '.jsx', '.es6'], 21 | modules: ['node_modules'] 22 | }, 23 | module: { 24 | loaders: [ 25 | { test: /\.jsx$|\.es6$|\.js$/, loaders: ['babel-loader'], exclude: /node_modules/ }, 26 | { test: /\.scss$|\.css$/, loader: 'style-loader!style-loader!css-loader!sass-loader' }, 27 | { test: /\.(jpe?g|png|gif)$/i, loader: 'url?limit=10000!img?progressive=true' }, 28 | { test: /\.json/, loader: 'json-loader' } 29 | ] 30 | }, 31 | plugins: [ 32 | new webpack.NoEmitOnErrorsPlugin() 33 | ], 34 | devtool: "cheap-source-map" 35 | }; 36 | -------------------------------------------------------------------------------- /mocha.opts: -------------------------------------------------------------------------------- 1 | --require ./tests/setup 2 | --full-trace 3 | --compilers js:babel-core/register 4 | --recursive ./tests/**/*.jsx 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "legit-scheduler", 3 | "version": "0.3.2", 4 | "description": "A React drag and drop scheduler component", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "mocha --opts ./mocha.opts; eslint ./src/ --ext .jsx,.js --global require,exports:true", 8 | "compile": "babel src --ignore __tests__ --stage 0 --out-dir lib; node-sass src/css/default.scss lib/css/default.css", 9 | "example": "webpack-dev-server --config ./example/webpack.config.js --hot" 10 | }, 11 | "author": "Darin Haener (https://github.com/dphaener)", 12 | "license": "MIT", 13 | "keywords": [ 14 | "react-component", 15 | "react", 16 | "calendar", 17 | "scheduler", 18 | "gantt" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git+ssh://git@github.com/legitcode/scheduler.git" 23 | }, 24 | "dependencies": { 25 | "immutable": "3.8.1", 26 | "legit-rubyfill": "0.0.7", 27 | "moment": "2.18.0", 28 | "moment-timezone": "0.5.11", 29 | "prop-types": "^15.6.0", 30 | "react": "16.2.0", 31 | "react-dnd": "2.5.4", 32 | "react-dnd-html5-backend": "2.5.4", 33 | "react-dom": "16.2.0", 34 | "react-draggable": "2.2.3", 35 | "react-redux": "5.0.6", 36 | "redux": "3.7.2", 37 | "redux-batched-actions": "0.2.1", 38 | "strftime": "0.10.0" 39 | }, 40 | "devDependencies": { 41 | "babel": "6.23.0", 42 | "babel-cli": "^6.24.0", 43 | "babel-core": "6.24.0", 44 | "babel-eslint": "7.1.1", 45 | "babel-loader": "6.4.1", 46 | "babel-plugin-transform-decorators-legacy": "1.3.4", 47 | "babel-preset-es2015": "6.24.0", 48 | "babel-preset-react": "6.23.0", 49 | "babel-preset-stage-0": "6.22.0", 50 | "chai": "3.5.0", 51 | "css-loader": "0.27.3", 52 | "eslint": "3.18.0", 53 | "eslint-plugin-react": "6.10.2", 54 | "estraverse-fb": "1.3.1", 55 | "expect": "1.20.2", 56 | "file-loader": "0.10.1", 57 | "img-loader": "2.0.0", 58 | "jsdom": "9.12.0", 59 | "json-loader": "0.5.7", 60 | "legit-tests": "1.1.2", 61 | "mocha": "3.2.0", 62 | "mocha-babel": "3.0.3", 63 | "node-libs-browser": "2.0.0", 64 | "node-sass": "4.5.0", 65 | "react-addons-perf": "15.4.2", 66 | "react-hot-loader": "3.1.3", 67 | "sass-loader": "6.0.3", 68 | "sinon": "2.1.0", 69 | "sinon-chai": "2.8.0", 70 | "style-loader": "0.14.1", 71 | "timekeeper": "1.0.0", 72 | "url-loader": "0.5.8", 73 | "webpack": "3.10.0", 74 | "webpack-dev-server": "2.11.0", 75 | "why-did-you-update": "0.1.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/actions/cells.js: -------------------------------------------------------------------------------- 1 | export function createCells(resources, range) { 2 | return { 3 | type: 'createCells', 4 | range, 5 | resources 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/actions/events.js: -------------------------------------------------------------------------------- 1 | export function moveEvent(event, cell, callback) { 2 | return { 3 | type: 'moveEvent', 4 | callback, 5 | event, 6 | cell 7 | } 8 | } 9 | 10 | export function updateEventDuration(event, duration, callback) { 11 | return { 12 | type: 'updateEventDuration', 13 | callback, 14 | event, 15 | duration 16 | } 17 | } 18 | 19 | export function replaceResources(resources) { 20 | return { 21 | type: 'replaceResources', 22 | resources 23 | } 24 | } 25 | 26 | export function replaceEvents(events) { 27 | return { 28 | type: 'replaceEvents', 29 | events 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/actions/range.js: -------------------------------------------------------------------------------- 1 | export function advanceRange() { 2 | return { 3 | type: 'advanceRange', 4 | nextAction: 'createCells' 5 | } 6 | } 7 | 8 | export function retardRange() { 9 | return { 10 | type: 'retardRange', 11 | nextAction: 'createCells' 12 | } 13 | } 14 | 15 | export function setRange(range) { 16 | return { 17 | type: 'setRange', 18 | range 19 | } 20 | } 21 | 22 | export function clearRangeFlag() { 23 | return { 24 | type: 'clearRangeFlag' 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cell.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React from 'react' 3 | import PropTypes from 'prop-types'; 4 | import { DropTarget } from 'react-dnd' 5 | 6 | // Local LIbraries 7 | import { ItemTypes } from './constants' 8 | 9 | // Styles 10 | import { cell } from './styles' 11 | 12 | const cellTarget = { 13 | drop(props, monitor, component) { 14 | return Object.assign(component.state, props) 15 | }, 16 | canDrop(props) { 17 | return !props.children 18 | } 19 | } 20 | 21 | function collect(connect, monitor) { 22 | return { 23 | connectDropTarget: connect.dropTarget(), 24 | isOver: monitor.isOver() 25 | } 26 | } 27 | 28 | @DropTarget(ItemTypes.EVENT, cellTarget, collect) 29 | export default class Cell extends React.Component { 30 | static propTypes = { 31 | resource: PropTypes.string.isRequired, 32 | date: PropTypes.string.isRequired 33 | } 34 | 35 | constructor(props) { 36 | super(props); 37 | this.state = { cellWidth: 0 }; 38 | } 39 | 40 | componentDidMount() { 41 | const node = this.wrapper; 42 | const rect = node.getBoundingClientRect(); 43 | const cellWidth = rect.width + 2; 44 | 45 | this.setState({ cellWidth }); 46 | } 47 | 48 | shouldComponentUpdate(nextProps, nextState) { 49 | //if (this.state.cellWidth !== nextState.cellWidth) { shouldUpdate = true } 50 | //if (nextProps.resource !== this.props.resource) { shouldUpdate = true } 51 | //if (nextProps.date !== this.props.date) { shouldUpdate = true } 52 | //if (nextProps.children && !this.props.children) { shouldUpdate = true } 53 | 54 | if (!this.areObjectsEqual(this.props.children, nextProps.children)) { return true } 55 | 56 | return false; 57 | } 58 | 59 | areObjectsEqual(first = {}, second = {}) { 60 | return Object.keys(first).reduce((prev, curr) => { 61 | if (first[curr] !== second[curr]) { return false } 62 | }, true); 63 | } 64 | 65 | render() { 66 | const { children, connectDropTarget, onClick } = this.props; 67 | 68 | return ( 69 | connectDropTarget( 70 |
{ this.wrapper = el; }}> 71 | { React.Children.map(children, child => React.cloneElement(child, this.state)) } 72 |
73 | ) 74 | ) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/chart.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React, { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import { DragDropContext } from 'react-dnd'; 5 | import HTML5Backend from 'react-dnd-html5-backend'; 6 | import 'legit-rubyfill/array/each_slice'; 7 | import 'legit-rubyfill/array/equals'; 8 | 9 | // Local Libraries 10 | import Event from './event'; 11 | import PartialEvent from './partial_event'; 12 | import Cell from './cell'; 13 | import RangeDate from './range_date'; 14 | 15 | // Styles 16 | import { chart, cellWrapper } from './styles'; 17 | 18 | @DragDropContext(HTML5Backend) 19 | export default class Chart extends Component { 20 | static propTypes = { 21 | events: PropTypes.array.isRequired, 22 | resources: PropTypes.array.isRequired, 23 | range: PropTypes.object.isRequired, 24 | cells: PropTypes.object.isRequired, 25 | eventChanged: PropTypes.func.isRequired, 26 | eventResized: PropTypes.func.isRequired, 27 | eventClicked: PropTypes.func.isRequired, 28 | cellClicked: PropTypes.func.isRequired, 29 | rowHeight: PropTypes.number.isRequired, 30 | width: PropTypes.number.isRequired 31 | } 32 | 33 | shouldComponentUpdate(nextProps, nextState) { 34 | let shouldUpdate = true; 35 | 36 | // First test to see if the resources are exactly the same 37 | if (nextProps.resources.equals(this.props.resources)) { shouldUpdate = false } 38 | 39 | // Now let's look at the events and see if they are different 40 | nextProps.events.forEach((event, idx) => { 41 | if (!this.areObjectsEqual(event, this.props.events[idx])) { shouldUpdate = true } 42 | }) 43 | 44 | return shouldUpdate; 45 | } 46 | 47 | areObjectsEqual(first, second) { 48 | return Object.keys(first).reduce((prev, curr) => { 49 | if (first[curr] !== second[curr]) { return false } 50 | }, true); 51 | } 52 | 53 | renderEvent(resource, date) { 54 | const { rowHeight, eventChanged, eventResized, eventClicked } = this.props 55 | const currentEvent = this.props.events.find(event => { 56 | return event.resource === resource && event.startDate === date 57 | }) 58 | 59 | if (currentEvent) { 60 | return ( 61 | 68 | ) 69 | } else { 70 | const partialEvent = this.props.events.find(event => { 71 | let eventEnd = new RangeDate(event.startDate).advance('days', event.duration), 72 | from = this.props.range.from.date, 73 | eventStart = new RangeDate(event.startDate).date 74 | 75 | return ( 76 | eventEnd.toRef() === date && 77 | from.isAfter(eventStart, 'day') && 78 | event.resource === resource 79 | ) 80 | }) 81 | 82 | if (partialEvent) return ( 83 | 88 | ) 89 | } 90 | } 91 | 92 | cellClicked(ev, resource, date) { 93 | ev.stopPropagation() 94 | const targetClass = ev.target.attributes[0].value 95 | if (targetClass !== 'resizer') { 96 | this.props.cellClicked(resource, date) 97 | } 98 | } 99 | 100 | renderCell(resource, date) { 101 | const { width, range } = this.props 102 | 103 | return ( 104 |
108 | ::this.cellClicked(ev, resource, date)}> 112 | { this.renderEvent(resource, date) } 113 | 114 |
115 | ) 116 | } 117 | 118 | renderRow(resource, idx) { 119 | const { range, width } = this.props 120 | 121 | return ( 122 |
123 | { range.map(date => this.renderCell(resource, date.toRef())) } 124 |
125 | ) 126 | } 127 | 128 | createCells() { 129 | const { resources } = this.props, 130 | rows = [] 131 | 132 | resources.forEach((resource, idx) => { 133 | rows.push(this.renderRow(resource, idx)) 134 | }) 135 | 136 | return rows 137 | } 138 | 139 | render() { 140 | const { width } = this.props 141 | 142 | return ( 143 |
144 | { this.createCells() } 145 |
146 | ) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const ItemTypes = { 2 | EVENT: 'event' 3 | } 4 | -------------------------------------------------------------------------------- /src/css/default.scss: -------------------------------------------------------------------------------- 1 | div.event-box { 2 | span.resizer { 3 | &:hover { 4 | cursor: ew-resize; 5 | } 6 | } 7 | 8 | span.event-handle { 9 | &:hover { 10 | cursor: move; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/date_range.jsx: -------------------------------------------------------------------------------- 1 | import RangeDate from './range_date' 2 | 3 | export default class DateRange { 4 | constructor(...args) { 5 | let [ from, to ] = args 6 | 7 | this.from = new RangeDate(from) 8 | this.to = new RangeDate(to) 9 | } 10 | 11 | daysInRange() { 12 | return this.to.date.diff(this.from.date, 'days') + 1 13 | } 14 | 15 | toString() { 16 | return `${this.from.toString()} - ${this.to.toString()}` 17 | } 18 | 19 | advance(reverse = false) { 20 | const advanceAmount = reverse ? -this.daysInRange() : this.daysInRange(), 21 | from = this.from.advance('days', advanceAmount), 22 | to = this.to.advance('days', advanceAmount) 23 | 24 | return new DateRange(from, to) 25 | } 26 | 27 | map(func) { 28 | let current = this.from, 29 | dates = [] 30 | 31 | while (current.value() <= this.to.value()) { 32 | dates.push(func(current)) 33 | current = current.advance('days', 1) 34 | } 35 | 36 | return dates 37 | } 38 | 39 | forEach(func) { 40 | let current = this.from 41 | 42 | while (current.value() <= this.to.value()) { 43 | func(current) 44 | current = current.advance('days', 1) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/event.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React from 'react' 3 | import PropTypes from 'prop-types'; 4 | import { DragSource } from 'react-dnd' 5 | import { connect } from 'react-redux' 6 | 7 | // Local Libraries 8 | import { moveEvent, updateEventDuration } from './actions/events' 9 | import { ItemTypes } from './constants' 10 | 11 | // Styles 12 | import { eventHandleStyles, eventStyles, resizerStyles, boxStyles } from './styles' 13 | 14 | /* globals document */ 15 | 16 | const eventSource = { 17 | beginDrag(props) { 18 | return { 19 | resource: props.resource, 20 | date: props.startDate, 21 | id: props.id 22 | } 23 | }, 24 | endDrag(props, monitor, component) { 25 | if (!monitor.didDrop()) return 26 | 27 | component.props.dispatch( 28 | moveEvent( 29 | props, 30 | monitor.getDropResult(), 31 | props.eventChanged 32 | ) 33 | ) 34 | }, 35 | canDrag(props) { 36 | return !props.disabled 37 | } 38 | } 39 | 40 | function collect(connect, monitor) { 41 | return { 42 | connectDragSource: connect.dragSource(), 43 | isDragging: monitor.isDragging(), 44 | connectDragPreview: connect.dragPreview() 45 | } 46 | } 47 | 48 | @DragSource(ItemTypes.EVENT, eventSource, collect) 49 | class Event extends React.Component { 50 | static propTypes = { 51 | title: PropTypes.string.isRequired, 52 | startDate: PropTypes.string.isRequired, 53 | duration: PropTypes.number.isRequired, 54 | resource: PropTypes.string.isRequired, 55 | dispatch: PropTypes.func, 56 | eventChanged: PropTypes.func.isRequired, 57 | eventResized: PropTypes.func.isRequired, 58 | eventClicked: PropTypes.func.isRequired, 59 | cellWidth: PropTypes.number, 60 | disabled: PropTypes.bool, 61 | id: PropTypes.string.isRequired, 62 | styles: PropTypes.object, 63 | isDragging: PropTypes.bool.isRequired, 64 | connectDragSource: PropTypes.func.isRequired, 65 | connectDragPreview: PropTypes.func.isRequired, 66 | rowHeight: PropTypes.number.isRequired, 67 | children: PropTypes.node 68 | } 69 | 70 | constructor(props) { 71 | super(props) 72 | 73 | this.state = {} 74 | } 75 | 76 | componentWillMount() { 77 | const { duration, cellWidth } = this.props, 78 | width = (duration * cellWidth) === 0 ? cellWidth : (duration * cellWidth) - duration - 9 79 | 80 | this.setState({ cellWidth, width, startWidth: width }) 81 | } 82 | 83 | componentDidMount() { 84 | this.refs.resizer.addEventListener('mousedown', this.initDrag, false) 85 | } 86 | 87 | componentWillReceiveProps(nextProps) { 88 | const { duration, cellWidth } = nextProps, 89 | width = (duration * cellWidth) === 0 ? cellWidth : duration * cellWidth - duration - 9 90 | 91 | this.setState({ duration, width, startWidth: width }) 92 | } 93 | 94 | initDrag = (ev) => { 95 | ev.stopPropagation() 96 | 97 | this.setState({ 98 | startX: ev.clientX 99 | }) 100 | 101 | document.documentElement.addEventListener('mousemove', this.doDrag, false) 102 | document.documentElement.addEventListener('mouseup', this.stopDrag, false) 103 | } 104 | 105 | doDrag = (ev) => { 106 | ev.stopPropagation() 107 | const { startWidth, startX } = this.state, 108 | newWidth = (startWidth + ev.clientX - startX) 109 | 110 | this.setState({ width: newWidth }) 111 | } 112 | 113 | stopDrag = (ev) => { 114 | ev.stopPropagation() 115 | const { eventResized, disabled, dispatch, id, title, startDate, resource, styles } = this.props, 116 | { width } = this.state, 117 | newDuration = this.roundToNearest(width) 118 | 119 | dispatch( 120 | updateEventDuration( 121 | { disabled, id, title, startDate, resource, styles }, 122 | newDuration, 123 | eventResized 124 | ) 125 | ) 126 | 127 | document.documentElement.removeEventListener('mousemove', this.doDrag, false) 128 | document.documentElement.removeEventListener('mouseup', this.stopDrag, false) 129 | } 130 | 131 | roundToNearest(numToRound) { 132 | return Math.ceil(numToRound / this.props.cellWidth) 133 | } 134 | 135 | dispatchEventClick(ev) { 136 | ev.stopPropagation() 137 | this.props.eventClicked(this.props) 138 | } 139 | 140 | render() { 141 | const { styles, isDragging, connectDragSource, connectDragPreview, id, title, children, rowHeight, ...rest } = this.props, 142 | { width } = this.state, 143 | resizerStyleMerge = Object.assign({ height: '100%' }, resizerStyles), 144 | defaultStyles = { color: '#000', backgroundColor: 'darkgrey' }, 145 | eventStyleMerge = Object.assign({ width }, styles || defaultStyles, eventStyles), 146 | opacity = isDragging ? 0 : 1, 147 | boxStyleMerge = Object.assign({ width, opacity }, boxStyles) 148 | 149 | return ( 150 |
151 | { isDragging ? null : 152 | connectDragPreview( 153 |
154 | { connectDragSource( 155 | 156 | ) 157 | } 158 | {title} 159 |
160 | ) 161 | } 162 | 163 |
164 | ) 165 | } 166 | } 167 | 168 | export default connect()(Event) 169 | -------------------------------------------------------------------------------- /src/header.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React from 'react' 3 | 4 | // Styles 5 | import { headerWrapper, chartHeader } from './styles' 6 | 7 | export default ({ range, width }) => ( 8 |
9 |
10 | { range.map(date => ( 11 |
15 | {date.toCal()} 16 |
17 | )) 18 | } 19 |
20 |
21 | ) 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Scheduler from './scheduler' 2 | import RangeDate from './range_date' 3 | import DateRange from './date_range' 4 | 5 | export { RangeDate, DateRange } 6 | 7 | export default Scheduler 8 | -------------------------------------------------------------------------------- /src/layout.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React, { Component } from 'react' 3 | import PropTypes from 'prop-types'; 4 | import { connect } from 'react-redux' 5 | import 'legit-rubyfill/array/equals'; 6 | 7 | // Local Libraries 8 | import Chart from './chart' 9 | import Header from './header' 10 | import Resources from './resources' 11 | import RangeSelector from './range_selector' 12 | 13 | class Layout extends Component { 14 | static propTypes = { 15 | resources: PropTypes.array.isRequired, 16 | range: PropTypes.object.isRequired, 17 | events: PropTypes.array.isRequired, 18 | cells: PropTypes.object.isRequired, 19 | eventChanged: PropTypes.func.isRequired, 20 | eventResized: PropTypes.func.isRequired, 21 | eventClicked: PropTypes.func.isRequired, 22 | cellClicked: PropTypes.func.isRequired, 23 | rangeChanged: PropTypes.func.isRequired, 24 | rangeDidChange: PropTypes.bool.isRequired, 25 | width: PropTypes.number.isRequired, 26 | rowHeight: PropTypes.number.isRequired 27 | } 28 | 29 | shouldComponentUpdate(nextProps, nextState) { 30 | let shouldUpdate = true; 31 | 32 | //if (nextProps.resources.equals(this.props.resources)) { shouldUpdate = false } 33 | //if (nextProps.events.length !== this.props.events.length) { shouldUpdate = true } 34 | 35 | return shouldUpdate; 36 | } 37 | 38 | render() { 39 | const { rangeDidChange, rangeChanged, width, range, resources, rowHeight } = this.props 40 | 41 | return ( 42 |
43 | 44 |
45 |
46 |
47 | 48 | 49 |
50 |
51 |
52 | ) 53 | } 54 | } 55 | 56 | export default connect(state => { 57 | const { rangeDidChange, range } = state.range.toJS() 58 | const { resources, events } = state.event.toJS() 59 | const { cells } = state.cells.toJS() 60 | return { rangeDidChange, cells, range, resources, events } 61 | })(Layout) 62 | -------------------------------------------------------------------------------- /src/partial_event.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React from 'react' 3 | import PropTypes from 'prop-types'; 4 | 5 | // Styles 6 | import { partialEventStyles, boxStyles } from './styles' 7 | 8 | export default class PartialEvent extends React.Component { 9 | static propTypes = { 10 | title: PropTypes.string.isRequired, 11 | startDate: PropTypes.string.isRequired, 12 | duration: PropTypes.number.isRequired, 13 | resource: PropTypes.string.isRequired, 14 | dispatch: PropTypes.func, 15 | cellWidth: PropTypes.number, 16 | disabled: PropTypes.bool, 17 | id: PropTypes.string.isRequired, 18 | styles: PropTypes.object, 19 | rowHeight: PropTypes.number.isRequired, 20 | children: PropTypes.node, 21 | eventClicked: PropTypes.func.isRequired 22 | } 23 | 24 | constructor(props) { 25 | super(props) 26 | 27 | this.state = {} 28 | } 29 | 30 | componentDidMount() { 31 | const { duration, cellWidth } = this.props, 32 | width = (duration * cellWidth) === 0 ? cellWidth : (duration * cellWidth) - duration - 9 33 | 34 | this.setState({ cellWidth, width, startWidth: width }) 35 | } 36 | 37 | componentWillReceiveProps(nextProps) { 38 | const { duration, cellWidth } = nextProps, 39 | width = (duration * cellWidth) === 0 ? cellWidth : duration * cellWidth - duration - 9 40 | 41 | this.setState({ duration, width, startWidth: width }) 42 | } 43 | 44 | dispatchEventClick(ev) { 45 | ev.stopPropagation() 46 | this.props.eventClicked(this.props) 47 | } 48 | 49 | render() { 50 | const { styles, id, title, children, rowHeight, ...rest } = this.props, 51 | { width } = this.state, 52 | defaultStyles = { color: '#000', backgroundColor: 'darkgrey' }, 53 | eventStyleMerge = Object.assign({ width }, styles || defaultStyles, partialEventStyles), 54 | boxStyleMerge = Object.assign({ height: '100%', top: '2px', width }, boxStyles) 55 | 56 | return ( 57 |
58 |
59 | {title} 60 |
61 |
62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/range_date.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment-timezone' 2 | 3 | export default class RangeDate { 4 | constructor(date = null) { 5 | if (date) { 6 | this.date = date instanceof RangeDate ? moment(date.value()) : moment(date) 7 | } else { 8 | this.date = moment() 9 | } 10 | } 11 | 12 | toString() { 13 | return this.date.format('MMMM D, YYYY') 14 | } 15 | 16 | toCal() { 17 | return this.date.format('MMM[\n]M/D') 18 | } 19 | 20 | toRef() { 21 | return this.date.format('YYYY-MM-DD') 22 | } 23 | 24 | value() { 25 | return this.date._d 26 | } 27 | 28 | advance(increment, amount) { 29 | return new RangeDate( 30 | this.date.clone().add(amount, increment) 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/range_selector.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React, { Component } from 'react' 3 | import PropTypes from 'prop-types'; 4 | import { connect } from 'react-redux' 5 | 6 | // Actions 7 | import { advanceRange, retardRange, clearRangeFlag } from './actions/range' 8 | 9 | // Styles 10 | import { selectors, leftButton, leftButtonAfter, rightButton, rightButtonAfter } from './styles' 11 | 12 | class RangeSelector extends Component { 13 | static propTypes = { 14 | range: PropTypes.object.isRequired, 15 | rangeDidChange: PropTypes.bool.isRequired, 16 | dispatch: PropTypes.func.isRequired, 17 | rangeChanged: PropTypes.func.isRequired, 18 | leftCursor: PropTypes.string, 19 | rightCursor: PropTypes.string 20 | } 21 | 22 | constructor(props) { 23 | super(props) 24 | 25 | this.state = {} 26 | } 27 | 28 | shouldComponentUpdate(nextProps, nextState) { 29 | if (nextProps.rangeDidChange) { return true } 30 | return !this.areCursorsEqual(this.state, nextState); 31 | } 32 | 33 | areCursorsEqual(currentState, nextState) { 34 | return currentState.rightCursor === nextState.rightCursor && 35 | currentState.leftCursor === nextState.leftCursor 36 | } 37 | 38 | componentWillReceiveProps(nextProps) { 39 | if (nextProps.rangeDidChange) { 40 | this.props.dispatch(clearRangeFlag()) 41 | this.props.rangeChanged(nextProps.range) 42 | } 43 | } 44 | 45 | addLeftHover = () => { 46 | this.setState({ leftCursor: 'pointer' }) 47 | } 48 | 49 | addRightHover = () => { 50 | this.setState({ rightCursor: 'pointer' }) 51 | } 52 | 53 | removeLeftHover = () => { 54 | this.setState({ leftCursor: 'arrow' }) 55 | } 56 | 57 | removeRightHover = () => { 58 | this.setState({ rightCursor: 'arrow' }) 59 | } 60 | 61 | previousClicked() { 62 | this.props.dispatch(retardRange()) 63 | } 64 | 65 | nextClicked() { 66 | this.props.dispatch(advanceRange()) 67 | } 68 | 69 | render() { 70 | const { leftCursor, rightCursor } = this.state, 71 | { range } = this.props, 72 | mergedLeftButtonStyle = Object.assign({ cursor: leftCursor }, leftButton), 73 | mergedRightButtonStyle = Object.assign({ cursor: rightCursor }, rightButton) 74 | 75 | return ( 76 |
77 |
83 |
84 |
85 |
86 | { range.toString() } 87 |
88 |
94 |
95 |
96 |
97 | ) 98 | } 99 | } 100 | 101 | export default connect()(RangeSelector) 102 | -------------------------------------------------------------------------------- /src/reducers/cells.js: -------------------------------------------------------------------------------- 1 | import { Map, fromJS } from 'immutable' 2 | 3 | const defaultState = Map({ 4 | cells: Map({}) 5 | }) 6 | 7 | export default (state = defaultState, action) => { 8 | switch(action.type) { 9 | case 'createCells': 10 | const { range, resources } = action 11 | let cells = Map({}) 12 | 13 | resources.forEach(resource => { 14 | range.forEach(date => { 15 | cells = cells.setIn([`${resource}${date.toRef()}`], Map({ resource: resource, date: date.toRef()})) 16 | }) 17 | }) 18 | 19 | return fromJS({ cells: cells }) 20 | default: 21 | return state 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/reducers/event.js: -------------------------------------------------------------------------------- 1 | import { Map, fromJS } from 'immutable' 2 | 3 | const defaultState = Map({ 4 | events: [], 5 | resources: [] 6 | }) 7 | 8 | export default (state = defaultState, action) => { 9 | let index, newEvent 10 | 11 | switch (action.type) { 12 | case 'replaceResources': 13 | const resources = fromJS(action.resources) 14 | return state.setIn(['resources'], resources) 15 | case 'replaceEvents': 16 | const events = fromJS(action.events) 17 | return state.setIn(['events'], events) 18 | case 'moveEvent': 19 | newEvent = Map(action.event).withMutations(map => { 20 | map.set('startDate', action.cell.date). 21 | set('resource', action.cell.resource) 22 | }).filter((value, key) => ['styles', 'duration', 'id', 'resource', 'startDate', 'title'].includes(key)) 23 | 24 | index = state.get('events').findIndex(item => { 25 | return item.get('id') === action.event.id 26 | }) 27 | 28 | return state.setIn(['events', index], newEvent) 29 | case 'updateEventDuration': 30 | newEvent = Map(action.event).withMutations(map => { 31 | map.set('duration', action.duration) 32 | }) 33 | index = state.get('events').findIndex(item => { 34 | return item.get('id') === action.event.id 35 | }) 36 | 37 | return state.setIn(['events', index], newEvent) 38 | default: 39 | return state 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import event from './event' 3 | import range from './range' 4 | import cells from './cells' 5 | 6 | export default combineReducers({ 7 | event, 8 | range, 9 | cells 10 | }) 11 | -------------------------------------------------------------------------------- /src/reducers/range.js: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable' 2 | 3 | import RangeDate from '../range_date' 4 | import DateRange from '../date_range' 5 | 6 | const from = new RangeDate() 7 | const to = new RangeDate().advance('weeks', 4) 8 | 9 | const defaultState = Map({ 10 | range: new DateRange(from, to), 11 | rangeDidChange: false 12 | }) 13 | 14 | export default (state = defaultState, action) => { 15 | let newRange 16 | 17 | switch(action.type) { 18 | case 'setRange': 19 | return state.setIn(['range'], action.range) 20 | case 'advanceRange': 21 | newRange = state.get('range').advance() 22 | return state.withMutations(map => { 23 | map.set('range', newRange). 24 | set('rangeDidChange', true) 25 | }) 26 | case 'retardRange': 27 | newRange = state.get('range').advance(true) 28 | return state.withMutations(map => { 29 | map.set('range', newRange). 30 | set('rangeDidChange', true) 31 | }) 32 | case 'clearRangeFlag': 33 | return state.setIn(['rangeDidChange'], false) 34 | default: 35 | return state 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/resources.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React from 'react' 3 | 4 | // Styles 5 | import { resourceWrapper, resourceSideBar } from './styles' 6 | 7 | export default ({ width, resources, height }) => ( 8 |
9 | { resources.map(resource => ( 10 |
14 | {resource} 15 |
16 | )) 17 | } 18 |
19 | ) 20 | -------------------------------------------------------------------------------- /src/scheduler.jsx: -------------------------------------------------------------------------------- 1 | // Vendor Libraries 2 | import React, { Component } from 'react' 3 | import PropTypes from 'prop-types'; 4 | import { Provider } from 'react-redux' 5 | import { createStore, applyMiddleware } from 'redux' 6 | import { batchActions, enableBatching } from 'redux-batched-actions' 7 | 8 | // Local Libraries 9 | import RangeDate from './range_date' 10 | import DateRange from './date_range' 11 | import Layout from './layout' 12 | import reducers from './reducers' 13 | 14 | // Actions 15 | import { createCells } from './actions/cells' 16 | import { replaceResources, replaceEvents } from './actions/events' 17 | import { setRange } from './actions/range' 18 | 19 | // Promise Middleware 20 | const promiseMiddleware = store => next => action => { 21 | const { callback, nextAction, type, ...rest } = action 22 | if (!nextAction && !callback) { 23 | return next(action) 24 | } 25 | 26 | var p = new Promise((resolve) => { 27 | next({ ...rest, type: type }) 28 | resolve(store.getState()) 29 | }) 30 | 31 | p.then(state => { 32 | if (nextAction) { 33 | next({ ...rest, type: action.nextAction, range: state.range.toJS().range, resources: state.event.toJS().resources }) 34 | } else { 35 | let id = action.event.id; 36 | let index = state.event.get('events').findIndex(i => i.get('id') === id); 37 | 38 | callback(state.event.getIn(['events', index]).toJS()); 39 | } 40 | }). 41 | catch(ex => { 42 | next({ ...rest, ex, type: type + '_FAILURE' }) 43 | throw new Error(ex) 44 | }) 45 | } 46 | 47 | // Create the store 48 | const createStoreWithMiddleware = applyMiddleware(promiseMiddleware)(createStore) 49 | const store = createStoreWithMiddleware(enableBatching(reducers)) 50 | 51 | export default class Scheduler extends Component { 52 | static propTypes = { 53 | resources: PropTypes.array.isRequired, 54 | events: PropTypes.array.isRequired, 55 | from: PropTypes.oneOfType([ 56 | PropTypes.string, 57 | PropTypes.object 58 | ]), 59 | to: PropTypes.oneOfType([ 60 | PropTypes.string, 61 | PropTypes.object 62 | ]), 63 | rowHeight: PropTypes.number, 64 | width: PropTypes.number.isRequired, 65 | onEventChanged: PropTypes.func, 66 | onEventResized: PropTypes.func, 67 | onEventClicked: PropTypes.func, 68 | onCellClicked: PropTypes.func, 69 | onRangeChanged: PropTypes.func 70 | } 71 | 72 | static defaultProps = { 73 | from: new RangeDate(), 74 | to: new RangeDate().advance('weeks', 4), 75 | rowHeight: 30, 76 | selectorStyles: {}, 77 | chartStyles: {} 78 | } 79 | 80 | componentWillMount() { 81 | const { resources, from, to } = this.props, 82 | range = new DateRange(from, to) 83 | 84 | this.initializeStore(this.props) 85 | store.dispatch(createCells(resources, range)) 86 | } 87 | 88 | componentWillReceiveProps(nextProps) { 89 | this.initializeStore(nextProps) 90 | } 91 | 92 | initializeStore(props) { 93 | const { resources, events, from, to } = props, 94 | range = new DateRange(from, to) 95 | 96 | store.dispatch(batchActions([ 97 | setRange(range), 98 | replaceResources(resources), 99 | replaceEvents(events) 100 | ])) 101 | } 102 | 103 | fireEventChanged = (props) => { 104 | const { onEventChanged } = this.props, 105 | { id, title, startDate, duration, resource, disabled } = props 106 | if (onEventChanged) onEventChanged({ id, title, startDate, duration, resource, disabled }) 107 | } 108 | 109 | fireEventResized = (props) => { 110 | const { onEventResized } = this.props, 111 | { id, title, startDate, duration, resource, disabled } = props 112 | if (onEventResized) onEventResized({ id, title, startDate, duration, resource, disabled }) 113 | } 114 | 115 | fireEventClicked = (props) => { 116 | const { onEventClicked } = this.props, 117 | { id, title, startDate, duration, resource, disabled } = props 118 | if (onEventClicked) onEventClicked({ id, title, startDate, duration, resource, disabled }) 119 | } 120 | 121 | fireCellClicked = (resource, date) => { 122 | const { onCellClicked } = this.props 123 | if (onCellClicked) onCellClicked(resource, date) 124 | } 125 | 126 | fireRangeChanged = (range) => { 127 | const { onRangeChanged } = this.props 128 | if (onRangeChanged) onRangeChanged(range) 129 | } 130 | 131 | render() { 132 | return ( 133 | 134 | 142 | 143 | ) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | export const selectors = { 2 | textAlign: 'center', 3 | margin: '25px 0' 4 | } 5 | 6 | export const leftButton = { 7 | position: 'relative', 8 | marginRight: '10px', 9 | display: 'inline-block', 10 | width: '2em', 11 | height: '2em', 12 | border: '0.25em solid darkgrey', 13 | borderRadius: '50%', 14 | verticalAlign: 'middle' 15 | } 16 | 17 | export const leftButtonAfter = { 18 | position: 'absolute', 19 | display: 'inline-block', 20 | top: '0.4em', 21 | left: '0.5em', 22 | width: '0.7em', 23 | height: '0.7em', 24 | borderTop: '0.25em solid darkgrey', 25 | borderRight: '0.25em solid darkgrey', 26 | transform: 'rotate(-135deg)' 27 | } 28 | 29 | export const rightButton = { 30 | position: 'relative', 31 | marginLeft: '10px', 32 | display: 'inline-block', 33 | width: '2em', 34 | height: '2em', 35 | border: '0.25em solid darkgrey', 36 | borderRadius: '50%', 37 | verticalAlign: 'middle' 38 | } 39 | 40 | export const rightButtonAfter = { 41 | position: 'absolute', 42 | display: 'inline-block', 43 | top: '0.4em', 44 | right: '0.5em', 45 | width: '0.7em', 46 | height: '0.7em', 47 | borderTop: '0.25em solid darkgrey', 48 | borderLeft: '0.25em solid darkgrey', 49 | transform: 'rotate(135deg)' 50 | } 51 | 52 | export const chartHeader = { 53 | border: 'solid 1px darkgrey', 54 | margin: '0 -1px -1px 0', 55 | padding: '0 4px', 56 | flexGrow: 0 57 | } 58 | 59 | export const headerWrapper = { 60 | borderRight: 'solid 1px darkgrey' 61 | } 62 | 63 | export const resourceSideBar = { 64 | border: 'solid 1px darkgrey', 65 | margin: '0 -1px -1px 0', 66 | textAlign: 'center', 67 | zIndex: 99, 68 | backgroundColor: '#FFF' 69 | } 70 | 71 | export const cell = { 72 | width: '100%', 73 | height: '100%', 74 | backgroundColor: 'transparent', 75 | display: 'flex', 76 | alignItems: 'center' 77 | } 78 | 79 | export const chart = { 80 | display: 'flex', 81 | flexWrap: 'wrap', 82 | borderBottom: 'solid 1px darkgrey', 83 | borderRight: 'solid 1px darkgrey' 84 | } 85 | 86 | export const cellWrapper = { 87 | margin: '0 -1px -1px 0', 88 | border: 'solid 1px darkgrey' 89 | } 90 | 91 | export const resourceWrapper = { 92 | display: 'flex', 93 | flexDirection: 'column' 94 | } 95 | 96 | export const eventStyles = { 97 | position: 'relative', 98 | top: 0, 99 | left: '4px', 100 | borderRadius: '3px', 101 | padding: '2px 5px' 102 | } 103 | 104 | export const partialEventStyles = { 105 | position: 'absolute', 106 | top: 0, 107 | right: '4px', 108 | borderRadius: '3px', 109 | padding: '2px 5px', 110 | textAlign: 'right' 111 | } 112 | 113 | export const resizerStyles = { 114 | top: 0, 115 | right: 0, 116 | width: '5px', 117 | display: 'inline-block', 118 | position: 'absolute' 119 | } 120 | 121 | export const boxStyles = { 122 | position: 'relative', 123 | borderRadius: '3px' 124 | } 125 | 126 | export const eventHandleStyles = { 127 | position: 'absolute', 128 | top: 0, 129 | left: 0, 130 | height: '100%', 131 | width: 30, 132 | display: 'inline-block' 133 | } 134 | -------------------------------------------------------------------------------- /tests/date_range.jsx: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import DateRange from '../src/date_range' 3 | import RangeDate from '../src/range_date' 4 | 5 | describe('DateRange', () => { 6 | describe('initialize', () => { 7 | it('should accept an instance of range date in the constructor', () => { 8 | let from = new RangeDate(2015, 8, 1), 9 | to = new RangeDate(2015, 9, 1), 10 | range = new DateRange(from, to) 11 | 12 | assert(range.from instanceof RangeDate) 13 | assert(range.to instanceof RangeDate) 14 | }) 15 | }) 16 | 17 | describe('#toString', () => { 18 | it('should convert the range to a formatted string', () => { 19 | let range = new DateRange(new Date(2015, 8, 1), new Date(2015, 9, 1)) 20 | 21 | assert.equal("September 1, 2015 - October 1, 2015", range.toString()) 22 | }) 23 | }) 24 | 25 | describe('#advance', () => { 26 | it('should advance the date range', () => { 27 | let range = new DateRange("2015-09-01", "2015-09-15") 28 | 29 | assert.equal("September 16, 2015 - September 30, 2015", range.advance().toString()) 30 | }) 31 | 32 | it('should advance the date range for the number of days in the range', () => { 33 | let range = new DateRange("2015-09-01", "2015-09-04") 34 | 35 | assert.equal("September 5, 2015 - September 8, 2015", range.advance().toString()) 36 | }) 37 | }) 38 | 39 | describe('#daysInRange', () => { 40 | it('should return the number of days in the range', () => { 41 | let range = new DateRange("2016-01-01", "2016-01-03") 42 | 43 | assert.equal(3, range.daysInRange()) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /tests/range_date.jsx: -------------------------------------------------------------------------------- 1 | import RangeDate from '../src/range_date' 2 | import assert from 'assert' 3 | import timekeeper from 'timekeeper' 4 | import moment from 'moment-timezone' 5 | 6 | describe('RangeDate', () => { 7 | describe('initialize', () => { 8 | it('should accept an instance of itself', () => { 9 | let date = new RangeDate(new Date(2015, 8, 1)), 10 | date2 = new RangeDate(date) 11 | 12 | assert(+(moment(new Date(2015, 8, 1))) === +date2.value()) 13 | }) 14 | 15 | it('should set the date to now if undefined is passed in', () => { 16 | let now = new Date() 17 | timekeeper.freeze(now) 18 | let date = new RangeDate() 19 | 20 | assert(+now === +date.value()) 21 | timekeeper.reset() 22 | }) 23 | 24 | it('should set the date to now if null is passed in', () => { 25 | let now = new Date() 26 | timekeeper.freeze(now) 27 | let date = new RangeDate(null) 28 | 29 | assert(+now === +date.value()) 30 | timekeeper.reset() 31 | }) 32 | }) 33 | 34 | describe('#toString', () => { 35 | let date = new RangeDate(new Date(2015, 8, 1)) 36 | 37 | it('should convert the range date to a formatted string', () => { 38 | assert.equal("September 1, 2015", date.toString()) 39 | }) 40 | }) 41 | 42 | describe('#advance', () => { 43 | it('should properly advance the date by days', () => { 44 | let date = new RangeDate(new Date(2015, 8, 1)) 45 | assert.equal(+(new Date(2015, 8, 15)), +date.advance('days', 14).value()) 46 | }) 47 | 48 | it('should properly advance the date by weeks', () => { 49 | let date = new RangeDate(new Date(2015, 8, 1)) 50 | assert.equal(+(new Date(2015, 8, 15)), +date.advance('weeks', 2).value()) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /tests/scheduler.jsx: -------------------------------------------------------------------------------- 1 | import Test from 'legit-tests' 2 | import { expect } from 'chai' 3 | 4 | import Scheduler from '../src/scheduler' 5 | import RangeDate from '../src/range_date' 6 | 7 | describe('Scheduler component', () => { 8 | describe('initialize', () => { 9 | it('should create a default range of the next two weeks if no range is defined', () => { 10 | let expectedFrom = new RangeDate(new Date()), 11 | expectedTo = expectedFrom.advance('weeks', 4) 12 | 13 | Test(). 14 | test(({ instance }) => { 15 | expect(instance.props.from.toString()).to.eq(expectedFrom.toString()) 16 | expect(instance.props.to.toString()).to.eq(expectedTo.toString()) 17 | }) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | /* globals global */ 2 | 3 | require('babel-core/register')(); 4 | 5 | function propagateToGlobal (window) { 6 | for (let key in window) { 7 | if (!window.hasOwnProperty(key)) continue 8 | if (key in global) continue 9 | 10 | global[key] = window[key] 11 | } 12 | } 13 | 14 | var jsdom = require('jsdom'); 15 | 16 | var doc = jsdom.jsdom(''); 17 | var win = doc.defaultView; 18 | 19 | global.document = doc; 20 | global.window = win; 21 | 22 | propagateToGlobal(win); 23 | --------------------------------------------------------------------------------