├── config ├── flow │ ├── css.js.flow │ └── file.js.flow ├── env.js ├── polyfills.js ├── babel.dev.js ├── babel.prod.js ├── webpack.config.umd.js ├── paths.js ├── eslint.js ├── webpack.config.dev.js └── webpack.config.prod.js ├── src ├── types.js ├── index.js ├── decorators │ ├── index.js │ ├── ListPreview │ │ ├── propTypes.js │ │ └── index.js │ ├── ItemPreview │ │ ├── propTypes.js │ │ └── index.js │ ├── Item │ │ ├── propTypes.js │ │ └── index.js │ └── List │ │ ├── propTypes.js │ │ └── index.js ├── demo │ ├── App.css │ ├── index.js │ ├── utils │ │ └── generateLists.js │ ├── List.js │ └── App.js ├── PureComponent.js ├── DragLayer │ ├── propTypes.js │ └── index.js ├── SortableList │ ├── dragSpec.js │ ├── propTypes.js │ ├── itemCache.js │ ├── dropSpec.js │ └── index.js ├── SortableItem │ ├── propTypes.js │ ├── dragSpec.js │ ├── index.js │ └── dropSpec.js ├── Kanban │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── updateLists.test.js.snap │ │ └── updateLists.test.js │ ├── propTypes.js │ ├── updateLists.js │ └── index.js ├── propTypes.js └── styles.css ├── .travis.yml ├── .gitignore ├── .babelrc ├── index.html ├── scripts ├── utils │ ├── WatchMissingNodeModulesPlugin.js │ ├── chrome.applescript │ └── prompt.js ├── build.js └── start.js ├── LICENSE.md ├── CHANGELOG.md ├── README.md └── package.json /config/flow/css.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | -------------------------------------------------------------------------------- /config/flow/file.js.flow: -------------------------------------------------------------------------------- 1 | // @flow 2 | declare export default string; 3 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | export const ROW_TYPE = 'row'; 2 | export const LIST_TYPE = 'list'; 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "5" 5 | - "4" 6 | 7 | before_install: 8 | - npm install -g npm@3 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Kanban from './Kanban'; 2 | import * as decorators from './decorators'; 3 | 4 | export { 5 | decorators, 6 | Kanban as VirtualKanban 7 | }; 8 | -------------------------------------------------------------------------------- /src/decorators/index.js: -------------------------------------------------------------------------------- 1 | export Item from './Item'; 2 | export ItemPreview from './ItemPreview'; 3 | export List from './List'; 4 | export ListPreview from './ListPreview'; 5 | -------------------------------------------------------------------------------- /src/demo/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | background-color: #21a5b8; 6 | } 7 | 8 | .KanbanWrapper { 9 | width: 100vw; 10 | height: 100vh; 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # production 7 | build 8 | dist 9 | lib 10 | 11 | # misc 12 | .DS_Store 13 | npm-debug.log 14 | 15 | # doc 16 | TODO.md 17 | RELEASE_STEPS.md 18 | -------------------------------------------------------------------------------- /src/PureComponent.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import shallowCompare from 'react-addons-shallow-compare'; 3 | 4 | export default class PureComponent extends Component { 5 | shouldComponentUpdate(nextProps, nextState) { 6 | return shallowCompare(this, nextProps, nextState); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/decorators/ListPreview/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes } from '../../propTypes'; 3 | 4 | export const list = PropTypes.object.isRequired; 5 | export const listId = CustomPropTypes.id.isRequired; 6 | export const listStyle = PropTypes.object.isRequired; 7 | export const isGhost = PropTypes.bool.isRequired; 8 | -------------------------------------------------------------------------------- /src/decorators/ItemPreview/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes } from '../../propTypes'; 3 | 4 | export const row = PropTypes.object.isRequired; 5 | export const rowId = CustomPropTypes.id.isRequired; 6 | export const rowStyle = PropTypes.object.isRequired; 7 | export const containerWidth = PropTypes.number.isRequired; 8 | export const isGhost = PropTypes.bool.isRequired; 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["latest", "react"], 3 | "plugins": [ 4 | "babel-plugin-transform-export-extensions", 5 | "babel-plugin-transform-class-properties", 6 | "babel-plugin-transform-object-rest-spread", 7 | "babel-plugin-transform-regenerator", 8 | "babel-plugin-transform-runtime", 9 | "babel-plugin-transform-react-constant-elements", 10 | "babel-plugin-transform-react-remove-prop-types", 11 | "babel-plugin-transform-remove-console" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/DragLayer/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | 3 | export const lists = PropTypes.array; 4 | export const item = PropTypes.object; 5 | export const itemType = PropTypes.string; 6 | export const currentOffset = PropTypes.shape({ 7 | x: PropTypes.number.isRequired, 8 | y: PropTypes.number.isRequire, 9 | }); 10 | export const isDragging = PropTypes.bool.isRequired; 11 | export const itemPreviewComponent = PropTypes.func.isRequired; 12 | export const listPreviewComponent = PropTypes.func.isRequired; 13 | -------------------------------------------------------------------------------- /src/decorators/Item/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes } from '../../propTypes'; 3 | 4 | export const row = PropTypes.object.isRequired; 5 | export const rowId = CustomPropTypes.id.isRequired; 6 | export const listId = CustomPropTypes.id.isRequired; 7 | export const isDragging = PropTypes.bool.isRequired; 8 | export const rowStyle = PropTypes.object.isRequired; 9 | export const connectDragSource = PropTypes.func.isRequired; 10 | export const connectDropTarget = PropTypes.func.isRequired; 11 | -------------------------------------------------------------------------------- /src/SortableList/dragSpec.js: -------------------------------------------------------------------------------- 1 | export function beginDrag(props) { 2 | const data = { 3 | list: props.list, 4 | listId: props.listId, 5 | listStyle: props.listStyle 6 | }; 7 | 8 | props.dragBeginList(data); 9 | 10 | return data; 11 | } 12 | 13 | export function endDrag(props, monitor) { 14 | const { listId } = props; 15 | 16 | props.dragEndList({listId}); 17 | } 18 | 19 | export function isDragging({ listId }, monitor) { 20 | const draggingListId = monitor.getItem().listId; 21 | 22 | return listId === draggingListId; 23 | } 24 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 2 | // injected into the application via DefinePlugin in Webpack configuration. 3 | 4 | var REACT_APP = /^REACT_APP_/i; 5 | var NODE_ENV = JSON.stringify(process.env.NODE_ENV || 'development'); 6 | 7 | module.exports = Object 8 | .keys(process.env) 9 | .filter(key => REACT_APP.test(key)) 10 | .reduce((env, key) => { 11 | env['process.env.' + key] = JSON.stringify(process.env[key]); 12 | return env; 13 | }, { 14 | 'process.env.NODE_ENV': NODE_ENV 15 | }); 16 | -------------------------------------------------------------------------------- /src/decorators/List/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes, deprecate } from '../../propTypes'; 3 | 4 | export const list = PropTypes.object.isRequired; 5 | export const listId = CustomPropTypes.id.isRequired; 6 | export const listStyle = PropTypes.object.isRequired; 7 | export const rows = deprecate(PropTypes.array, '`rows` is deprecated. Use `list.rows` instead'); 8 | export const children = PropTypes.node; 9 | export const isDragging = PropTypes.bool.isRequired; 10 | export const connectDragSource = PropTypes.func.isRequired; 11 | export const connectDropTarget = PropTypes.func.isRequired; 12 | -------------------------------------------------------------------------------- /config/polyfills.js: -------------------------------------------------------------------------------- 1 | if (typeof Promise === 'undefined') { 2 | // Rejection tracking prevents a common issue where React gets into an 3 | // inconsistent state due to an error, but it gets swallowed by a Promise, 4 | // and the user has no idea what causes React's erratic future behavior. 5 | require('promise/lib/rejection-tracking').enable(); 6 | window.Promise = require('promise/lib/es6-extensions.js'); 7 | } 8 | 9 | // fetch() polyfill for making API calls. 10 | require('whatwg-fetch'); 11 | 12 | // Object.assign() is commonly used with React. 13 | // It will use the native implementation if it's present and isn't buggy. 14 | Object.assign = require('object-assign'); 15 | -------------------------------------------------------------------------------- /src/demo/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Perf from 'react-addons-perf'; 4 | 5 | import '../../lib/styles.css'; 6 | 7 | import { generateLists } from './utils/generateLists'; 8 | 9 | import App from './App'; 10 | 11 | window.Perf = Perf; 12 | 13 | function getLists() { 14 | const lists = window.localStorage.getItem('lists'); 15 | 16 | return JSON.parse(lists) || generateLists(20, 50); 17 | } 18 | 19 | function setLists(lists) { 20 | window.localStorage.setItem('lists', JSON.stringify(lists)); 21 | } 22 | 23 | ReactDOM.render( 24 | , 25 | document.getElementById('root') 26 | ); 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Virtual Kanban 7 | 8 | 9 |
10 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/SortableItem/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes } from '../propTypes'; 3 | 4 | export const row = PropTypes.object; 5 | export const rowId = CustomPropTypes.id.isRequired; 6 | export const listId = CustomPropTypes.id.isRequired; 7 | export const rowStyle = PropTypes.object; 8 | export const itemComponent = PropTypes.func; 9 | export const moveRow = PropTypes.func; 10 | export const dragEndRow = PropTypes.func; 11 | export const dropRow = PropTypes.func; 12 | // React DnD 13 | export const isDragging = PropTypes.bool; 14 | export const connectDropTarget = PropTypes.func; 15 | export const connectDragSource = PropTypes.func; 16 | export const connectDragPreview = PropTypes.func; 17 | -------------------------------------------------------------------------------- /src/demo/utils/generateLists.js: -------------------------------------------------------------------------------- 1 | function generateRandom(count) { 2 | return Array.from({length: count}, (_, i) => { 3 | return { 4 | id: i, 5 | name: `${i}`, 6 | lastModified: Date.now(), 7 | }; 8 | }); 9 | } 10 | 11 | export function generateLists(count, rowsPerList) { 12 | let rows; 13 | 14 | console.time('rows generation'); 15 | rows = generateRandom(count * rowsPerList); 16 | 17 | const lists = rows.reduce((memo, row, i) => { 18 | let group = memo[i % count]; 19 | 20 | if (!group) { 21 | group = memo[i % count] = {id: i, rows: []}; 22 | } 23 | 24 | group.rows.push(row); 25 | 26 | return memo; 27 | }, []); 28 | 29 | console.timeEnd('rows generation'); 30 | 31 | return lists; 32 | } 33 | -------------------------------------------------------------------------------- /src/decorators/ListPreview/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as propTypes from './propTypes'; 4 | 5 | import PureComponent from '../../PureComponent'; 6 | 7 | export default class ListPreview extends PureComponent { 8 | static propTypes = propTypes; 9 | 10 | render() { 11 | const { listId, listStyle, isGhost } = this.props; 12 | const { width, height } = listStyle; 13 | 14 | return ( 15 |
16 |
17 |
18 | List {listId} 19 |
20 |
21 |
22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/decorators/ItemPreview/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import * as propTypes from './propTypes'; 4 | 5 | import PureComponent from '../../PureComponent'; 6 | 7 | export default class ItemPreview extends PureComponent { 8 | static propTypes = propTypes; 9 | 10 | render() { 11 | // TODO: Grab a proper item width 12 | const { row, rowStyle, containerWidth: width, isGhost } = this.props; 13 | const { height } = rowStyle; 14 | 15 | return ( 16 |
17 |
18 |
19 |

{row.name}

20 |
21 |
22 |
23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Kanban/__tests__/__snapshots__/updateLists.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`move between lists 1`] = `"[{\\"id\\":1,\\"rows\\":[{\\"id\\":1}]},{\\"id\\":2,\\"rows\\":[{\\"id\\":2},{\\"id\\":3},{\\"id\\":4}]}]"`; 4 | 5 | exports[`move item from a list with a single element 1`] = `"[{\\"id\\":1,\\"rows\\":[{\\"id\\":1},{\\"id\\":3},{\\"id\\":2}]},{\\"id\\":2,\\"rows\\":[]}]"`; 6 | 7 | exports[`move item inside same list 1`] = `"[{\\"id\\":1,\\"rows\\":[{\\"id\\":2},{\\"id\\":1}]},{\\"id\\":2,\\"rows\\":[{\\"id\\":3},{\\"id\\":4}]}]"`; 8 | 9 | exports[`move item to an empty list 1`] = `"[{\\"id\\":1,\\"rows\\":[{\\"id\\":2}]},{\\"id\\":2,\\"rows\\":[{\\"id\\":1}]}]"`; 10 | 11 | exports[`move lists 1`] = `"[{\\"id\\":2,\\"rows\\":[{\\"id\\":3},{\\"id\\":4}]},{\\"id\\":1,\\"rows\\":[{\\"id\\":1},{\\"id\\":2}]}]"`; 12 | -------------------------------------------------------------------------------- /src/decorators/Item/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import * as propTypes from './propTypes'; 5 | 6 | import PureComponent from '../../PureComponent'; 7 | 8 | export default class Item extends PureComponent { 9 | static propTypes = propTypes; 10 | 11 | render() { 12 | const { row, rowStyle, connectDragSource, connectDropTarget, isDragging } = this.props; 13 | 14 | const itemContainerClass = classnames({ 15 | 'ItemContainer': true, 16 | 'ItemPlaceholder': isDragging, 17 | }); 18 | 19 | return connectDragSource(connectDropTarget( 20 |
21 |
22 |
23 |

{row.name}

24 |
25 |
26 |
27 | )); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/SortableList/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | import { PropTypes as CustomPropTypes } from '../propTypes'; 3 | 4 | export const list = PropTypes.object; 5 | export const listId = CustomPropTypes.id.isRequired; 6 | export const listStyle = PropTypes.object; 7 | export const listComponent = PropTypes.func; 8 | export const itemComponent = PropTypes.func; 9 | export const moveRow = PropTypes.func; 10 | export const moveList = PropTypes.func; 11 | export const dropRow = PropTypes.func; 12 | export const dropList = PropTypes.func; 13 | export const dragEndRow = PropTypes.func; 14 | export const overscanRowCount = PropTypes.number; 15 | export const itemCacheKey = PropTypes.func; 16 | // React DnD 17 | export const isDragging = PropTypes.bool; 18 | export const connectDropTarget = PropTypes.func; 19 | export const connectDragSource = PropTypes.func; 20 | export const connectDragPreview = PropTypes.func; 21 | -------------------------------------------------------------------------------- /src/Kanban/propTypes.js: -------------------------------------------------------------------------------- 1 | import { PropTypes } from 'react'; 2 | 3 | export const lists = PropTypes.array; 4 | export const width = PropTypes.number; 5 | export const listWidth = PropTypes.number; 6 | export const height = PropTypes.number; 7 | export const listComponent = PropTypes.func; 8 | export const itemComponent = PropTypes.func; 9 | export const itemPreviewComponent = PropTypes.func; 10 | export const listPreviewComponent = PropTypes.func; 11 | export const onMoveRow = PropTypes.func; 12 | export const onMoveList = PropTypes.func; 13 | export const onDropRow = PropTypes.func; 14 | export const onDropList = PropTypes.func; 15 | export const onDragEndRow = PropTypes.func; 16 | export const overscanListCount = PropTypes.number; 17 | export const overscanRowCount = PropTypes.number; 18 | export const scrollToList = PropTypes.number; 19 | export const scrollToAlignment = PropTypes.string; 20 | export const itemCacheKey = PropTypes.func; 21 | -------------------------------------------------------------------------------- /src/SortableList/itemCache.js: -------------------------------------------------------------------------------- 1 | // Cache singleton 2 | const cachedItems = new Map(); 3 | 4 | export class ItemCache { 5 | constructor(items, cacheKey, store = cachedItems) { 6 | this.items = items; 7 | this.cacheKey = cacheKey; 8 | this.store = store; 9 | } 10 | 11 | clearAllRowHeights() { 12 | this.store.clear(); 13 | } 14 | 15 | clearRowHeight(index) { 16 | const item = this.items[index]; 17 | 18 | this.store.delete(this.cacheKey(item)); 19 | } 20 | 21 | getRowHeight(index) { 22 | const item = this.items[index]; 23 | 24 | return this.store.get(this.cacheKey(item)); 25 | } 26 | 27 | setRowHeight(index, height) { 28 | const item = this.items[index]; 29 | 30 | this.store.set(this.cacheKey(item), height); 31 | } 32 | 33 | // Not implemented 34 | 35 | clearAllColumnWidths() {} 36 | clearColumnWidth(index) {} 37 | getColumnWidth(index) {} 38 | setColumnWidth(index, width) {} 39 | } 40 | -------------------------------------------------------------------------------- /src/SortableItem/dragSpec.js: -------------------------------------------------------------------------------- 1 | import { findDOMNode } from 'react-dom'; 2 | import { width } from 'dom-helpers/query'; 3 | 4 | export function beginDrag(props, _, component) { 5 | const node = findDOMNode(component); 6 | const containerWidth = node ? width(node) : 0; 7 | 8 | const data = { 9 | lists: props.lists, 10 | row: props.row, 11 | rowId: props.rowId, 12 | rowStyle: props.rowStyle, 13 | containerWidth, 14 | }; 15 | 16 | props.dragBeginRow(data); 17 | 18 | return data; 19 | } 20 | 21 | export function endDrag(props, monitor) { 22 | const { rowId: itemId } = props; 23 | 24 | props.dragEndRow({itemId}); 25 | } 26 | 27 | /** 28 | * Determines whether current item is being dragged or not. 29 | * 30 | * This is the logic used to display the gaps (gray items) in the list. 31 | */ 32 | export function isDragging({ rowId }, monitor) { 33 | const draggingRowId = monitor.getItem().rowId; 34 | 35 | return rowId === draggingRowId; 36 | } 37 | -------------------------------------------------------------------------------- /src/propTypes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const PropTypes = { 4 | id: React.PropTypes.oneOfType([ 5 | React.PropTypes.string, 6 | React.PropTypes.number, 7 | React.PropTypes.symbol 8 | ]), 9 | 10 | decorator: React.PropTypes.func, 11 | }; 12 | 13 | /** 14 | * Wraps a singular React.PropTypes.[type] with 15 | * a console.warn call that is only called if the 16 | * prop is not undefined/null and is only called 17 | * once. 18 | * @param {Object} propType React.PropType type 19 | * @param {String} message Deprecation message 20 | * @return {Function} ReactPropTypes checkType 21 | */ 22 | export function deprecate(propType, message) { 23 | let warned = false; 24 | return function(...args) { 25 | const [props, propName] = args; 26 | const prop = props[propName]; 27 | if (prop !== undefined && prop !== null && !warned) { 28 | warned = true; 29 | console.warn(message); 30 | } 31 | return propType.call(this, ...args); 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /scripts/utils/WatchMissingNodeModulesPlugin.js: -------------------------------------------------------------------------------- 1 | // This Webpack plugin ensures `npm install ` forces a project rebuild. 2 | // We’re not sure why this isn't Webpack's default behavior. 3 | // See https://github.com/facebookincubator/create-react-app/issues/186. 4 | 5 | function WatchMissingNodeModulesPlugin(nodeModulesPath) { 6 | this.nodeModulesPath = nodeModulesPath; 7 | } 8 | 9 | WatchMissingNodeModulesPlugin.prototype.apply = function (compiler) { 10 | compiler.plugin('emit', (compilation, callback) => { 11 | var missingDeps = compilation.missingDependencies; 12 | var nodeModulesPath = this.nodeModulesPath; 13 | 14 | // If any missing files are expected to appear in node_modules... 15 | if (missingDeps.some(file => file.indexOf(nodeModulesPath) !== -1)) { 16 | // ...tell webpack to watch node_modules recursively until they appear. 17 | compilation.contextDependencies.push(nodeModulesPath); 18 | } 19 | 20 | callback(); 21 | }); 22 | } 23 | 24 | module.exports = WatchMissingNodeModulesPlugin; 25 | -------------------------------------------------------------------------------- /scripts/utils/chrome.applescript: -------------------------------------------------------------------------------- 1 | on run argv 2 | set theURL to item 1 of argv 3 | 4 | tell application "Chrome" 5 | 6 | if (count every window) = 0 then 7 | make new window 8 | end if 9 | 10 | -- Find a tab currently running the debugger 11 | set found to false 12 | set theTabIndex to -1 13 | repeat with theWindow in every window 14 | set theTabIndex to 0 15 | repeat with theTab in every tab of theWindow 16 | set theTabIndex to theTabIndex + 1 17 | if theTab's URL is theURL then 18 | set found to true 19 | exit repeat 20 | end if 21 | end repeat 22 | 23 | if found then 24 | exit repeat 25 | end if 26 | end repeat 27 | 28 | if found then 29 | tell theTab to reload 30 | set index of theWindow to 1 31 | set theWindow's active tab index to theTabIndex 32 | else 33 | tell window 1 34 | activate 35 | make new tab with properties {URL:theURL} 36 | end tell 37 | end if 38 | end tell 39 | end run 40 | -------------------------------------------------------------------------------- /scripts/utils/prompt.js: -------------------------------------------------------------------------------- 1 | var rl = require('readline'); 2 | 3 | // Convention: "no" should be the conservative choice. 4 | // If you mistype the answer, we'll always take it as a "no". 5 | // You can control the behavior on with `isYesDefault`. 6 | module.exports = function (question, isYesDefault) { 7 | if (typeof isYesDefault !== 'boolean') { 8 | throw new Error('Provide explicit boolean isYesDefault as second argument.'); 9 | } 10 | return new Promise(resolve => { 11 | var rlInterface = rl.createInterface({ 12 | input: process.stdin, 13 | output: process.stdout, 14 | }); 15 | 16 | var hint = isYesDefault === true ? '[Y/n]' : '[y/N]'; 17 | var message = question + ' ' + hint + '\n'; 18 | 19 | rlInterface.question(message, function(answer) { 20 | rlInterface.close(); 21 | 22 | var useDefault = answer.trim().length === 0; 23 | if (useDefault) { 24 | return resolve(isYesDefault); 25 | } 26 | 27 | var isYes = answer.match(/^(yes|y)$/i); 28 | return resolve(isYes); 29 | }); 30 | }); 31 | }; 32 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Eduardo Lanchares 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 | -------------------------------------------------------------------------------- /src/demo/List.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import { decorators } from '../../src'; 5 | 6 | export default class List extends decorators.List { 7 | render() { 8 | const { 9 | listId, 10 | style, 11 | connectDragSource, 12 | connectDropTarget, 13 | isDragging, 14 | children, 15 | } = this.props; 16 | 17 | let listContainerClass = classnames({ 18 | 'ListContainer': true, 19 | 'ListPlaceholder': isDragging 20 | }); 21 | 22 | return ( 23 |
24 |
25 | {connectDragSource( 26 |
27 | List {listId} 28 |
29 | )} 30 | {connectDropTarget( 31 |
32 | {children} 33 |
34 | )} 35 |
36 |
37 | 38 |
39 |
40 |
41 |
42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/decorators/List/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | 4 | import * as propTypes from './propTypes'; 5 | 6 | import PureComponent from '../../PureComponent'; 7 | 8 | export default class List extends PureComponent { 9 | static propTypes = propTypes; 10 | 11 | render() { 12 | const { 13 | list, 14 | listId, 15 | listStyle, 16 | connectDragSource, 17 | connectDropTarget, 18 | isDragging, 19 | children, 20 | } = this.props; 21 | 22 | let listContainerClass = classnames({ 23 | 'ListContainer': true, 24 | 'ListPlaceholder': isDragging 25 | }); 26 | 27 | return ( 28 |
29 |
30 | {connectDragSource( 31 |
32 | List {listId} ({list.rows.length}) 33 |
34 | )} 35 | {connectDropTarget( 36 |
37 | {children} 38 |
39 | )} 40 |
41 |
42 | 43 |
44 |
45 |
46 |
47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/babel.dev.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Don't try to find .babelrc because we want to force this configuration. 3 | babelrc: false, 4 | // This is a feature of `babel-loader` for webpack (not Babel itself). 5 | // It enables caching results in OS temporary directory for faster rebuilds. 6 | cacheDirectory: true, 7 | presets: [ 8 | // Latest stable ECMAScript features 9 | require.resolve('babel-preset-latest'), 10 | // JSX, Flow 11 | require.resolve('babel-preset-react') 12 | ], 13 | plugins: [ 14 | // displayName for ES6 classes 15 | require.resolve('babel-plugin-transform-class-display-name'), 16 | // export Foo from './Foo' 17 | require.resolve('babel-plugin-transform-export-extensions'), 18 | // class { handleClick = () => { } } 19 | require.resolve('babel-plugin-transform-class-properties'), 20 | // { ...todo, completed: true } 21 | require.resolve('babel-plugin-transform-object-rest-spread'), 22 | // function* () { yield 42; yield 43; } 23 | [require.resolve('babel-plugin-transform-regenerator'), { 24 | // Async functions are converted to generators by babel-preset-latest 25 | async: false 26 | }], 27 | // Polyfills the runtime needed for async/await and generators 28 | [require.resolve('babel-plugin-transform-runtime'), { 29 | helpers: false, 30 | polyfill: false, 31 | regenerator: true 32 | }] 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /config/babel.prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Don't try to find .babelrc because we want to force this configuration. 3 | babelrc: false, 4 | presets: [ 5 | // Latest stable ECMAScript features 6 | require.resolve('babel-preset-latest'), 7 | // JSX, Flow 8 | require.resolve('babel-preset-react') 9 | ], 10 | plugins: [ 11 | require.resolve('babel-plugin-transform-export-extensions'), 12 | // class { handleClick = () => { } } 13 | require.resolve('babel-plugin-transform-class-properties'), 14 | // { ...todo, completed: true } 15 | require.resolve('babel-plugin-transform-object-rest-spread'), 16 | // function* () { yield 42; yield 43; } 17 | [require.resolve('babel-plugin-transform-regenerator'), { 18 | // Async functions are converted to generators by babel-preset-latest 19 | async: false 20 | }], 21 | // Polyfills the runtime needed for async/await and generators 22 | [require.resolve('babel-plugin-transform-runtime'), { 23 | helpers: false, 24 | polyfill: false, 25 | regenerator: true 26 | }], 27 | // Optimization: hoist JSX that never changes out of render() 28 | require.resolve('babel-plugin-transform-react-constant-elements'), 29 | // Remove PropTypes declarations 30 | require.resolve('babel-plugin-transform-react-remove-prop-types'), 31 | // Remove console.log statements 32 | require.resolve('babel-plugin-transform-remove-console') 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /src/demo/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { AutoSizer } from 'react-virtualized'; 3 | 4 | import { VirtualKanban } from '../'; 5 | 6 | import './App.css'; 7 | 8 | const keyGenerator = ({ id, lastModified }) => `${id}-${lastModified}`; 9 | 10 | class App extends Component { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.state = { 15 | lists: props.getLists(), 16 | }; 17 | 18 | setInterval(() => { 19 | this.setState((prevState) => { 20 | if (prevState.lists[0].rows.length > 0) { 21 | this._initialLists = prevState.lists; 22 | return {lists: prevState.lists.map((list) => ({...list, rows: []}))}; 23 | } else { 24 | return {lists: this._initialLists.concat()}; 25 | } 26 | }); 27 | }, 3000); 28 | } 29 | 30 | render() { 31 | return ( 32 |
33 | 34 | {({ width, height }) => ( 35 | this.setState(() => ({lists}))} 42 | onMoveList={({ lists }) => this.setState(() => ({lists}))} 43 | /> 44 | )} 45 | 46 |
47 | ); 48 | } 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /src/SortableItem/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DragSource, DropTarget } from 'react-dnd'; 3 | import { getEmptyImage } from 'react-dnd-html5-backend'; 4 | 5 | import { ROW_TYPE } from '../types'; 6 | import * as dragSpec from './dragSpec'; 7 | import * as dropSpec from './dropSpec'; 8 | import * as propTypes from './propTypes'; 9 | 10 | import PureComponent from '../PureComponent'; 11 | 12 | class SortableItem extends PureComponent { 13 | static propTypes = propTypes; 14 | 15 | componentDidMount() { 16 | this.props.connectDragPreview(getEmptyImage(), { 17 | captureDraggingState: true 18 | }); 19 | } 20 | 21 | render() { 22 | const { 23 | row, 24 | rowId, 25 | listId, 26 | itemComponent: DecoratedItem, 27 | isDragging, 28 | connectDragSource, 29 | connectDropTarget, 30 | rowStyle, 31 | } = this.props; 32 | 33 | return ( 34 | 43 | ); 44 | } 45 | } 46 | 47 | const connectDrop = DropTarget(ROW_TYPE, dropSpec, connect => ({ 48 | connectDropTarget: connect.dropTarget() 49 | })) 50 | 51 | 52 | const connectDrag = DragSource(ROW_TYPE, dragSpec, (connect, monitor) => ({ 53 | connectDragSource: connect.dragSource(), 54 | connectDragPreview: connect.dragPreview(), 55 | isDragging: monitor.isDragging(), 56 | })) 57 | 58 | export default connectDrop(connectDrag(SortableItem)); 59 | -------------------------------------------------------------------------------- /config/webpack.config.umd.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | var paths = require('./paths'); 5 | 6 | const UglifyJsPlugin = webpack.optimize.UglifyJsPlugin; 7 | 8 | const libraryName = 'ReactVirtualKanban'; 9 | 10 | const plugins = [ 11 | new UglifyJsPlugin({ 12 | beautify: true, 13 | comments: true, 14 | mangle: false 15 | }), 16 | new ExtractTextPlugin('styles.css', { 17 | allChunks: false, 18 | beautify: true, 19 | mangle: false 20 | }) 21 | ]; 22 | 23 | var config = { 24 | devtool: 'source-map', 25 | entry: { 26 | 'react-virtual-kanban': path.join(paths.appSrc, 'index.js') 27 | }, 28 | output: { 29 | path: paths.appDist, 30 | filename: '[name].js', 31 | library: libraryName, 32 | libraryTarget: 'umd', 33 | }, 34 | // TODO: Should we declare externals? 35 | externals: { 36 | 'react': 'React', 37 | 'react-dom': 'ReactDOM', 38 | 'react-addons-shallow-compare': 'var React.addons.shallowCompare' 39 | }, 40 | module: { 41 | loaders: [ 42 | { 43 | test: /\.js$/, 44 | loader: 'babel', 45 | include: paths.appSrc, 46 | query: require('./babel.prod') 47 | }, 48 | { 49 | test: /\.css$/, 50 | include: [paths.appSrc, paths.appNodeModules], 51 | loader: ExtractTextPlugin.extract('style', 'css?-autoprefixer!postcss') 52 | }, 53 | { 54 | test: /\.js$/, 55 | loader: 'eslint-loader', 56 | include: paths.appSrc 57 | } 58 | ] 59 | }, 60 | // resolve: { 61 | // root: path.resolve('./src'), 62 | // extensions: ['', '.js'] 63 | // }, 64 | plugins: plugins 65 | }; 66 | 67 | module.exports = config; 68 | -------------------------------------------------------------------------------- /src/SortableList/dropSpec.js: -------------------------------------------------------------------------------- 1 | import { findDOMNode } from 'react-dom'; 2 | import { width, querySelectorAll } from 'dom-helpers/query'; 3 | 4 | import { LIST_TYPE, ROW_TYPE } from '../types'; 5 | 6 | function calculateContainerWidth(component) { 7 | const innerScrollContainer = querySelectorAll( 8 | findDOMNode(component), 9 | '.ReactVirtualized__Grid__innerScrollContainer' 10 | )[0]; 11 | 12 | if (!innerScrollContainer) return 0; 13 | 14 | return width(innerScrollContainer); 15 | } 16 | 17 | export function hover(props, monitor, component) { 18 | if (!monitor.isOver({shallow: true})) return; 19 | if (!monitor.canDrop()) return; 20 | 21 | const item = monitor.getItem(); 22 | const itemType = monitor.getItemType(); 23 | const { listId: dragListId } = item; 24 | const { listId: hoverListId } = props; 25 | 26 | if (dragListId === hoverListId) { 27 | return; 28 | } 29 | 30 | if (itemType === LIST_TYPE) { 31 | props.moveList({listId: dragListId}, {listId: hoverListId}); 32 | return; 33 | } 34 | 35 | if (itemType === ROW_TYPE) { 36 | const dragItemId = item.rowId; 37 | 38 | item.containerWidth = calculateContainerWidth(component) || item.containerWidth; 39 | 40 | props.moveRow( 41 | {itemId: dragItemId}, 42 | {listId: hoverListId} 43 | ); 44 | return; 45 | } 46 | } 47 | 48 | export function canDrop(props, monitor) { 49 | const item = monitor.getItem(); 50 | const itemType = monitor.getItemType(); 51 | 52 | if (itemType === LIST_TYPE) { 53 | return true; 54 | } 55 | 56 | if (itemType === ROW_TYPE) { 57 | return item.listId !== props.listId; 58 | } 59 | } 60 | 61 | export function drop(props, monitor) { 62 | if (!monitor.isOver({shallow: true})) return; 63 | 64 | const { listId } = props; 65 | 66 | props.dropList({listId}); 67 | } 68 | -------------------------------------------------------------------------------- /src/SortableItem/dropSpec.js: -------------------------------------------------------------------------------- 1 | import { findDOMNode } from 'react-dom'; 2 | import { width } from 'dom-helpers/query'; 3 | 4 | export function hover(props, monitor, component) { 5 | const item = monitor.getItem(); 6 | const { rowId: dragItemId } = item; 7 | const { rowId: hoverItemId, findItemIndex } = props; 8 | 9 | // Hovering over the same item 10 | if (dragItemId === hoverItemId) { 11 | return; 12 | } 13 | 14 | // Sometimes component may be null when it's been unmounted 15 | if (!component) { 16 | console.warn(`null component for #${dragItemId}`); 17 | return; 18 | } 19 | 20 | const dragItemIndex = findItemIndex(dragItemId); 21 | const hoverItemIndex = findItemIndex(hoverItemId); 22 | 23 | // In order to avoid swap flickering when dragging element is smaller than 24 | // dropping one, we check whether dropping middle has been reached or not. 25 | 26 | // Determine rectangle on screen 27 | const node = findDOMNode(component); 28 | const hoverBoundingRect = node.getBoundingClientRect(); 29 | 30 | // Get vertical middle 31 | const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; 32 | 33 | // Determine mouse position 34 | const clientOffset = monitor.getClientOffset(); 35 | 36 | // Get pixels to the top 37 | const hoverClientY = clientOffset.y - hoverBoundingRect.top; 38 | 39 | // Dragging downwards 40 | if (dragItemIndex < hoverItemIndex && hoverClientY < hoverMiddleY) { 41 | return; 42 | } 43 | 44 | // Dragging upwards 45 | if (dragItemIndex > hoverItemIndex && hoverClientY > hoverMiddleY) { 46 | return; 47 | } 48 | 49 | item.containerWidth = width(node); 50 | 51 | props.moveRow( 52 | {itemId: dragItemId}, 53 | {itemId: hoverItemId} 54 | ); 55 | } 56 | 57 | export function canDrop(props, monitor) { 58 | const item = monitor.getItem(); 59 | 60 | return item.rowId === props.rowId; 61 | } 62 | 63 | export function drop(props) { 64 | const { rowId: itemId } = props; 65 | 66 | props.dropRow({itemId}); 67 | } 68 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | // TODO: we can split this file into several files (pre-eject, post-eject, test) 2 | // and use those instead. This way we don't need to branch here. 3 | 4 | var path = require('path'); 5 | 6 | // True after ejecting, false when used as a dependency 7 | var isEjected = ( 8 | path.resolve(path.join(__dirname, '..')) === 9 | path.resolve(process.cwd()) 10 | ); 11 | 12 | // Are we developing create-react-app locally? 13 | var isInCreateReactAppSource = ( 14 | process.argv.some(arg => arg.indexOf('--debug-template') > -1) 15 | ); 16 | 17 | function resolveOwn(relativePath) { 18 | return path.resolve(__dirname, relativePath); 19 | } 20 | 21 | function resolveApp(relativePath) { 22 | return path.resolve(relativePath); 23 | } 24 | 25 | if (isInCreateReactAppSource) { 26 | // create-react-app development: we're in ./config/ 27 | module.exports = { 28 | appBuild: resolveOwn('../build'), 29 | appHtml: resolveOwn('../template/index.html'), 30 | appPackageJson: resolveOwn('../package.json'), 31 | appSrc: resolveOwn('../template/src'), 32 | appNodeModules: resolveOwn('../node_modules'), 33 | ownNodeModules: resolveOwn('../node_modules') 34 | }; 35 | } else if (!isEjected) { 36 | // before eject: we're in ./node_modules/react-scripts/config/ 37 | module.exports = { 38 | appBuild: resolveApp('build'), 39 | appHtml: resolveApp('index.html'), 40 | appPackageJson: resolveApp('package.json'), 41 | appSrc: resolveApp('src'), 42 | appLib: resolveApp('lib'), 43 | appDist: resolveApp('dist'), 44 | appNodeModules: resolveApp('node_modules'), 45 | // this is empty with npm3 but node resolution searches higher anyway: 46 | ownNodeModules: resolveOwn('../node_modules') 47 | }; 48 | } else { 49 | // after eject: we're in ./config/ 50 | module.exports = { 51 | appBuild: resolveApp('build'), 52 | appHtml: resolveApp('index.html'), 53 | appPackageJson: resolveApp('package.json'), 54 | appSrc: resolveApp('src'), 55 | appLib: resolveApp('lib'), 56 | appDist: resolveApp('dist'), 57 | appNodeModules: resolveApp('node_modules'), 58 | ownNodeModules: resolveApp('node_modules') 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/DragLayer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DragLayer } from 'react-dnd'; 3 | 4 | import * as ItemTypes from '../types'; 5 | import * as propTypes from './propTypes'; 6 | 7 | // TODO: Extract to utils dir 8 | import { findItemIndex, findListIndex } from '../Kanban/updateLists'; 9 | 10 | import PureComponent from '../PureComponent'; 11 | 12 | function getStyles({ currentOffset }) { 13 | if (!currentOffset) { 14 | return { 15 | display: 'none' 16 | }; 17 | } 18 | 19 | const { x, y } = currentOffset; 20 | const transform = `translate(${x}px, ${y}px)`; 21 | 22 | return { 23 | transform, 24 | }; 25 | } 26 | 27 | class KanbanDragLayer extends PureComponent { 28 | static propTypes = propTypes; 29 | 30 | constructor(props) { 31 | super(props); 32 | 33 | this.renderItem = this.renderItem.bind(this); 34 | } 35 | 36 | renderItem(type, item) { 37 | const { 38 | lists, 39 | itemPreviewComponent: ItemPreview, 40 | listPreviewComponent: ListPreview, 41 | } = this.props; 42 | 43 | switch (type) { 44 | case ItemTypes.ROW_TYPE: 45 | return ( 46 | 53 | ); 54 | case ItemTypes.LIST_TYPE: 55 | return ( 56 | 62 | ); 63 | default: 64 | return null; 65 | } 66 | } 67 | 68 | render() { 69 | const { item, itemType, isDragging } = this.props; 70 | 71 | if (!isDragging) { 72 | return null; 73 | } 74 | 75 | return ( 76 |
77 |
78 | {this.renderItem(itemType, item)} 79 |
80 |
81 | ); 82 | } 83 | } 84 | 85 | function collect(monitor) { 86 | return { 87 | item: monitor.getItem(), 88 | itemType: monitor.getItemType(), 89 | currentOffset: monitor.getSourceClientOffset(), 90 | isDragging: monitor.isDragging() 91 | }; 92 | } 93 | 94 | export default DragLayer(collect)(KanbanDragLayer); 95 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ### 0.2.0 5 | Added itemCacheKey prop to be able to specify a custom key for caching Item components. This comes handy if underlaying Item data changes (like a title) and needs to be re-rendered to reflect new height. 6 | 7 | Added `scrollToList` and `scrollToAlignment` to set kanban scroll to desired list. 8 | 9 | Fixed items not being refreshing when moving lists around. 10 | 11 | Updated package.json and added some badges to the README. 12 | 13 | ### 0.1.1 14 | `onDropRow` callback is now invoked only when dropping into a valid target. 15 | 16 | Defended against null component received when hovering over an unmounted row. 17 | 18 | Remove dragDropManager prop type warning. 19 | 20 | 21 | ### 0.1.0 22 | Added `overscanListCount` and `overscanRowCount` props to be able to configure the number of extra rendered items. You may use this if you want to tune up kanban performance. 23 | 24 | Use `id` as key value for SortableItem and SortableList components. This avoids wierd behaviour when changing component's internal state. 25 | 26 | ### 0.0.12 27 | Now `Kanban` component gets `DragDropManager` from context or creates a new one. This is useful for scenarios where react-dnd is used inside decorators. 28 | 29 | ### 0.0.11 30 | Added guard in SortableList component when list reference is not set. 31 | 32 | ### 0.0.10 33 | Fixed a bug that was causing out of index errors when moving an row between lists. 34 | 35 | Now when moving a row to a list, the row will be placed at the beginning of that list (previously was at the end). 36 | 37 | ### 0.0.9 38 | Updated PropTypes for List decorator. Also pass `rows` prop. 39 | 40 | ### 0.0.8 41 | Pass `containerWidth` for preview components 42 | 43 | Use explicit props for decorated components 44 | 45 | ### 0.0.7 46 | Use generated lib for demo application 47 | 48 | ### 0.0.6 49 | Added jest test runner :green_apple:. Also added tests for `updateList` module. 50 | 51 | Update param names for `moveRow` and `moveList` callbacks on `SortableList` component. 52 | 53 | Defend wierd out of bounds edge case when dragging too fast. Need more investigation though. 54 | 55 | Added travis ci. 56 | 57 | ### 0.0.5 58 | Pass `listIndex` to `onDropList` callback. 59 | 60 | ### 0.0.4 61 | Pass `rowIndex` and `listIndex` to `onDropRow` callback. 62 | 63 | ### 0.0.3 64 | Pass updated list to `onDropRow` and `onDropList` callbacks. 65 | 66 | ### 0.0.2 67 | Fixed missing dom-helpers dependency. 68 | 69 | ### 0.0.1 70 | Initial release. 71 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import 'react-virtualized/styles.css'; 2 | 3 | .KanbanDragLayer { 4 | position: fixed; 5 | pointer-events: none; 6 | z-index: 100; 7 | left: 0; 8 | top: 0; 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | .KanbanGrid { 14 | outline: none; 15 | } 16 | 17 | .KanbanList { 18 | outline: none; 19 | } 20 | 21 | /* ListPreview */ 22 | .ListPreviewWrapper { 23 | transform: rotate(4deg); 24 | } 25 | 26 | /* List */ 27 | .ListWrapper { 28 | padding: 8px 4px 8px 4px; 29 | margin: 0 4px; 30 | box-sizing: border-box; 31 | position: relative; 32 | } 33 | 34 | .ListContainer { 35 | border-radius: 3px; 36 | display: flex; 37 | flex-direction: column; 38 | color: #4d4d4d; 39 | background-color: #e6e6e6; 40 | height: 100%; 41 | align-items: stretch; 42 | } 43 | 44 | .ListPlaceholder { 45 | background-color: rgba(0, 0, 0, .2); 46 | } 47 | 48 | .ListPlaceholder .ListHeader, 49 | .ListPlaceholder .ListContent, 50 | .ListPlaceholder .ListFooter { 51 | opacity: 0; 52 | } 53 | 54 | .ListHeader { 55 | padding: 8px 10px; 56 | flex: 0 1 10px; 57 | /*flex-shrink: 0; 58 | flex-basis: auto;*/ 59 | } 60 | 61 | .ListContent { 62 | flex-grow: 1; 63 | flex-shrink: 1; 64 | flex-basis: auto; 65 | margin: 0 4px; 66 | padding: 0 4px; 67 | } 68 | 69 | .ListFooter { 70 | flex: 0 1 auto; 71 | /*flex-grow: 1; 72 | flex-shrink: 0; 73 | flex-basis: auto;*/ 74 | } 75 | 76 | .ListTitle { 77 | min-height: 20px; 78 | font-weight: bold; 79 | } 80 | 81 | .ListActions { 82 | padding: 8px 10px; 83 | display: flex; 84 | align-items: center; 85 | } 86 | 87 | .ListActionItem { 88 | background: none; 89 | border: medium none; 90 | cursor: pointer; 91 | font-size: 14px; 92 | color: #8c8c8c; 93 | } 94 | 95 | /* ItemPreview */ 96 | .ItemPreviewWrapper { 97 | transform: rotate(4deg); 98 | } 99 | 100 | /* Item */ 101 | .ItemWrapper { 102 | display: flex; 103 | flex-direction: column; 104 | height: 100%; 105 | width: 100%; 106 | padding: 5px; 107 | box-sizing: border-box; 108 | } 109 | 110 | .ItemContainer { 111 | padding: 6px 6px 2px 8px; 112 | width: 100%; 113 | height: 100%; 114 | border-color: darkgray; 115 | border-radius: 3px; 116 | background-color: white; 117 | flex: 1; 118 | box-shadow: 1px 1px 0px 0px #ccc; 119 | box-sizing: border-box; 120 | } 121 | 122 | .ItemPlaceholder { 123 | background-color: #C4C9CC; 124 | } 125 | 126 | .ItemPlaceholder .ItemContent { 127 | opacity: 0; 128 | } 129 | 130 | .ItemContent p { 131 | margin: 0; 132 | } 133 | -------------------------------------------------------------------------------- /src/Kanban/__tests__/updateLists.test.js: -------------------------------------------------------------------------------- 1 | import { updateLists } from '../updateLists'; 2 | 3 | const lists = [ 4 | { 5 | id: 1, 6 | rows: [ 7 | {id: 1}, 8 | {id: 2} 9 | ] 10 | }, 11 | { 12 | id: 2, 13 | rows: [ 14 | {id: 3}, 15 | {id: 4} 16 | ] 17 | } 18 | ]; 19 | 20 | test('move lists', () => { 21 | const updatedList = updateLists(lists, { 22 | from: {listId: 1}, 23 | to: {listId: 2} 24 | }); 25 | 26 | expect(JSON.stringify(updatedList)).toMatchSnapshot(); 27 | }); 28 | 29 | test('move item inside same list', () => { 30 | const updatedList = updateLists(lists, { 31 | from: {itemId: 1}, 32 | to: {itemId: 2} 33 | }); 34 | 35 | expect(JSON.stringify(updatedList)).toMatchSnapshot(); 36 | }); 37 | 38 | test('move between lists', () => { 39 | const updatedList = updateLists(lists, { 40 | from: {itemId: 2}, 41 | to: {itemId: 3} 42 | }); 43 | 44 | expect(JSON.stringify(updatedList)).toMatchSnapshot(); 45 | }); 46 | 47 | test('move item to an empty list', () => { 48 | const otherLists = [ 49 | { 50 | id: 1, 51 | rows: [ 52 | {id: 1}, 53 | {id: 2} 54 | ] 55 | }, 56 | { 57 | id: 2, 58 | rows: [] 59 | } 60 | ]; 61 | 62 | const updatedList = updateLists(otherLists, { 63 | from: {itemId: 1}, 64 | to: {listId: 2} 65 | }); 66 | 67 | expect(JSON.stringify(updatedList)).toMatchSnapshot(); 68 | }); 69 | 70 | test('move item from a list with a single element', () => { 71 | const otherLists = [ 72 | { 73 | id: 1, 74 | rows: [ 75 | {id: 1}, 76 | {id: 2} 77 | ] 78 | }, 79 | { 80 | id: 2, 81 | rows: [ 82 | {id: 3} 83 | ] 84 | } 85 | ]; 86 | 87 | const updatedList = updateLists(otherLists, { 88 | from: {itemId: 3}, 89 | to: {itemId: 2} 90 | }); 91 | 92 | expect(JSON.stringify(updatedList)).toMatchSnapshot(); 93 | }); 94 | 95 | test('null move', () => { 96 | const updatedList = updateLists(lists, { 97 | from: {itemId: 1}, 98 | to: {itemId: 1} 99 | }); 100 | 101 | expect(updatedList).toMatchObject(lists); 102 | }); 103 | 104 | test('lists immutability', () => { 105 | const updatedList = updateLists(lists, { 106 | from: {listId: 1}, 107 | to: {listId: 2} 108 | }); 109 | 110 | expect(updatedList).not.toBe(lists); 111 | }); 112 | 113 | test('single list equality', () => { 114 | const updatedList = updateLists(lists, { 115 | from: {listId: 1}, 116 | to: {listId: 2} 117 | }); 118 | 119 | expect(updatedList[1]).toBe(lists[0]); 120 | }); 121 | 122 | test('single item equality', () => { 123 | const updatedList = updateLists(lists, { 124 | from: {itemId: 1}, 125 | to: {itemId: 2} 126 | }); 127 | 128 | expect(updatedList[0][1]).toBe(lists[0][0]); 129 | }); 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Virtual Kanban 2 | ![NPM version](https://img.shields.io/npm/v/react-virtual-kanban.svg?style=flat) 3 | ![NPM license](https://img.shields.io/npm/l/react-virtual-kanban.svg?style=flat) 4 | ![Travis CI status](https://img.shields.io/travis/edulan/react-virtual-kanban.svg?style=flat) 5 | 6 | A Kanban component in React. 7 | 8 | Demo available here: 9 | https://edulan.github.io/react-virtual-kanban/ 10 | 11 | ## Features 12 | * Fully virtualized 13 | * Built-in drag and drop support 14 | * Dynamic heights 15 | * Custom Item and List components 16 | 17 | ## Installation 18 | Via npm: 19 | ```shell 20 | npm install react-virtual-kanban --save 21 | ``` 22 | 23 | ## Usage 24 | ### Basic example 25 | ```javascript 26 | import React from 'react'; 27 | import ReactDOM from 'react-dom'; 28 | import { VirtualKanban } from 'react-virtual-kanban'; 29 | 30 | // Import only once 31 | import 'react-virtual-kanban/lib/styles.css'; 32 | 33 | // Declare lists with the following structure 34 | const lists = [ 35 | { 36 | id: 'list#1', 37 | rows: [ 38 | {id: 'item#1'}, 39 | {id: 'item#2'}, 40 | {id: 'item#3'}, 41 | {id: 'item#4'} 42 | ] 43 | }, 44 | { 45 | id: 'list#2', 46 | rows: [ 47 | {id: 'item#5'}, 48 | {id: 'item#6'}, 49 | {id: 'item#7'}, 50 | {id: 'item#8'} 51 | ] 52 | }, 53 | { 54 | id: 'list#3', 55 | rows: [ 56 | {id: 'item#9'}, 57 | {id: 'item#10'}, 58 | {id: 'item#11'}, 59 | {id: 'item#12'} 60 | ] 61 | } 62 | ]; 63 | 64 | ReactDOM.render( 65 | , 71 | document.getElementById('root') 72 | ); 73 | ``` 74 | 75 | ## API 76 | | Property | Type | Default | Description | 77 | |:---------------------------|:------------------|:-----------------------------------------------------------------------------------------------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 78 | | lists | Array | `[]` | Array of lists to be rendered | 79 | | width | Number | | The width of the kanban board | 80 | | height | Number | | The height of the kanban board | 81 | | listWidth | Number | | Width of each list | 82 | | listComponent | Function | `List` | List decorator component | 83 | | itemComponent | Function | `Item` | Item decorator component | 84 | | itemPreviewComponent | Function | ItemPreview | Item preview decorator component | 85 | | listPreviewComponent | Function | | List preview decorator component | 86 | | onMoveRow | Function | | Move row callback | 87 | | onMoveList | Function | | Move list callback | 88 | | onDropRow | Function | | Drop row callback | 89 | | onDropList | Function | | Drop list callback | 90 | | overscanListCount | Number | 2 | Number of lists to render before/after the visible part | 91 | | overscanRowCount | Number | 2 | Number of row items to render before/after the visible part | 92 | | itemCacheKey | Function | `id` | Key generator function for caching Item components | 93 | 94 | ## TODO 95 | * Auto scrolling 96 | * Performance++ 97 | * Doc and examples 98 | * Integration with state managers (Redux, Mobx...) 99 | * Animations 100 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.NODE_ENV = 'production'; 3 | 4 | var chalk = require('chalk'); 5 | var fs = require('fs'); 6 | var path = require('path'); 7 | var filesize = require('filesize'); 8 | var gzipSize = require('gzip-size').sync; 9 | var rimrafSync = require('rimraf').sync; 10 | var webpack = require('webpack'); 11 | var config = require('../config/webpack.config.prod'); 12 | var paths = require('../config/paths'); 13 | var recursive = require('recursive-readdir'); 14 | var stripAnsi = require('strip-ansi'); 15 | 16 | // Input: /User/dan/app/build/static/js/main.82be8.js 17 | // Output: /static/js/main.js 18 | function removeFileNameHash(fileName) { 19 | return fileName 20 | .replace(paths.appBuild, '') 21 | .replace(/\/?(.*)(\.\w+)(\.js|\.css)/, (match, p1, p2, p3) => p1 + p3); 22 | } 23 | 24 | // Input: 1024, 2048 25 | // Output: "(+1 KB)" 26 | function getDifferenceLabel(currentSize, previousSize) { 27 | var FIFTY_KILOBYTES = 1024 * 50; 28 | var difference = currentSize - previousSize; 29 | var fileSize = !Number.isNaN(difference) ? filesize(difference) : 0; 30 | if (difference >= FIFTY_KILOBYTES) { 31 | return chalk.red('+' + fileSize); 32 | } else if (difference < FIFTY_KILOBYTES && difference > 0) { 33 | return chalk.yellow('+' + fileSize); 34 | } else if (difference < 0) { 35 | return chalk.green(fileSize); 36 | } else { 37 | return ''; 38 | } 39 | } 40 | 41 | // First, read the current file sizes in build directory. 42 | // This lets us display how much they changed later. 43 | recursive(paths.appBuild, (err, fileNames) => { 44 | var previousSizeMap = (fileNames || []) 45 | .filter(fileName => /\.(js|css)$/.test(fileName)) 46 | .reduce((memo, fileName) => { 47 | var contents = fs.readFileSync(fileName); 48 | var key = removeFileNameHash(fileName); 49 | memo[key] = gzipSize(contents); 50 | return memo; 51 | }, {}); 52 | 53 | // Remove all content but keep the directory so that 54 | // if you're in it, you don't end up in Trash 55 | rimrafSync(paths.appBuild + '/*'); 56 | 57 | // Start the webpack build 58 | build(previousSizeMap); 59 | }); 60 | 61 | // Print a detailed summary of build files. 62 | function printFileSizes(stats, previousSizeMap) { 63 | var assets = stats.toJson().assets 64 | .filter(asset => /\.(js|css)$/.test(asset.name)) 65 | .map(asset => { 66 | var fileContents = fs.readFileSync(paths.appBuild + '/' + asset.name); 67 | var size = gzipSize(fileContents); 68 | var previousSize = previousSizeMap[removeFileNameHash(asset.name)]; 69 | var difference = getDifferenceLabel(size, previousSize); 70 | return { 71 | folder: path.join('build', path.dirname(asset.name)), 72 | name: path.basename(asset.name), 73 | size: size, 74 | sizeLabel: filesize(size) + (difference ? ' (' + difference + ')' : '') 75 | }; 76 | }); 77 | assets.sort((a, b) => b.size - a.size); 78 | var longestSizeLabelLength = Math.max.apply(null, 79 | assets.map(a => stripAnsi(a.sizeLabel).length) 80 | ); 81 | assets.forEach(asset => { 82 | var sizeLabel = asset.sizeLabel; 83 | var sizeLength = stripAnsi(sizeLabel).length; 84 | if (sizeLength < longestSizeLabelLength) { 85 | var rightPadding = ' '.repeat(longestSizeLabelLength - sizeLength); 86 | sizeLabel += rightPadding; 87 | } 88 | console.log( 89 | ' ' + sizeLabel + 90 | ' ' + chalk.dim(asset.folder + path.sep) + chalk.cyan(asset.name) 91 | ); 92 | }); 93 | } 94 | 95 | // Create the production build and print the deployment instructions. 96 | function build(previousSizeMap) { 97 | console.log('Creating an optimized production build...'); 98 | webpack(config).run((err, stats) => { 99 | if (err) { 100 | console.error('Failed to create a production build. Reason:'); 101 | console.error(err.message || err); 102 | process.exit(1); 103 | } 104 | 105 | console.log(chalk.green('Compiled successfully.')); 106 | console.log(); 107 | 108 | console.log('File sizes after gzip:'); 109 | console.log(); 110 | printFileSizes(stats, previousSizeMap); 111 | }); 112 | } 113 | -------------------------------------------------------------------------------- /src/SortableList/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List as VirtualScroll, CellMeasurer, AutoSizer } from 'react-virtualized'; 3 | import { DragSource, DropTarget } from 'react-dnd'; 4 | import { getEmptyImage } from 'react-dnd-html5-backend'; 5 | 6 | import { ItemCache } from './itemCache'; 7 | import SortableItem from '../SortableItem'; 8 | 9 | import { LIST_TYPE, ROW_TYPE } from '../types'; 10 | import * as dragSpec from './dragSpec'; 11 | import * as dropSpec from './dropSpec'; 12 | import * as propTypes from './propTypes'; 13 | 14 | import PureComponent from '../PureComponent'; 15 | 16 | const identity = (c) => c; 17 | 18 | class SortableList extends PureComponent { 19 | static propTypes = propTypes; 20 | 21 | constructor(props) { 22 | super(props); 23 | 24 | this.renderRow = this.renderRow.bind(this); 25 | this.renderItemForMeasure = this.renderItemForMeasure.bind(this); 26 | this.renderList = this.renderList.bind(this); 27 | } 28 | 29 | componentDidMount() { 30 | this.props.connectDragPreview(getEmptyImage(), { 31 | captureDraggingState: true 32 | }); 33 | } 34 | 35 | componentDidUpdate(prevProps) { 36 | if (prevProps.list.rows !== this.props.list.rows && !!this._list) { 37 | this._list.recomputeRowHeights(); 38 | } 39 | } 40 | 41 | renderRow({ index, key, style }) { 42 | const row = this.props.list.rows[index]; 43 | 44 | return ( 45 | 58 | ); 59 | } 60 | 61 | renderItemForMeasure({ rowIndex }) { 62 | const { itemComponent: DecoratedItem } = this.props; 63 | const row = this.props.list.rows[rowIndex]; 64 | 65 | return ( 66 | 75 | ); 76 | } 77 | 78 | renderList({ width, height }) { 79 | // TODO: Check whether scrollbar is visible or not :/ 80 | 81 | return ( 82 | 89 | {({ getRowHeight }) => ( 90 | (this._list = c)} 92 | className='KanbanList' 93 | width={width} 94 | height={height} 95 | rowHeight={getRowHeight} 96 | rowCount={this.props.list.rows.length} 97 | rowRenderer={this.renderRow} 98 | overscanRowCount={this.props.overscanRowCount} 99 | /> 100 | )} 101 | 102 | ); 103 | } 104 | 105 | render() { 106 | const { 107 | list, 108 | listId, 109 | listComponent: DecoratedList, 110 | isDragging, 111 | connectDragSource, 112 | connectDropTarget, 113 | listStyle, 114 | } = this.props; 115 | 116 | return ( 117 | 126 | 127 | {(dimensions) => this.renderList(dimensions)} 128 | 129 | 130 | ); 131 | } 132 | } 133 | 134 | const connectDrop = DropTarget([LIST_TYPE, ROW_TYPE], dropSpec, connect => ({ 135 | connectDropTarget: connect.dropTarget(), 136 | })) 137 | 138 | const connectDrag = DragSource(LIST_TYPE, dragSpec, (connect, monitor) => ({ 139 | connectDragSource: connect.dragSource(), 140 | connectDragPreview: connect.dragPreview(), 141 | isDragging: monitor.isDragging(), 142 | })) 143 | 144 | export default connectDrop(connectDrag(SortableList)); 145 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-virtual-kanban", 3 | "description": "A Kanban component in React", 4 | "version": "0.2.0", 5 | "author": "Eduardo Lanchares ", 6 | "homepage": "https://edulan.github.io/react-virtual-kanban/", 7 | "main": "lib/index.js", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/edulan/react-virtual-kanban.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/edulan/react-virtual-kanban/issues" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "react-component", 19 | "kanban", 20 | "virtual-scroll" 21 | ], 22 | "files": [ 23 | "dist", 24 | "lib" 25 | ], 26 | "devDependencies": { 27 | "autoprefixer": "6.4.0", 28 | "babel-cli": "^6.18.0", 29 | "babel-core": "6.14.0", 30 | "babel-eslint": "6.1.2", 31 | "babel-loader": "6.2.5", 32 | "babel-plugin-add-module-exports": "^0.2.1", 33 | "babel-plugin-transform-class-display-name": "0.0.3", 34 | "babel-plugin-transform-class-properties": "6.11.5", 35 | "babel-plugin-transform-export-extensions": "^6.8.0", 36 | "babel-plugin-transform-object-rest-spread": "6.8.0", 37 | "babel-plugin-transform-react-constant-elements": "6.9.1", 38 | "babel-plugin-transform-react-remove-prop-types": "^0.2.11", 39 | "babel-plugin-transform-regenerator": "6.14.0", 40 | "babel-plugin-transform-remove-console": "^6.8.0", 41 | "babel-plugin-transform-runtime": "6.12.0", 42 | "babel-preset-es2015": "^6.18.0", 43 | "babel-preset-latest": "6.14.0", 44 | "babel-preset-react": "6.11.1", 45 | "babel-preset-stage-0": "^6.16.0", 46 | "case-sensitive-paths-webpack-plugin": "1.1.3", 47 | "chalk": "1.1.3", 48 | "connect-history-api-fallback": "1.3.0", 49 | "cross-spawn": "4.0.0", 50 | "css-loader": "0.23.1", 51 | "decorate-component-with-props": "^1.0.2", 52 | "detect-port": "1.0.0", 53 | "eslint": "3.1.1", 54 | "eslint-config-react-app": "^0.2.1", 55 | "eslint-loader": "1.4.1", 56 | "eslint-plugin-flowtype": "2.4.0", 57 | "eslint-plugin-import": "1.12.0", 58 | "eslint-plugin-jsx-a11y": "2.0.1", 59 | "eslint-plugin-react": "5.2.2", 60 | "extract-text-webpack-plugin": "1.0.1", 61 | "file-loader": "0.9.0", 62 | "filesize": "3.3.0", 63 | "fs-extra": "0.30.0", 64 | "gh-pages": "^0.12.0", 65 | "gzip-size": "3.0.0", 66 | "html-webpack-plugin": "2.22.0", 67 | "http-proxy-middleware": "0.17.0", 68 | "jest": "^19.0.2", 69 | "json-loader": "0.5.4", 70 | "object-assign": "4.1.0", 71 | "opn": "4.0.2", 72 | "postcss": "^5.2.6", 73 | "postcss-cli": "^2.6.0", 74 | "postcss-import": "^9.0.0", 75 | "postcss-loader": "0.9.1", 76 | "promise": "7.1.1", 77 | "react": "15.4.1", 78 | "react-addons-perf": "15.4.1", 79 | "react-addons-shallow-compare": "15.4.1", 80 | "react-dom": "15.4.1", 81 | "recursive-readdir": "2.0.0", 82 | "rimraf": "2.5.4", 83 | "strip-ansi": "3.0.1", 84 | "style-loader": "0.13.1", 85 | "url-loader": "0.5.7", 86 | "webpack": "1.13.1", 87 | "webpack-dev-server": "1.14.1", 88 | "whatwg-fetch": "1.0.0" 89 | }, 90 | "dependencies": { 91 | "babel-runtime": "6.11.6", 92 | "classnames": "2.2.5", 93 | "dnd-core": "^2.2.3", 94 | "dom-helpers": "^2.4.0", 95 | "react-addons-update": "15.4.1", 96 | "react-dnd": "2.1.4", 97 | "react-dnd-html5-backend": "2.1.2", 98 | "react-dnd-scrollzone": "3.1.0", 99 | "react-virtualized": "8.8.1" 100 | }, 101 | "peerDependencies": { 102 | "react": "^0.14.0 || ^15.0.0", 103 | "react-addons-shallow-compare": "^0.14.0 || ^15.0.0", 104 | "react-dom": "^0.14.0 || ^15.0.0" 105 | }, 106 | "scripts": { 107 | "build:clean": "rm -rf ./lib ./dist", 108 | "build:lib": "NODE_ENV=production BABEL_ENV=commonjs babel src --out-dir lib --ignore src/demo/ --ignore src/**/__tests__", 109 | "build:umd": "NODE_ENV=production webpack --config config/webpack.config.umd.js --bail", 110 | "build:demo": "node ./scripts/build.js", 111 | "build:css": "postcss --use autoprefixer --use postcss-import src/styles.css > lib/styles.css", 112 | "build": "npm run build:clean && npm run build:umd && npm run build:lib && npm run build:css && npm run build:demo", 113 | "deploy": "gh-pages -d build", 114 | "prepublish": "npm run build", 115 | "postpublish": "npm run deploy", 116 | "start": "npm run build:lib && npm run build:css && node ./scripts/start.js", 117 | "test": "jest" 118 | }, 119 | "eslintConfig": { 120 | "extends": "./config/eslint.js" 121 | }, 122 | "jest": { 123 | "collectCoverageFrom": [ 124 | "src/**/*.js" 125 | ], 126 | "testPathIgnorePatterns": [ 127 | "(lib|build|docs|node_modules)[/\\\\]" 128 | ] 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Kanban/updateLists.js: -------------------------------------------------------------------------------- 1 | import update from 'react-addons-update'; 2 | 3 | function rotateRight(range, offset) { 4 | const length = range.length; 5 | 6 | return range.map((_, index, list) => list[(index + offset) % length]); 7 | } 8 | 9 | function rotateLeft(range, offset) { 10 | return rotateRight(range, range.length - Math.abs(offset % range.length)); 11 | } 12 | 13 | function buildUpdateOperation(list, { from, to }) { 14 | const lower = Math.min(from, to); 15 | const upper = Math.max(from, to); 16 | const range = list.slice(lower, upper + 1); 17 | const rotated = to - from > 0 ? rotateRight(range, 1) : rotateLeft(range, 1); 18 | 19 | return [lower, rotated.length, ...rotated]; 20 | } 21 | 22 | export function findListIndex(lists, listId) { 23 | return lists.findIndex(({ id }) => id === listId); 24 | } 25 | 26 | export function findItemIndex(lists, itemId) { 27 | let index = -1; 28 | 29 | lists.forEach(({ rows }) => { 30 | if (index !== -1) return; 31 | index = rows.findIndex(({ id }) => id === itemId); 32 | }); 33 | 34 | return index; 35 | } 36 | 37 | export function findItemListIndex(lists, itemId) { 38 | let index = -1; 39 | 40 | lists.forEach(({ rows }, i) => { 41 | if (index !== -1) return; 42 | 43 | if (rows.some(({ id }) => id === itemId)) { 44 | index = i; 45 | } 46 | }); 47 | 48 | return index; 49 | } 50 | 51 | export function findItemListId(lists, itemId) { 52 | const list = lists.find(({ rows }) => { 53 | return rows.some(({ id }) => id === itemId); 54 | }); 55 | 56 | return list && list.id; 57 | } 58 | 59 | function moveLists(lists, { fromId, toId }) { 60 | const fromIndex = findListIndex(lists, fromId); 61 | const toIndex = findListIndex(lists, toId); 62 | 63 | // Sanity checks 64 | if (fromIndex === -1 || toIndex === -1) { 65 | console.warn(`List not in bounds`); 66 | return lists; 67 | } 68 | 69 | const fromList = lists[fromIndex]; 70 | 71 | if (!fromList) { 72 | console.warn(`List is not an object`); 73 | return lists; 74 | } 75 | 76 | return update(lists, { 77 | $splice: [ 78 | [fromIndex, 1], 79 | [toIndex, 0, fromList], 80 | ] 81 | }); 82 | } 83 | 84 | function moveItems(lists, { fromId, toId }) { 85 | const fromListIndex = findItemListIndex(lists, fromId); 86 | const toListIndex = findItemListIndex(lists, toId); 87 | const fromIndex = findItemIndex(lists, fromId); 88 | const toIndex = findItemIndex(lists, toId); 89 | 90 | // Sanity checks 91 | if (fromListIndex === -1) { 92 | console.warn(`List not in bounds`); 93 | return lists; 94 | } 95 | 96 | if (fromIndex === -1 || toIndex === -1) { 97 | console.warn(`Item not in bounds`); 98 | return lists; 99 | } 100 | 101 | const fromList = lists[fromListIndex]; 102 | 103 | if (fromListIndex === toListIndex) { 104 | return update(lists, { 105 | [fromListIndex]: { 106 | rows: { 107 | $splice: [ 108 | buildUpdateOperation(fromList.rows, {from: fromIndex, to: toIndex}) 109 | ] 110 | } 111 | } 112 | }); 113 | } 114 | 115 | const fromItem = fromList.rows[fromIndex]; 116 | 117 | return update(lists, { 118 | // Remove item from source list 119 | [fromListIndex]: { 120 | rows: { 121 | $splice: [ 122 | [fromIndex, 1], 123 | ] 124 | } 125 | }, 126 | // Add item to target list 127 | [toListIndex]: { 128 | rows: { 129 | $splice: [ 130 | [toIndex, 0, fromItem] 131 | ] 132 | } 133 | }, 134 | }); 135 | } 136 | 137 | function moveItemToList(lists, { fromId, toId }) { 138 | const fromIndex = findItemIndex(lists, fromId); 139 | const fromListIndex = findItemListIndex(lists, fromId); 140 | const toListIndex = findListIndex(lists, toId); 141 | 142 | if (fromIndex === -1) { 143 | console.warn(`Item not in bounds`); 144 | return lists; 145 | } 146 | 147 | const fromList = lists[fromListIndex]; 148 | const toList = lists[toListIndex]; 149 | 150 | if (!toList) { 151 | console.warn(`List is not an object`); 152 | return lists; 153 | } 154 | 155 | // Only move when list is empty 156 | if (toList.rows.length > 0) { 157 | return lists; 158 | } 159 | 160 | const fromItem = fromList.rows[fromIndex]; 161 | 162 | return update(lists, { 163 | // Remove item from source list 164 | [fromListIndex]: { 165 | rows: { 166 | $splice: [ 167 | [fromIndex, 1], 168 | ] 169 | } 170 | }, 171 | // Add item to target list 172 | [toListIndex]: { 173 | rows: { 174 | $push: [ 175 | fromItem 176 | ] 177 | } 178 | }, 179 | }); 180 | } 181 | 182 | export function updateLists(lists, { from, to }) { 183 | const { itemId: fromItemId, listId: fromListId } = from; 184 | const { itemId: toItemId, listId: toListId } = to; 185 | 186 | // Deprecation checks 187 | if (from.listIndex || from.rowIndex || to.listIndex || to.rowIndex) { 188 | console.warn(`Deprecated listIndex and rowIndex param. Use listId or itemId`); 189 | return lists; 190 | } 191 | 192 | // Move lists 193 | if (fromListId !== toListId && fromItemId === void 0 && toItemId === void 0) { 194 | return moveLists(lists, { fromId: fromListId, toId: toListId }); 195 | } 196 | 197 | // Move item inside same list 198 | if (fromListId === toListId && fromItemId !== void 0 && toItemId !== void 0) { 199 | return moveItems(lists, { fromId: fromItemId, toId: toItemId }); 200 | } 201 | 202 | // Move item to a different list 203 | if (fromListId === void 0 && toListId !== void 0 && fromItemId !== void 0 && toItemId === void 0) { 204 | return moveItemToList(lists, { fromId: fromItemId, toId: toListId }); 205 | } 206 | 207 | return lists; 208 | } 209 | -------------------------------------------------------------------------------- /config/eslint.js: -------------------------------------------------------------------------------- 1 | // Inspired by https://github.com/airbnb/javascript but less opinionated. 2 | 3 | // We use eslint-loader so even warnings are very visibile. 4 | // This is why we only use "WARNING" level for potential errors, 5 | // and we don't use "ERROR" level at all. 6 | 7 | // In the future, we might create a separate list of rules for production. 8 | // It would probably be more strict. 9 | 10 | module.exports = { 11 | root: true, 12 | 13 | parser: 'babel-eslint', 14 | 15 | // import plugin is termporarily disabled, scroll below to see why 16 | plugins: [/*'import', */'flowtype', 'jsx-a11y', 'react'], 17 | 18 | env: { 19 | browser: true, 20 | commonjs: true, 21 | es6: true, 22 | node: true, 23 | jest: true 24 | }, 25 | 26 | parserOptions: { 27 | ecmaVersion: 6, 28 | sourceType: 'module', 29 | ecmaFeatures: { 30 | jsx: true, 31 | generators: true, 32 | experimentalObjectRestSpread: true 33 | } 34 | }, 35 | 36 | settings: { 37 | 'import/ignore': [ 38 | 'node_modules', 39 | '\\.(json|css|jpg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm)$', 40 | ], 41 | 'import/extensions': ['.js'], 42 | 'import/resolver': { 43 | node: { 44 | extensions: ['.js', '.json'] 45 | } 46 | } 47 | }, 48 | 49 | rules: { 50 | // http://eslint.org/docs/rules/ 51 | 'array-callback-return': 'warn', 52 | 'default-case': ['warn', { commentPattern: '^no default$' }], 53 | 'dot-location': ['warn', 'property'], 54 | eqeqeq: ['warn', 'allow-null'], 55 | 'guard-for-in': 'warn', 56 | 'new-parens': 'warn', 57 | 'no-array-constructor': 'warn', 58 | 'no-caller': 'warn', 59 | 'no-cond-assign': ['warn', 'always'], 60 | 'no-const-assign': 'warn', 61 | 'no-control-regex': 'warn', 62 | 'no-delete-var': 'warn', 63 | 'no-dupe-args': 'warn', 64 | 'no-dupe-class-members': 'warn', 65 | 'no-dupe-keys': 'warn', 66 | 'no-duplicate-case': 'warn', 67 | 'no-empty-character-class': 'warn', 68 | 'no-empty-pattern': 'warn', 69 | 'no-eval': 'warn', 70 | 'no-ex-assign': 'warn', 71 | 'no-extend-native': 'warn', 72 | 'no-extra-bind': 'warn', 73 | 'no-extra-label': 'warn', 74 | 'no-fallthrough': 'warn', 75 | 'no-func-assign': 'warn', 76 | 'no-implied-eval': 'warn', 77 | 'no-invalid-regexp': 'warn', 78 | 'no-iterator': 'warn', 79 | 'no-label-var': 'warn', 80 | 'no-labels': ['warn', { allowLoop: false, allowSwitch: false }], 81 | 'no-lone-blocks': 'warn', 82 | 'no-loop-func': 'warn', 83 | 'no-mixed-operators': ['warn', { 84 | groups: [ 85 | ['&', '|', '^', '~', '<<', '>>', '>>>'], 86 | ['==', '!=', '===', '!==', '>', '>=', '<', '<='], 87 | ['&&', '||'], 88 | ['in', 'instanceof'] 89 | ], 90 | allowSamePrecedence: false 91 | }], 92 | 'no-multi-str': 'warn', 93 | 'no-native-reassign': 'warn', 94 | 'no-negated-in-lhs': 'warn', 95 | 'no-new-func': 'warn', 96 | 'no-new-object': 'warn', 97 | 'no-new-symbol': 'warn', 98 | 'no-new-wrappers': 'warn', 99 | 'no-obj-calls': 'warn', 100 | 'no-octal': 'warn', 101 | 'no-octal-escape': 'warn', 102 | 'no-redeclare': 'warn', 103 | 'no-regex-spaces': 'warn', 104 | 'no-restricted-syntax': [ 105 | 'warn', 106 | 'LabeledStatement', 107 | 'WithStatement', 108 | ], 109 | 'no-return-assign': 'warn', 110 | 'no-script-url': 'warn', 111 | 'no-self-assign': 'warn', 112 | 'no-self-compare': 'warn', 113 | 'no-sequences': 'warn', 114 | 'no-shadow-restricted-names': 'warn', 115 | 'no-sparse-arrays': 'warn', 116 | 'no-this-before-super': 'warn', 117 | 'no-throw-literal': 'warn', 118 | 'no-undef': 'warn', 119 | 'no-unexpected-multiline': 'warn', 120 | 'no-unreachable': 'warn', 121 | 'no-unused-expressions': 'warn', 122 | 'no-unused-labels': 'warn', 123 | "no-unused-vars": ['warn', {vars: 'local', args: 'none', 'varsIgnorePattern': '^_'}], 124 | 'no-use-before-define': ['warn', 'nofunc'], 125 | 'no-useless-computed-key': 'warn', 126 | 'no-useless-concat': 'warn', 127 | 'no-useless-constructor': 'warn', 128 | 'no-useless-escape': 'warn', 129 | 'no-useless-rename': ['warn', { 130 | ignoreDestructuring: false, 131 | ignoreImport: false, 132 | ignoreExport: false, 133 | }], 134 | 'no-with': 'warn', 135 | 'no-whitespace-before-property': 'warn', 136 | 'operator-assignment': ['warn', 'always'], 137 | radix: 'warn', 138 | 'require-yield': 'warn', 139 | 'rest-spread-spacing': ['warn', 'never'], 140 | strict: ['warn', 'never'], 141 | 'unicode-bom': ['warn', 'never'], 142 | 'use-isnan': 'warn', 143 | 'valid-typeof': 'warn', 144 | 145 | // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/ 146 | 147 | // TODO: import rules are temporarily disabled because they don't play well 148 | // with how eslint-loader only checks the file you change. So if module A 149 | // imports module B, and B is missing a default export, the linter will 150 | // record this as an issue in module A. Now if you fix module B, the linter 151 | // will not be aware that it needs to re-lint A as well, so the error 152 | // will stay until the next restart, which is really confusing. 153 | 154 | // This is probably fixable with a patch to eslint-loader. 155 | // When file A is saved, we want to invalidate all files that import it 156 | // *and* that currently have lint errors. This should fix the problem. 157 | 158 | // 'import/default': 'warn', 159 | // 'import/export': 'warn', 160 | // 'import/named': 'warn', 161 | // 'import/namespace': 'warn', 162 | // 'import/no-amd': 'warn', 163 | // 'import/no-duplicates': 'warn', 164 | // 'import/no-extraneous-dependencies': 'warn', 165 | // 'import/no-named-as-default': 'warn', 166 | // 'import/no-named-as-default-member': 'warn', 167 | // 'import/no-unresolved': ['warn', { commonjs: true }], 168 | 169 | // https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules 170 | 'react/jsx-equals-spacing': ['warn', 'never'], 171 | 'react/jsx-no-duplicate-props': ['warn', { ignoreCase: true }], 172 | 'react/jsx-no-undef': 'warn', 173 | 'react/jsx-pascal-case': ['warn', { 174 | allowAllCaps: true, 175 | ignore: [], 176 | }], 177 | 'react/jsx-uses-react': 'warn', 178 | 'react/jsx-uses-vars': 'warn', 179 | 'react/no-deprecated': 'warn', 180 | 'react/no-direct-mutation-state': 'warn', 181 | 'react/no-is-mounted': 'warn', 182 | 'react/react-in-jsx-scope': 'warn', 183 | 'react/require-render-return': 'warn', 184 | 185 | // https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules 186 | 'jsx-a11y/aria-role': 'warn', 187 | 'jsx-a11y/img-has-alt': 'warn', 188 | 'jsx-a11y/img-redundant-alt': 'warn', 189 | 'jsx-a11y/no-access-key': 'warn', 190 | 191 | // https://github.com/gajus/eslint-plugin-flowtype 192 | 'flowtype/define-flow-type': 'warn', 193 | 'flowtype/require-valid-file-annotation': 'warn', 194 | 'flowtype/use-flow-type': 'warn' 195 | } 196 | }; 197 | -------------------------------------------------------------------------------- /config/webpack.config.dev.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var autoprefixer = require('autoprefixer'); 3 | var atImport = require('postcss-import'); 4 | var webpack = require('webpack'); 5 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | var CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin'); 7 | var WatchMissingNodeModulesPlugin = require('../scripts/utils/WatchMissingNodeModulesPlugin'); 8 | var paths = require('./paths'); 9 | var env = require('./env'); 10 | 11 | // This is the development configuration. 12 | // It is focused on developer experience and fast rebuilds. 13 | // The production configuration is different and lives in a separate file. 14 | module.exports = { 15 | // This makes the bundle appear split into separate modules in the devtools. 16 | // We don't use source maps here because they can be confusing: 17 | // https://github.com/facebookincubator/create-react-app/issues/343#issuecomment-237241875 18 | // You may want 'cheap-module-source-map' instead if you prefer source maps. 19 | devtool: 'eval', 20 | // These are the "entry points" to our application. 21 | // This means they will be the "root" imports that are included in JS bundle. 22 | // The first two entry points enable "hot" CSS and auto-refreshes for JS. 23 | entry: [ 24 | // Include WebpackDevServer client. It connects to WebpackDevServer via 25 | // sockets and waits for recompile notifications. When WebpackDevServer 26 | // recompiles, it sends a message to the client by socket. If only CSS 27 | // was changed, the app reload just the CSS. Otherwise, it will refresh. 28 | // The "?/" bit at the end tells the client to look for the socket at 29 | // the root path, i.e. /sockjs-node/. Otherwise visiting a client-side 30 | // route like /todos/42 would make it wrongly request /todos/42/sockjs-node. 31 | // The socket server is a part of WebpackDevServer which we are using. 32 | // The /sockjs-node/ path I'm referring to is hardcoded in WebpackDevServer. 33 | require.resolve('webpack-dev-server/client') + '?/', 34 | // Include Webpack hot module replacement runtime. Webpack is pretty 35 | // low-level so we need to put all the pieces together. The runtime listens 36 | // to the events received by the client above, and applies updates (such as 37 | // new CSS) to the running application. 38 | require.resolve('webpack/hot/dev-server'), 39 | // We ship a few polyfills by default. 40 | require.resolve('./polyfills'), 41 | // Finally, this is your app's code: 42 | path.join(paths.appSrc, 'demo/index') 43 | // We include the app code last so that if there is a runtime error during 44 | // initialization, it doesn't blow up the WebpackDevServer client, and 45 | // changing JS code would still trigger a refresh. 46 | ], 47 | output: { 48 | // Next line is not used in dev but WebpackDevServer crashes without it: 49 | path: paths.appBuild, 50 | // Add /* filename */ comments to generated require()s in the output. 51 | pathinfo: true, 52 | // This does not produce a real file. It's just the virtual path that is 53 | // served by WebpackDevServer in development. This is the JS bundle 54 | // containing code from all our entry points, and the Webpack runtime. 55 | filename: 'static/js/bundle.js', 56 | // In development, we always serve from the root. This makes config easier. 57 | publicPath: '/' 58 | }, 59 | module: { 60 | // First, run the linter. 61 | // It's important to do this before Babel processes the JS. 62 | preLoaders: [ 63 | { 64 | test: /\.js$/, 65 | loader: 'eslint', 66 | include: paths.appSrc, 67 | } 68 | ], 69 | loaders: [ 70 | // Process JS with Babel. 71 | { 72 | test: /\.js$/, 73 | include: paths.appSrc, 74 | loader: 'babel', 75 | query: require('./babel.dev') 76 | }, 77 | // "postcss" loader applies autoprefixer to our CSS. 78 | // "css" loader resolves paths in CSS and adds assets as dependencies. 79 | // "style" loader turns CSS into JS modules that inject