├── src ├── Views │ ├── NotFound │ │ ├── styles.css │ │ └── index.js │ ├── Organigram │ │ ├── styles.css │ │ └── index.js │ └── Home │ │ ├── styles.css │ │ └── index.js ├── globals │ ├── constants.js │ ├── styles │ │ ├── globalStyles.css │ │ └── normalize.css │ ├── actions.js │ ├── __tests__ │ │ ├── actions.js │ │ └── reducers.js │ └── reducers.js ├── Components │ ├── AppContainer │ │ ├── styles.css │ │ └── index.js │ └── EmployeeNode │ │ ├── assets │ │ └── arrow.svg │ │ ├── DragContainer.js │ │ ├── DropContainer.js │ │ ├── styles.css │ │ └── index.js ├── index.js ├── index.html ├── store.js ├── utilities │ ├── exportJson.js │ ├── __tests__ │ │ ├── moveEmployee.js │ │ ├── deNormalizeDataStructure.js │ │ ├── isSupervisor.js │ │ └── normalizeDataStructure.js │ ├── moveEmployee.js │ ├── deNormalizeDataStructure.js │ ├── isSupervisor.js │ └── normalizeDataStructure.js ├── routes.js └── sample_json.json ├── .gitignore ├── previews ├── preview_1.png └── preview_2.png ├── .babelrc ├── .eslintrc.js ├── LICENSE ├── webpack.config.js ├── package.json └── README.md /src/Views/NotFound/styles.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist -------------------------------------------------------------------------------- /src/globals/constants.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_EMPLOYEE_DATA = 'UPDATE_EMPLOYEE_DATA' -------------------------------------------------------------------------------- /previews/preview_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proshoumma/organigram/HEAD/previews/preview_1.png -------------------------------------------------------------------------------- /previews/preview_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/proshoumma/organigram/HEAD/previews/preview_2.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["babel-preset-env", "react"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /src/Components/AppContainer/styles.css: -------------------------------------------------------------------------------- 1 | .appContainer { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | 6 | padding-bottom: 30px; 7 | } -------------------------------------------------------------------------------- /src/Views/NotFound/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import './styles.css' 3 | 4 | class NotFound extends Component { 5 | render() { 6 | return ( 7 |
8 | 404, Not Found 9 |
10 | ) 11 | } 12 | } 13 | 14 | export default NotFound -------------------------------------------------------------------------------- /src/globals/styles/globalStyles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --content-box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); 3 | --theme-color: #e74c3c; 4 | --theme-color-deep: #c0392b; 5 | } 6 | 7 | html, 8 | body { 9 | font-family: 'Quicksand', sans-serif; 10 | background-color: rgb(239, 240, 242); 11 | } -------------------------------------------------------------------------------- /src/globals/actions.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_EMPLOYEE_DATA } from './constants' 2 | 3 | /** 4 | * update employee list action 5 | * @param {object} data employee list object (normalized) 6 | */ 7 | export const updateEmployeeData = (data) => ({ 8 | type: UPDATE_EMPLOYEE_DATA, 9 | payload: data, 10 | }) -------------------------------------------------------------------------------- /src/Components/AppContainer/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import propTypes from 'prop-types' 3 | 4 | import './styles.css' 5 | 6 | const AppContainer = ({ children }) => ( 7 |
8 | { children } 9 |
10 | ) 11 | 12 | AppContainer.propTypes = { 13 | children: propTypes.object 14 | } 15 | 16 | export default AppContainer -------------------------------------------------------------------------------- /src/globals/__tests__/actions.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_EMPLOYEE_DATA } from '../constants' 2 | import * as actionTypes from '../actions' 3 | 4 | describe('global actions', () => { 5 | it('should provide an action to update employee data', () => { 6 | const expectedAction = { 7 | type: UPDATE_EMPLOYEE_DATA, 8 | payload: {} 9 | } 10 | 11 | expect(actionTypes.updateEmployeeData({})).toEqual(expectedAction) 12 | }) 13 | }) -------------------------------------------------------------------------------- /src/Components/EmployeeNode/assets/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | 5 | import Routes from './routes' 6 | import store from './store' 7 | import './globals/styles/normalize.css' 8 | import './globals/styles/globalStyles.css' 9 | 10 | const App = () => ( 11 | 12 | 13 | 14 | ) 15 | 16 | // render the app component to root div element 17 | ReactDOM.render( 18 | , 19 | document.getElementById('root') 20 | ) -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Organigram 12 | 13 | 14 |
15 | 16 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, combineReducers } from 'redux' 2 | 3 | import globalReducer from './globals/reducers' 4 | 5 | /** 6 | * feature based reducer splitting 7 | * for better scalability and isolation 8 | */ 9 | const rootReducer = combineReducers({ 10 | global: globalReducer, 11 | }) 12 | 13 | // redux dev tool extension 14 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose 15 | 16 | // application store 17 | let store = createStore( 18 | rootReducer, 19 | composeEnhancers() 20 | ) 21 | 22 | export default store -------------------------------------------------------------------------------- /src/globals/reducers.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_EMPLOYEE_DATA } from './constants' 2 | 3 | export const defaultState = { 4 | employeeData: {} 5 | } 6 | 7 | /** 8 | * global reducer for handling any global state requirement 9 | * @param {object} state current state of the global reducer 10 | * @param {object} action performed action object 11 | */ 12 | const globalAppReducer = (state = defaultState, action) => { 13 | switch(action.type) { 14 | case UPDATE_EMPLOYEE_DATA: { 15 | return { 16 | ...state, 17 | employeeData: action.payload 18 | } 19 | } 20 | 21 | default: { 22 | return state 23 | } 24 | } 25 | } 26 | 27 | export default globalAppReducer -------------------------------------------------------------------------------- /src/utilities/exportJson.js: -------------------------------------------------------------------------------- 1 | import deNormalizeDataStructure from './deNormalizeDataStructure' 2 | 3 | /** 4 | * convert and export the employee tree to user's local machine 5 | * @param {object} employeeObjList nomalized employee object list from redux-store 6 | */ 7 | const exportJson = (employeeObjList) => { 8 | // de-normalize the data first 9 | const deNormalizedData = deNormalizeDataStructure(employeeObjList) 10 | 11 | // perform download operation 12 | const a = document.createElement('a') 13 | const file = new Blob([JSON.stringify(deNormalizedData)], { type: 'application/json' }) 14 | a.href = URL.createObjectURL(file) 15 | a.download = 'organigram' 16 | a.click() 17 | } 18 | 19 | export default exportJson -------------------------------------------------------------------------------- /src/globals/__tests__/reducers.js: -------------------------------------------------------------------------------- 1 | import { UPDATE_EMPLOYEE_DATA } from '../constants' 2 | import globalReducer, { defaultState } from '../reducers' 3 | 4 | describe('global reducers', () => { 5 | it('should return the initial state', () => { 6 | expect( 7 | globalReducer(undefined, {}) 8 | ).toEqual(defaultState) 9 | }) 10 | 11 | it(`should handle ${UPDATE_EMPLOYEE_DATA}`, () => { 12 | const payload = { 13 | 'Nick': { 14 | name: 'Nick', 15 | position: 'Lead Engineer', 16 | employees: {} 17 | } 18 | } 19 | 20 | const expectedResult = { 21 | employeeData: payload 22 | } 23 | 24 | expect( 25 | globalReducer({}, { 26 | type: UPDATE_EMPLOYEE_DATA, 27 | payload, 28 | }) 29 | ).toEqual(expectedResult) 30 | }) 31 | }) -------------------------------------------------------------------------------- /src/utilities/__tests__/moveEmployee.js: -------------------------------------------------------------------------------- 1 | import moveEmployee from '../moveEmployee' 2 | import normalizedTree from '../mockData/perfectTreeResult' 3 | import moveResult from '../mockData/treeMoveResult' 4 | 5 | describe('moveEmployee function', () => { 6 | it('should exists and its a function', () => { 7 | expect(moveEmployee).not.toBeFalsy() 8 | expect(moveEmployee).toBeInstanceOf(Function) 9 | }) 10 | 11 | it('should throw an error if no parameter is passed', () => { 12 | expect(() => { 13 | moveEmployee() 14 | }).toThrow() 15 | }) 16 | 17 | it('should move an employee from its supervisor to new supervisor', () => { 18 | const result = moveEmployee( 19 | 'Nick', 20 | 'Sophie', 21 | 'Jonas', 22 | normalizedTree 23 | ) 24 | 25 | expect(result).toEqual(moveResult) 26 | }) 27 | }) -------------------------------------------------------------------------------- /src/utilities/__tests__/deNormalizeDataStructure.js: -------------------------------------------------------------------------------- 1 | import deNormalizeDataStructure from '../deNormalizeDataStructure' 2 | import perfectTreeResult from '../mockData/perfectTreeResult' 3 | import perfectTreeDeNormalized from '../mockData/perfectTreeDeNormalized' 4 | 5 | describe('deNormalizeDataStructure function', () => { 6 | it('should exists and its a function', () => { 7 | expect(deNormalizeDataStructure).not.toBeFalsy() 8 | expect(deNormalizeDataStructure).toBeInstanceOf(Function) 9 | }) 10 | 11 | it('should throw an error when no parameter is passed', () => { 12 | expect(() => { deNormalizeDataStructure() }).toThrow() 13 | }) 14 | 15 | it('should denomalize a normalized employee structrue', () => { 16 | const result = deNormalizeDataStructure(perfectTreeResult) 17 | expect(result).toEqual(perfectTreeDeNormalized) 18 | }) 19 | }) -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "node": true, 7 | "jest": true 8 | }, 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:react/recommended" 12 | ], 13 | "parserOptions": { 14 | "ecmaFeatures": { 15 | "jsx": true 16 | }, 17 | "ecmaVersion": 2018, 18 | "sourceType": "module" 19 | }, 20 | "plugins": [ 21 | "react" 22 | ], 23 | "globals": { 24 | "System": true, 25 | }, 26 | "rules": { 27 | "linebreak-style": [ 28 | "error", 29 | "unix" 30 | ], 31 | "quotes": [ 32 | "error", 33 | "single" 34 | ], 35 | "semi": [ 36 | "error", 37 | "never" 38 | ] 39 | } 40 | }; -------------------------------------------------------------------------------- /src/utilities/moveEmployee.js: -------------------------------------------------------------------------------- 1 | /** 2 | * move operation for drag and dropped employee objects 3 | * @param {string} sourceId source/dragged employee id 4 | * @param {string} sourceSupervisorId source/dragged employee's supervisor id 5 | * @param {string} destinationId destination/dropped employee id 6 | * @param {object} employeeListObj normalized employee list object from redux-store 7 | */ 8 | const moveEmployee = ( 9 | sourceId, 10 | sourceSupervisorId, 11 | destinationId, 12 | employeeListObj 13 | ) => { 14 | // define a new employee list object 15 | const newEmployeeList = employeeListObj 16 | 17 | // remove the employee from its current supervisor 18 | delete newEmployeeList[sourceSupervisorId].employees[sourceId] 19 | 20 | // add the employee to its new supervisor 21 | newEmployeeList[destinationId].employees[sourceId] = true 22 | 23 | // return the new list 24 | return newEmployeeList 25 | } 26 | 27 | export default moveEmployee -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Provash Shoumma 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. -------------------------------------------------------------------------------- /src/Components/EmployeeNode/DragContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { DragSource } from 'react-dnd' 3 | import propTypes from 'prop-types' 4 | 5 | class DragContainer extends Component { 6 | render() { 7 | const { 8 | children, 9 | className, 10 | connectDragSource 11 | } = this.props 12 | 13 | return connectDragSource ( 14 |
15 | { children } 16 |
17 | ) 18 | } 19 | } 20 | 21 | DragContainer.propTypes = { 22 | employeeInfo: propTypes.object, 23 | className: propTypes.string, 24 | children: propTypes.any, 25 | connectDragSource: propTypes.func 26 | } 27 | 28 | export default DragSource ( 29 | // item type of the dragged item 30 | 'EmployeeNode', 31 | { 32 | // return employeeInfo for drop target to 33 | // catch this data for any drop action 34 | beginDrag: (props) => ({ 35 | ...props.employeeInfo 36 | }) 37 | }, 38 | // props to pass down to the component 39 | (connect, monitor) => ({ 40 | connectDragSource: connect.dragSource(), 41 | isDragging: monitor.isDragging() 42 | }) 43 | )(DragContainer) 44 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Router, hashHistory } from 'react-router' 3 | 4 | import AppContainer from './Components/AppContainer' 5 | import HomeView from './Views/Home' 6 | 7 | /** 8 | * route mapping with code splitting 9 | * to only load necessery javascript code for 10 | * a perticular screen when it loads in the browser :-) 11 | */ 12 | const routeMappings = { 13 | component: AppContainer, 14 | path: '/', 15 | indexRoute: { 16 | component: HomeView 17 | }, 18 | childRoutes: [ 19 | { 20 | path: 'organigram_view', 21 | getComponent(location, cb) { 22 | // async call for loading the view 23 | System.import('./Views/Organigram').then( 24 | module => cb(null, module.default) 25 | ) 26 | } 27 | }, 28 | { 29 | path: '*', 30 | getComponent(location, cb) { 31 | // async call for loading the view 32 | System.import('./Views/NotFound').then( 33 | module => cb(null, module.default) 34 | ) 35 | } 36 | } 37 | ] 38 | } 39 | 40 | // main router component with hashHistory 41 | const Routes = () => ( 42 | 46 | ) 47 | 48 | export default Routes -------------------------------------------------------------------------------- /src/Views/Organigram/styles.css: -------------------------------------------------------------------------------- 1 | .organigramViewContainer { 2 | width: 960px; 3 | } 4 | 5 | /** 6 | * top bar styles 7 | */ 8 | .topBar { 9 | display: flex; 10 | justify-content: space-between; 11 | align-items: center; 12 | padding: 20px 10px; 13 | } 14 | 15 | .topBarLogo { 16 | font-family: 'Pacifico', cursive; 17 | font-size: 24px; 18 | color: #444; 19 | } 20 | 21 | .navContainer { 22 | display: flex; 23 | } 24 | 25 | .navButton { 26 | padding: 0px 10px; 27 | 28 | font-size: 14px; 29 | font-weight: 500; 30 | text-transform: uppercase; 31 | letter-spacing: 1px; 32 | user-select: none; 33 | 34 | color: var(--theme-color); 35 | cursor: pointer; 36 | } 37 | 38 | .navButton:hover { 39 | color: var(--theme-color-deep); 40 | } 41 | 42 | /** 43 | * tree container styles 44 | */ 45 | .treeContainerBox { 46 | padding: 20px 25px; 47 | background-color: white; 48 | border-radius: 5px; 49 | box-shadow: var(--content-box-shadow); 50 | } 51 | 52 | .messageBox { 53 | margin-bottom: 10px; 54 | 55 | font-size: 14px; 56 | letter-spacing: 0.5px; 57 | text-align: center; 58 | 59 | color: #666; 60 | } 61 | 62 | .treeContainer { 63 | display: flex; 64 | padding-left: 30px; 65 | border: 1px dashed #ddd; 66 | } 67 | 68 | .noData { 69 | padding: 10px 0px; 70 | } -------------------------------------------------------------------------------- /src/utilities/__tests__/isSupervisor.js: -------------------------------------------------------------------------------- 1 | import isSupervisor from '../isSupervisor' 2 | import normalizedTree from '../mockData/perfectTreeResult' 3 | 4 | describe('isSupervisor function', () => { 5 | it('should exists and its a function', () => { 6 | expect(isSupervisor).not.toBeFalsy() 7 | expect(isSupervisor).toBeInstanceOf(Function) 8 | }) 9 | 10 | it('should throw error if no parameter is passed', () => { 11 | expect(() => { 12 | isSupervisor() 13 | }).toThrow() 14 | }) 15 | 16 | it('should return true if a dragged employee is supervisor of dropped employee', () => { 17 | const result = isSupervisor( 18 | 'Jonas', 19 | 'Sophie', 20 | normalizedTree 21 | ) 22 | 23 | expect(result).toBe(true) 24 | }) 25 | 26 | it('should return false if a dragged employee is not a supervisor of dropped employee', () => { 27 | const result = isSupervisor( 28 | 'Nick', 29 | 'Jonas', 30 | normalizedTree 31 | ) 32 | 33 | expect(result).toBe(false) 34 | }) 35 | 36 | it('should return true if a dragged employee is a great-supervisor of dropped employee', () => { 37 | const result = isSupervisor( 38 | 'Jonas', 39 | 'Barbara', 40 | normalizedTree 41 | ) 42 | 43 | expect(result).toBe(true) 44 | }) 45 | }) -------------------------------------------------------------------------------- /src/Components/EmployeeNode/DropContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { DropTarget } from 'react-dnd' 3 | import propTypes from 'prop-types' 4 | 5 | class DropContainer extends Component { 6 | render() { 7 | const { 8 | children, 9 | className, 10 | isOver, 11 | connectDropTarget 12 | } = this.props 13 | 14 | return connectDropTarget ( 15 |
16 | { children } 17 |
18 | ) 19 | } 20 | } 21 | 22 | DropContainer.propTypes = { 23 | employeeInfo: propTypes.object, 24 | className: propTypes.string, 25 | children: propTypes.any, 26 | isOver: propTypes.bool, 27 | dropAction: propTypes.func, 28 | connectDropTarget: propTypes.func 29 | } 30 | 31 | export default DropTarget ( 32 | // type of item that can be dropped 33 | 'EmployeeNode', 34 | { 35 | // when item is dropped on a target 36 | drop: (props, monitor) => { 37 | // get what is dropped 38 | const item = monitor.getItem() 39 | 40 | // call the drop action 41 | props.dropAction( 42 | item.name, 43 | item.supervisor, 44 | props.employeeInfo.name 45 | ) 46 | } 47 | }, 48 | // props to include in the component 49 | (connect, monitor) => ({ 50 | connectDropTarget: connect.dropTarget(), 51 | isOver: monitor.isOver() 52 | }) 53 | )(DropContainer) 54 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') 2 | var path = require('path') 3 | var HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | // splitting vedor libraries to reduce bundle size 6 | // and take benifit of browser cache mechanism 7 | const VENDOR_LIBS = [ 8 | 'react', 9 | 'react-dom', 10 | 'redux', 11 | 'react-redux' 12 | ] 13 | 14 | module.exports = { 15 | entry: { 16 | bundle: './src/index.js', 17 | vendor: VENDOR_LIBS 18 | }, 19 | output: { 20 | path: path.join(__dirname, 'dist'), 21 | filename: '[name].[chunkhash].js' 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | use: 'babel-loader', 27 | test: /\.js$/, 28 | exclude: /node_modules/ 29 | }, 30 | { 31 | use: ['style-loader', 'css-loader'], 32 | test: /\.css$/ 33 | }, 34 | { 35 | test: /\.(jpe?g|png|gif|svg)$/, 36 | use: [ 37 | { 38 | loader: 'url-loader', 39 | options: { limit: 40000 } 40 | }, 41 | 'image-webpack-loader' 42 | ] 43 | } 44 | ] 45 | }, 46 | plugins: [ 47 | new webpack.DefinePlugin({ 48 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 49 | }), 50 | new webpack.optimize.CommonsChunkPlugin({ 51 | names: ['vendor', 'manifest'] 52 | }), 53 | new HtmlWebpackPlugin({ 54 | template: 'src/index.html' 55 | }) 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "organigram", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "scripts": { 6 | "clean": "rimraf dist", 7 | "build": "NODE_ENV=production npm run clean && webpack -p", 8 | "serve:dev": "webpack-dev-server", 9 | "serve": "http-server dist", 10 | "test": "jest --verbose" 11 | }, 12 | "license": "MIT", 13 | "private": true, 14 | "devDependencies": { 15 | "babel-core": "^6.17.0", 16 | "babel-jest": "^23.4.2", 17 | "babel-loader": "^6.2.0", 18 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 19 | "babel-preset-env": "^1.1.4", 20 | "babel-preset-react": "^6.16.0", 21 | "css-loader": "^0.26.1", 22 | "eslint": "^5.3.0", 23 | "eslint-plugin-react": "^7.10.0", 24 | "html-webpack-plugin": "^2.24.1", 25 | "image-webpack-loader": "^4.3.1", 26 | "jest": "^23.5.0", 27 | "regenerator-runtime": "^0.12.1", 28 | "rimraf": "^2.5.4", 29 | "style-loader": "^0.13.1", 30 | "url-loader": "^1.0.1", 31 | "webpack": "^2.2.0-rc.0", 32 | "webpack-dev-server": "^2.2.0-rc.0" 33 | }, 34 | "dependencies": { 35 | "http-server": "^0.11.1", 36 | "prop-types": "^15.6.2", 37 | "react": "^16.4.2", 38 | "react-dnd": "^5.0.0", 39 | "react-dnd-html5-backend": "^5.0.1", 40 | "react-dom": "^16.4.2", 41 | "react-dropzone": "^4.3.0", 42 | "react-redux": "^4.4.6", 43 | "react-router": "^3.0.0", 44 | "redux": "^3.6.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/utilities/deNormalizeDataStructure.js: -------------------------------------------------------------------------------- 1 | /** 2 | * recursive call for denormazlising the data structure 3 | * @param {string} currentEmployeeId current id of the tree node 4 | * @param {object} employeeList list of normalized employee object 5 | * @param {object} supervisorRef reference to supervisor of current node 6 | */ 7 | const denormalize = (currentEmployeeId, employeeList, supervisorRef = {}) => { 8 | const position = employeeList[currentEmployeeId].position 9 | const subordinates = Object.keys(employeeList[currentEmployeeId].employees) 10 | 11 | // structure the employee object 12 | const employeeObj = { 13 | [currentEmployeeId]: { 14 | position, 15 | employees: subordinates.map((eachSubordinateId) => { 16 | // recursive call for denormalizing the subordinates 17 | return denormalize( 18 | eachSubordinateId, 19 | employeeList, 20 | employeeObj 21 | ) 22 | }) 23 | } 24 | } 25 | 26 | // supervisor refence to current node 27 | supervisorRef = employeeObj 28 | 29 | return supervisorRef 30 | } 31 | 32 | /** 33 | * de-normalize the data structure of employee list 34 | * for exporting or making api calls 35 | * @param {object} employeeListObj normalized employee list object from redux store 36 | */ 37 | const deNormalizeDataStructure = (employeeListObj) => { 38 | const treeRootId = Object.keys(employeeListObj)[0] 39 | const denormalizedData = denormalize(treeRootId,employeeListObj) 40 | 41 | return denormalizedData 42 | } 43 | 44 | export default deNormalizeDataStructure -------------------------------------------------------------------------------- /src/utilities/__tests__/normalizeDataStructure.js: -------------------------------------------------------------------------------- 1 | import normalizeDataStructure from '../normalizeDataStructure' 2 | import perfectTreeResult from '../mockData/perfectTreeResult' 3 | import perfectTreeDeNormalized from '../mockData/perfectTreeDeNormalized' 4 | import treeWithInvalidStructure from '../mockData/treeWithInvalidStructure' 5 | import treeWithALoop from '../mockData/treeWithALoop' 6 | import treeWithMultipleRoot from '../mockData/treeWithMultipleRoot' 7 | 8 | describe('normalizeDataStructure function', () => { 9 | it('should exists and its a function', () => { 10 | expect(normalizeDataStructure).not.toBeFalsy() 11 | expect(normalizeDataStructure).toBeInstanceOf(Function) 12 | }) 13 | 14 | it('should throw an error if nothing is passed', () => { 15 | expect(() => { normalizeDataStructure() }).toThrow() 16 | }) 17 | 18 | it('should normalize a perfectly structured tree', () => { 19 | const result = normalizeDataStructure(perfectTreeDeNormalized) 20 | expect(result).toEqual(perfectTreeResult) 21 | }) 22 | 23 | it('should throw an error for a tree with invalid structure', () => { 24 | expect(() => { 25 | normalizeDataStructure(treeWithInvalidStructure) 26 | }).toThrow() 27 | }) 28 | 29 | it('should throw an error for a tree with a loop employee object', () => { 30 | expect(() => { 31 | normalizeDataStructure(treeWithALoop) 32 | }).toThrow() 33 | }) 34 | 35 | it('should throw an error for a tree with multiple root employees', () => { 36 | expect(() => { 37 | normalizeDataStructure(treeWithMultipleRoot) 38 | }).toThrow() 39 | }) 40 | }) -------------------------------------------------------------------------------- /src/utilities/isSupervisor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * recursive function to check if currect node match with target employee 3 | * if its matched, it means that the source employee is a supervisor or 4 | * a great-supervisor of the target employee 5 | * 6 | * @param {string} sourceId employee id 7 | * @param {string} targetId id of the employee where the moved employee will included 8 | * @param {object} employeeList nomalized employee object 9 | * @param {Array} matchArray array saving the result of the check 10 | */ 11 | const checkSupervisor = ( 12 | sourceId, 13 | targetId, 14 | employeeList, 15 | matchArray = [] 16 | ) => { 17 | // check if current node id matches target id 18 | if (sourceId === targetId) matchArray.push(true) 19 | else matchArray.push(false) 20 | 21 | // map over the subordinates 22 | const subordinates = Object.keys(employeeList[sourceId].employees) 23 | subordinates.map(each => { 24 | checkSupervisor(each, targetId, employeeList, matchArray) 25 | }) 26 | 27 | return matchArray 28 | } 29 | 30 | /** 31 | * check if the source/dragged employee is a supervisor or 32 | * great supervisor of the targed/dropeed employee 33 | * 34 | * @param {string} sourceId id of the employee that is dragged 35 | * @param {string} targetId id of the employee where the source will get included 36 | * @param {object} employeeList nomalized employee list object 37 | */ 38 | const isSupervisor = (sourceId, targetId, employeeList) => { 39 | let result = false 40 | 41 | const checkArray = checkSupervisor(sourceId, targetId, employeeList) 42 | checkArray.map((eachCheck) => { 43 | if (eachCheck === true) result = true 44 | }) 45 | 46 | return result 47 | } 48 | 49 | export default isSupervisor -------------------------------------------------------------------------------- /src/utilities/normalizeDataStructure.js: -------------------------------------------------------------------------------- 1 | /** 2 | * recursive function to nomalize the data structure 3 | * @param {object} dataTree current node of the data tree 4 | * @param {object} newStructure new data structure 5 | */ 6 | const normalizeData = (dataTree, newStructure) => { 7 | const currentTreeRootId = Object.keys(dataTree)[0] 8 | const currentTreeRoot = dataTree[currentTreeRootId] 9 | 10 | // validate the object structure 11 | if ( 12 | !currentTreeRootId || 13 | !dataTree[currentTreeRootId].position 14 | ) { 15 | throw Error( 16 | 'Sorry, the structure of the JSON file is not valid.' 17 | ) 18 | } 19 | 20 | // check if the employee already in the list 21 | if (newStructure[currentTreeRootId]) { 22 | throw Error( 23 | `Sorry, employee ${currentTreeRootId} is in multiple places in the JSON file.` 24 | ) 25 | } 26 | 27 | // new structure for each employee 28 | newStructure[currentTreeRootId] = { 29 | name: currentTreeRootId, 30 | position: currentTreeRoot.position, 31 | employees: {} 32 | } 33 | 34 | // check if current employee have any subordinate 35 | if (currentTreeRoot.employees) { 36 | currentTreeRoot.employees.map(eachEmployee => { 37 | const employeeId = Object.keys(eachEmployee)[0] 38 | newStructure[currentTreeRootId].employees[employeeId] = true 39 | 40 | // recursive call with the new node of the tree 41 | normalizeData(eachEmployee, newStructure) 42 | }) 43 | } 44 | 45 | return newStructure 46 | } 47 | 48 | /** 49 | * normalize the employee structure for better managing organigram 50 | * and better performance with O(1) complexity when drag and dropped 51 | * @param {object} employeeRawObject raw parsed json employee object 52 | */ 53 | const normalizeDataStructure = (employeeRawObject = {}) => { 54 | const rootLenght = Object.keys(employeeRawObject).length 55 | 56 | // check if the there is multiple root element in the tree 57 | if (rootLenght > 1) { 58 | throw Error( 59 | 'Sorry, the JSON contains multiple core supervisors.' 60 | ) 61 | } 62 | 63 | return normalizeData(employeeRawObject, {}) 64 | } 65 | 66 | export default normalizeDataStructure -------------------------------------------------------------------------------- /src/sample_json.json: -------------------------------------------------------------------------------- 1 | { 2 | "Professor Albus Dumbledore": { 3 | "position": "CEO", 4 | "employees": [ 5 | { 6 | "Professor McGonagall": { 7 | "position": "VP Engineering", 8 | "employees": [ 9 | { 10 | "Harry Potter": { 11 | "position": "Frontend Lead", 12 | "employees": [ 13 | { 14 | "Hermione Granger": { 15 | "position": "Frontend Engineer", 16 | "employees": [] 17 | } 18 | }, 19 | { 20 | "Ron Weasley": { 21 | "position": "Frontend Engineer", 22 | "employees": [] 23 | } 24 | } 25 | ] 26 | } 27 | }, 28 | { 29 | "Ginny Weasley": { 30 | "position": "Art Director", 31 | "employees": [ 32 | { 33 | "Luna Lovegood": { 34 | "position": "UX Designer", 35 | "employees": [] 36 | } 37 | }, 38 | { 39 | "Neville Longbottom": { 40 | "position": "UX Designer", 41 | "employees": [] 42 | } 43 | } 44 | ] 45 | } 46 | } 47 | ] 48 | } 49 | }, 50 | { 51 | "Professor Severus Snape": { 52 | "position": "Director of Digital Business Solution", 53 | "employees": [ 54 | { 55 | "Draco Malfoy": { 56 | "position": "Project Manager", 57 | "employees": [] 58 | } 59 | }, 60 | { 61 | "Bellatrix Lestrange": { 62 | "position": "Project Manager", 63 | "employees": [] 64 | } 65 | }, 66 | { 67 | "Peter Pettigrew": { 68 | "position": "Project Manager", 69 | "employees": [] 70 | } 71 | } 72 | ] 73 | } 74 | } 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Views/Home/styles.css: -------------------------------------------------------------------------------- 1 | .homeContainer { 2 | width: 100vw; 3 | height: 100vh; 4 | 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | align-items: center; 9 | } 10 | 11 | .contentBox { 12 | width: 600px; 13 | height: 350px; 14 | padding: 20px 40px; 15 | margin: 0px 10px; 16 | 17 | display: flex; 18 | flex-direction: column; 19 | justify-content: center; 20 | align-items: center; 21 | 22 | background-color: white; 23 | border-radius: 5px; 24 | box-shadow: var(--content-box-shadow); 25 | } 26 | 27 | .activeFileType { 28 | background-color: rgb(200, 255, 200); 29 | } 30 | 31 | .rejectFileType { 32 | background-color: rgb(255, 200, 255); 33 | } 34 | 35 | .logo { 36 | font-family: 'Pacifico', cursive; 37 | font-size: 36px; 38 | 39 | margin-bottom: 70px; 40 | } 41 | 42 | .info { 43 | padding-left: 10px; 44 | padding-right: 10px; 45 | margin-bottom: 50px; 46 | 47 | font-size: 24px; 48 | text-align: center; 49 | line-height: 1.5em; 50 | color: #444; 51 | } 52 | 53 | /** 54 | * file input styles 55 | */ 56 | .fileInput { 57 | width: 0.1px; 58 | height: 0.1px; 59 | 60 | opacity: 0; 61 | overflow: hidden; 62 | position: absolute; 63 | 64 | z-index: -1; 65 | } 66 | 67 | .fileInput + .inputLabel { 68 | padding: 5px 10px; 69 | 70 | color: var(--theme-color); 71 | font-size: 14px; 72 | font-weight: 500; 73 | letter-spacing: 1px; 74 | 75 | border-radius: 10px; 76 | display: inline-block; 77 | cursor: pointer; 78 | } 79 | 80 | .fileInput:focus + .inputLabel, 81 | .fileInput + .inputLabel:hover { 82 | color: var(--theme-color-deep); 83 | } 84 | 85 | .fileInput:focus + .inputLabel { 86 | outline: 1px dotted #000; 87 | outline: -webkit-focus-ring-color auto 5px; 88 | } 89 | 90 | .dummyDataButton { 91 | margin: 0px; 92 | background: none; 93 | border: none; 94 | outline: none; 95 | 96 | padding: 10px 20px; 97 | cursor: pointer; 98 | color: #aaa; 99 | font-weight: 500; 100 | 101 | transition: color 0.3s; 102 | } 103 | 104 | .dummyDataButton:hover { 105 | color: #444; 106 | transition: color 0.3s; 107 | } 108 | 109 | /** 110 | * error message styling 111 | */ 112 | .errorMessage { 113 | width: 500px; 114 | margin: 20px 0px; 115 | 116 | font-size: 14px; 117 | font-weight: 500; 118 | letter-spacing: 0.5px; 119 | text-align: center; 120 | } -------------------------------------------------------------------------------- /src/Components/EmployeeNode/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * container styles 3 | */ 4 | .employeeNodeContainer { 5 | position: relative; 6 | flex: 1; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .employeeNodeRow { 12 | display: flex; 13 | align-items: center; 14 | margin: 6px 0px; 15 | } 16 | 17 | .employeeNodeRow::before { 18 | content: ''; 19 | position: absolute; 20 | left: -29px; 21 | width: 27px; 22 | height: 1px; 23 | border-top: 1px dashed #ddd; 24 | } 25 | 26 | .employeeSubordinatesContainer { 27 | margin-left: 10px; 28 | padding-left: 30px; 29 | border-left: 1px dashed #ddd; 30 | } 31 | 32 | /* .employeeSubordinatesContainer:last-child { 33 | border-left: 1px dashed #fff; 34 | } */ 35 | 36 | /** 37 | * collapse icon styles 38 | */ 39 | .collapseIconContainer { 40 | position: absolute; 41 | left: -44px; 42 | 43 | width: 28px; 44 | height: 28px; 45 | 46 | display: flex; 47 | justify-content: center; 48 | align-items: center; 49 | 50 | transform: rotate(90deg); 51 | transition: all 0.3s; 52 | 53 | cursor: pointer; 54 | border-radius: 15px; 55 | background-color: white; 56 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.1); 57 | } 58 | 59 | .collapseIconContainer:hover { 60 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.3); 61 | } 62 | 63 | .collapseIcon { 64 | width: 16px; 65 | height: 16px; 66 | } 67 | 68 | .collapsed { 69 | transform: rotate(0deg); 70 | } 71 | 72 | /** 73 | * node styles 74 | */ 75 | .employeeInfoContainer { 76 | display: flex; 77 | flex-direction: column; 78 | justify-content: center; 79 | 80 | padding: 10px 40px 10px 16px; 81 | border: 1px solid rgba(0, 0, 0, 0.1); 82 | border-radius: 10px; 83 | background-color: rgba(0, 0, 0, 0); 84 | 85 | cursor: move; 86 | transition: all 0.3s; 87 | } 88 | 89 | .employeeInfoContainer:hover { 90 | /* background-color: aqua; */ 91 | /* background-color: rgba(0, 0, 0, 0.06); */ 92 | background-color: aquamarine; 93 | } 94 | 95 | .employeeName { 96 | margin-bottom: 2px; 97 | 98 | font-size: 18px; 99 | letter-spacing: 0.5px; 100 | } 101 | 102 | .employeePosition { 103 | font-size: 12px; 104 | font-weight: 500; 105 | letter-spacing: 0.5px; 106 | color: #444; 107 | } 108 | 109 | .dropContainer { 110 | display: flex; 111 | border-radius: 10px; 112 | background-color: rgba(0, 0, 0, 0); 113 | transition: all 0.3s; 114 | } 115 | 116 | .dropBackground { 117 | background-color: aquamarine; 118 | } -------------------------------------------------------------------------------- /src/Components/EmployeeNode/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import propTypes from 'prop-types' 3 | 4 | import DragContainer from './DragContainer' 5 | import DropContainer from './DropContainer' 6 | import arrowIcon from './assets/arrow.svg' 7 | import './styles.css' 8 | 9 | class EmployeeNode extends Component { 10 | constructor(props) { 11 | super(props) 12 | this.state = { 13 | collapsed: false, 14 | } 15 | } 16 | 17 | renderCollapseIcon() { 18 | const { collapsed } = this.state 19 | const { subordinates } = this.props 20 | 21 | // check if the employee have any subordinates 22 | if (subordinates.length === 0) return null 23 | 24 | return ( 25 |
{ this.setState({ collapsed: !collapsed }) }} 28 | > 29 | 30 |
31 | ) 32 | } 33 | 34 | render() { 35 | const { 36 | name, 37 | position, 38 | supervisor, 39 | moveEmployee, 40 | subordinates, 41 | } = this.props 42 | 43 | return ( 44 |
45 |
46 | { this.renderCollapseIcon() } 47 | 48 | { 56 | moveEmployee ( 57 | draggedEmployeeId, 58 | supervisorId, 59 | droppedEmployeeId 60 | ) 61 | }} 62 | > 63 | 67 |
{ name }
68 |
{ position }
69 |
70 |
71 |
72 | 73 |
74 | { !this.state.collapsed && subordinates } 75 |
76 |
77 | ) 78 | } 79 | } 80 | 81 | EmployeeNode.propTypes = { 82 | name: propTypes.string, 83 | position: propTypes.string, 84 | supervisor: propTypes.string, 85 | subordinates: propTypes.any, 86 | moveEmployee: propTypes.func, 87 | connectDragSource: propTypes.func 88 | } 89 | 90 | export default EmployeeNode -------------------------------------------------------------------------------- /src/Views/Home/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Dropzone from 'react-dropzone' 3 | import { connect } from 'react-redux' 4 | import propTypes from 'prop-types' 5 | 6 | import sampleJsonData from '../../sample_json.json' 7 | import normalizeDataStructure from '../../utilities/normalizeDataStructure' 8 | import { updateEmployeeData } from '../../globals/actions' 9 | import './styles.css' 10 | 11 | class HomeView extends Component { 12 | constructor(props) { 13 | super(props) 14 | this.state = { 15 | error: '' 16 | } 17 | } 18 | 19 | hanldleFileInput(rawData) { 20 | const { updateEmployeeData } = this.props 21 | 22 | // using FileReader to read the raw data 23 | const fileReader = new FileReader() 24 | 25 | fileReader.onload = (event) => { 26 | try { 27 | // parse the json data 28 | const employeeData = JSON.parse(event.target.result) 29 | 30 | // normalize the data for better performance and data validation 31 | const normalizedData = normalizeDataStructure(employeeData) 32 | 33 | // update redux state with the employee data 34 | updateEmployeeData(normalizedData) 35 | 36 | // push to organigram screen 37 | this.props.router.push('organigram_view') 38 | } catch(error) { 39 | this.setState({ error: error.message }) 40 | } 41 | } 42 | 43 | fileReader.readAsText(rawData) 44 | } 45 | 46 | handleFileDrop(files) { 47 | // first item in the files array is the file 48 | // since we defined multiple to false 49 | if (files[0]) this.hanldleFileInput(files[0]) 50 | } 51 | 52 | parseDemoData() { 53 | const { updateEmployeeData } = this.props 54 | 55 | // normalize the data for better performance and data validation 56 | const normalizedData = normalizeDataStructure(sampleJsonData) 57 | 58 | // update redux state with the employee data 59 | updateEmployeeData(normalizedData) 60 | 61 | // push to organigram screen 62 | this.props.router.push('organigram_view') 63 | } 64 | 65 | render() { 66 | const { error } = this.state 67 | 68 | return ( 69 |
70 | 79 |
80 | Hi Personia, drop a JSON file here
81 | To view the organigram 82 |
83 | 84 |
85 | organigram 86 |
87 | 88 | { 95 | event.preventDefault() 96 | this.hanldleFileInput(event.target.files[0]) 97 | }} 98 | /> 99 | 105 | 111 |
112 | 113 |
114 | { error } 115 |
116 |
117 | ) 118 | } 119 | } 120 | 121 | HomeView.propTypes = { 122 | updateEmployeeData: propTypes.func, 123 | router: propTypes.object, 124 | } 125 | 126 | export default connect( 127 | null, 128 | (dispatch) => ({ 129 | updateEmployeeData: (data) => { 130 | dispatch(updateEmployeeData(data)) 131 | } 132 | }) 133 | )(HomeView) 134 | -------------------------------------------------------------------------------- /src/Views/Organigram/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import HTML5Backend from 'react-dnd-html5-backend' 3 | import { DragDropContext } from 'react-dnd' 4 | import { connect } from 'react-redux' 5 | import propTypes from 'prop-types' 6 | 7 | import EmployeeNode from '../../Components/EmployeeNode' 8 | import isSupervisor from '../../utilities/isSupervisor' 9 | import moveEmployee from '../../utilities/moveEmployee' 10 | import exportJson from '../../utilities/exportJson' 11 | import { updateEmployeeData } from '../../globals/actions' 12 | import './styles.css' 13 | 14 | const DEFAULT_MESSAGE = 'You can drag and drop employees to re-arrange the structure' 15 | 16 | class OrganigramView extends Component { 17 | constructor(props) { 18 | super(props) 19 | this.state = { 20 | message: DEFAULT_MESSAGE, 21 | } 22 | } 23 | 24 | handleBackPress() { 25 | const { router } = this.props 26 | router.goBack() 27 | } 28 | 29 | handleMoveEmployee ( 30 | draggedEmployeeId, 31 | dragSupervisorId, 32 | droppedEmployeeId 33 | ) { 34 | const { 35 | employeeData, 36 | updateEmployeeData 37 | } = this.props 38 | 39 | // check if the dragged employee is supervisor 40 | // or great-supervisor 😁 of the dropeed employee 41 | const supervisor = isSupervisor ( 42 | draggedEmployeeId, 43 | droppedEmployeeId, 44 | employeeData 45 | ) 46 | 47 | if (supervisor) { 48 | this.setState({ 49 | message: `Sorry, ${draggedEmployeeId} cannot be on ${droppedEmployeeId}'s team.` 50 | }) 51 | } else { 52 | // perfom move operation 53 | const newEmployeeData = moveEmployee ( 54 | draggedEmployeeId, 55 | dragSupervisorId, 56 | droppedEmployeeId, 57 | employeeData 58 | ) 59 | 60 | // save new data to store 61 | updateEmployeeData(newEmployeeData) 62 | 63 | // give user some insight 64 | this.setState({ 65 | message: `${draggedEmployeeId} successfully joined ${droppedEmployeeId}'s team` 66 | }) 67 | } 68 | } 69 | 70 | renderTree ( 71 | currentEmployeeId, 72 | employeeData, 73 | supervisorId 74 | ) { 75 | const { 76 | name, 77 | position, 78 | } = employeeData[currentEmployeeId] 79 | 80 | // get all subordinates for current employee to map over them 81 | const subordinates = Object.keys( 82 | employeeData[currentEmployeeId].employees 83 | ) 84 | 85 | return ( 86 | { 93 | return this.renderTree ( 94 | eachEmployeeId, 95 | employeeData, 96 | currentEmployeeId 97 | ) 98 | }) 99 | } 100 | moveEmployee={( 101 | draggedEmployeeId, 102 | draggedEmployeeSupervisorId, 103 | droppedEmployeeId 104 | ) => { 105 | this.handleMoveEmployee ( 106 | draggedEmployeeId, 107 | draggedEmployeeSupervisorId, 108 | droppedEmployeeId 109 | ) 110 | }} 111 | /> 112 | ) 113 | } 114 | 115 | render() { 116 | const { message } = this.state 117 | const { employeeData } = this.props 118 | const firstEmployeeId = Object.keys(employeeData)[0] 119 | 120 | return ( 121 |
122 |
123 |
organigram
124 |
125 |
{ this.handleBackPress() }} 128 | > 129 | Home 130 |
131 |
{ exportJson(employeeData) }} 134 | > 135 | Export 136 |
137 |
138 |
139 | 140 |
141 |
142 | { message } 143 |
144 | 145 |
146 | { !firstEmployeeId && ( 147 | 148 | No JSON data available, please go back to Home screen and upload a valid json file. 149 | 150 | ) } 151 | 152 | { firstEmployeeId && this.renderTree ( 153 | firstEmployeeId, 154 | employeeData 155 | ) } 156 |
157 |
158 |
159 | ) 160 | } 161 | } 162 | 163 | OrganigramView.propTypes = { 164 | employeeData: propTypes.object, 165 | updateEmployeeData: propTypes.func, 166 | router: propTypes.object, 167 | } 168 | 169 | // enable drag and drop for the current view 170 | const enableDND = DragDropContext(HTML5Backend)(OrganigramView) 171 | 172 | export default connect ( 173 | (state) => ({ 174 | employeeData: state.global.employeeData 175 | }), 176 | (dispatch) => ({ 177 | updateEmployeeData: (data) => { 178 | dispatch(updateEmployeeData(data)) 179 | } 180 | }) 181 | )(enableDND) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![love](https://forthebadge.com/images/badges/built-with-love.svg) 2 | ![javascript](https://forthebadge.com/images/badges/made-with-javascript.svg) 3 | 4 | # Organigram 5 | A JSON based tree structure with drag and drop functionally to re-arrange the tree. Show-cases some useful tree operations for deeply nested JSON data and webpack configuration for reducing bundle sizes. 6 | 7 | ![preview1](./previews/preview_1.png) 8 | ![preview](./previews/preview_2.png) 9 | 10 | ## Live 11 | You can try the app at [http://organigram.surge.sh](http://organigram.surge.sh). A sample JSON data for testing the functionality can be found [here](./src/sample_json.json). 12 | 13 | ## Technology Stack 14 | * [react](https://reactjs.org/) 15 | * [react-router](https://github.com/ReactTraining/react-router) 16 | * [react-dnd](https://github.com/react-dnd/react-dnd) 17 | * [redux](https://redux.js.org/) 18 | * [jest](https://jestjs.io/) 19 | * [webpack](https://webpack.js.org/) 20 | * [babel](https://babeljs.io/) 21 | * [eslint](https://eslint.org/) 22 | 23 | ## Running on local machine 24 | There are couple of ways to run the project. One is to run the production code server, and another one is to run the webpack-dev-server for developing. Either way, we need to install the libraries first. 🤓 25 | 26 | ### Building the project 27 | To install the libraries, please run the following command: 28 | ``` 29 | npm install 30 | ``` 31 | 32 | Now, we need to build our project using the following command: 33 | ``` 34 | npm run build 35 | ``` 36 | 37 | There will be some useful information from `webpack` library like bundle and vendor sizes. 38 | 39 | ### Running local server 40 | To run the project with distribution code, we need to perform the following command: 41 | ``` 42 | npm run serve 43 | ``` 44 | This command will run the server on localhost with a random port number using the library `http-server`. The host and port number can be found in the generated output of this command. 45 | 46 | ### Running development server 47 | To run the development server with live-reload support, we need perform the following command: 48 | ``` 49 | npm run serve:dev 50 | ``` 51 | 52 | ### Running the tests 53 | There is a set of unit test provided with the application. To run the tests, please perform the following command: 54 | ``` 55 | npm run test 56 | ``` 57 | 58 | That's it! You're now running Organigram! 🍻 👍 👏🏿 🤞🏾 🤙🏼 🎉 59 | 60 | ## Code Strategies 61 | There are some useful techniques used in the application to increase the performance and fast loading for user. 62 | 63 | ### Code splitting 64 | The `react-router` configuration utilizes code splitting mechanism for only loading necessery JavaScript code for certain views. 65 | ```js 66 | { 67 | path: 'organigram_view', 68 | getComponent(location, cb) { 69 | // async call for loading the view 70 | System.import('./Views/Organigram').then( 71 | module => cb(null, module.default) 72 | ) 73 | } 74 | }, 75 | ``` 76 | As you might notice the `System.import` call for the required component, which notifies webpack to split the code for this view and make an async call when this view loads in the browser. Webpack genrates different files for related code and only loads required JavaScript file when the view/route is displayed. 77 | 78 | ### Data Normalization 79 | As the JSON tree is structured with deeply nested employee objects, its costly to perform any move/drag-drop operations in a tree. It generally requires O(n) or O(n-square) complexity. I have performed a normalization operation when the JSON is uploaded and then saved it to redux-store. As an example, let's consider the following JSON structure: 80 | ```json 81 | { 82 | "Professor Albus Dumbledore": { 83 | "position": "CEO", 84 | "employees": [ 85 | { 86 | "Professor McGonagall": { 87 | "position": "VP Engineering", 88 | "employees": [ 89 | { 90 | "Harry Potter": { 91 | "position": "Frontend Engineer", 92 | "employees": [] 93 | } 94 | }, 95 | { 96 | "Ginny Weasley": { 97 | "position": "Backend Engineer", 98 | "employees": [] 99 | } 100 | } 101 | ] 102 | } 103 | } 104 | ] 105 | } 106 | } 107 | ``` 108 | The normalization operation will convert the structure into the following structure: 109 | ```json 110 | { 111 | "Professor Albus Dumbledore": { 112 | "name": "Professor Albus Dumbledore", 113 | "position": "CEO", 114 | "employees": { 115 | "Professor McGonagall": true 116 | } 117 | }, 118 | "Professor McGonagall": { 119 | "name": "Professor McGonagall", 120 | "position": "VP Engineering", 121 | "employees": { 122 | "Harry Potter": true, 123 | "Ginny Weasley": true, 124 | } 125 | }, 126 | "Harry Potter": { 127 | "name": "Harry Potter", 128 | "position": "Frontend Engineer", 129 | "employees": {} 130 | }, 131 | "Ginny Weasley": { 132 | "name": "Ginny Weasley", 133 | "position": "Backend Engineer", 134 | "employees": {} 135 | } 136 | } 137 | ``` 138 | 139 | This makes move/drag-drop operations more performant with O(1) complexity. 140 | ```javascript 141 | // remove the employee from its current supervisor 142 | delete newEmployeeList[sourceSupervisorId].employees[sourceId] 143 | 144 | // add the employee to its new supervisor 145 | newEmployeeList[destinationId].employees[sourceId] = true 146 | ``` 147 | 148 | If user wants to export the JSON structure for futher usage or we need to perform an API call at some point, I've also added the logic for de-normalizing the structure. 149 | 150 | ## LICENSE 151 | MIT. Anything you would like to do. 152 | -------------------------------------------------------------------------------- /src/globals/styles/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Correct the font size and margin on `h1` elements within `section` and 29 | * `article` contexts in Chrome, Firefox, and Safari. 30 | */ 31 | 32 | h1 { 33 | font-size: 2em; 34 | margin: 0.67em 0; 35 | } 36 | 37 | /* Grouping content 38 | ========================================================================== */ 39 | 40 | /** 41 | * 1. Add the correct box sizing in Firefox. 42 | * 2. Show the overflow in Edge and IE. 43 | */ 44 | 45 | hr { 46 | box-sizing: content-box; /* 1 */ 47 | height: 0; /* 1 */ 48 | overflow: visible; /* 2 */ 49 | } 50 | 51 | /** 52 | * 1. Correct the inheritance and scaling of font size in all browsers. 53 | * 2. Correct the odd `em` font sizing in all browsers. 54 | */ 55 | 56 | pre { 57 | font-family: monospace, monospace; /* 1 */ 58 | font-size: 1em; /* 2 */ 59 | } 60 | 61 | /* Text-level semantics 62 | ========================================================================== */ 63 | 64 | /** 65 | * Remove the gray background on active links in IE 10. 66 | */ 67 | 68 | a { 69 | background-color: transparent; 70 | } 71 | 72 | /** 73 | * 1. Remove the bottom border in Chrome 57- 74 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 75 | */ 76 | 77 | abbr[title] { 78 | border-bottom: none; /* 1 */ 79 | text-decoration: underline; /* 2 */ 80 | text-decoration: underline dotted; /* 2 */ 81 | } 82 | 83 | /** 84 | * Add the correct font weight in Chrome, Edge, and Safari. 85 | */ 86 | 87 | b, 88 | strong { 89 | font-weight: bolder; 90 | } 91 | 92 | /** 93 | * 1. Correct the inheritance and scaling of font size in all browsers. 94 | * 2. Correct the odd `em` font sizing in all browsers. 95 | */ 96 | 97 | code, 98 | kbd, 99 | samp { 100 | font-family: monospace, monospace; /* 1 */ 101 | font-size: 1em; /* 2 */ 102 | } 103 | 104 | /** 105 | * Add the correct font size in all browsers. 106 | */ 107 | 108 | small { 109 | font-size: 80%; 110 | } 111 | 112 | /** 113 | * Prevent `sub` and `sup` elements from affecting the line height in 114 | * all browsers. 115 | */ 116 | 117 | sub, 118 | sup { 119 | font-size: 75%; 120 | line-height: 0; 121 | position: relative; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -0.25em; 127 | } 128 | 129 | sup { 130 | top: -0.5em; 131 | } 132 | 133 | /* Embedded content 134 | ========================================================================== */ 135 | 136 | /** 137 | * Remove the border on images inside links in IE 10. 138 | */ 139 | 140 | img { 141 | border-style: none; 142 | } 143 | 144 | /* Forms 145 | ========================================================================== */ 146 | 147 | /** 148 | * 1. Change the font styles in all browsers. 149 | * 2. Remove the margin in Firefox and Safari. 150 | */ 151 | 152 | button, 153 | input, 154 | optgroup, 155 | select, 156 | textarea { 157 | font-family: inherit; /* 1 */ 158 | font-size: 100%; /* 1 */ 159 | line-height: 1.15; /* 1 */ 160 | margin: 0; /* 2 */ 161 | } 162 | 163 | /** 164 | * Show the overflow in IE. 165 | * 1. Show the overflow in Edge. 166 | */ 167 | 168 | button, 169 | input { /* 1 */ 170 | overflow: visible; 171 | } 172 | 173 | /** 174 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 175 | * 1. Remove the inheritance of text transform in Firefox. 176 | */ 177 | 178 | button, 179 | select { /* 1 */ 180 | text-transform: none; 181 | } 182 | 183 | /** 184 | * Correct the inability to style clickable types in iOS and Safari. 185 | */ 186 | 187 | button, 188 | [type="button"], 189 | [type="reset"], 190 | [type="submit"] { 191 | -webkit-appearance: button; 192 | } 193 | 194 | /** 195 | * Remove the inner border and padding in Firefox. 196 | */ 197 | 198 | button::-moz-focus-inner, 199 | [type="button"]::-moz-focus-inner, 200 | [type="reset"]::-moz-focus-inner, 201 | [type="submit"]::-moz-focus-inner { 202 | border-style: none; 203 | padding: 0; 204 | } 205 | 206 | /** 207 | * Restore the focus styles unset by the previous rule. 208 | */ 209 | 210 | button:-moz-focusring, 211 | [type="button"]:-moz-focusring, 212 | [type="reset"]:-moz-focusring, 213 | [type="submit"]:-moz-focusring { 214 | outline: 1px dotted ButtonText; 215 | } 216 | 217 | /** 218 | * Correct the padding in Firefox. 219 | */ 220 | 221 | fieldset { 222 | padding: 0.35em 0.75em 0.625em; 223 | } 224 | 225 | /** 226 | * 1. Correct the text wrapping in Edge and IE. 227 | * 2. Correct the color inheritance from `fieldset` elements in IE. 228 | * 3. Remove the padding so developers are not caught out when they zero out 229 | * `fieldset` elements in all browsers. 230 | */ 231 | 232 | legend { 233 | box-sizing: border-box; /* 1 */ 234 | color: inherit; /* 2 */ 235 | display: table; /* 1 */ 236 | max-width: 100%; /* 1 */ 237 | padding: 0; /* 3 */ 238 | white-space: normal; /* 1 */ 239 | } 240 | 241 | /** 242 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 243 | */ 244 | 245 | progress { 246 | vertical-align: baseline; 247 | } 248 | 249 | /** 250 | * Remove the default vertical scrollbar in IE 10+. 251 | */ 252 | 253 | textarea { 254 | overflow: auto; 255 | } 256 | 257 | /** 258 | * 1. Add the correct box sizing in IE 10. 259 | * 2. Remove the padding in IE 10. 260 | */ 261 | 262 | [type="checkbox"], 263 | [type="radio"] { 264 | box-sizing: border-box; /* 1 */ 265 | padding: 0; /* 2 */ 266 | } 267 | 268 | /** 269 | * Correct the cursor style of increment and decrement buttons in Chrome. 270 | */ 271 | 272 | [type="number"]::-webkit-inner-spin-button, 273 | [type="number"]::-webkit-outer-spin-button { 274 | height: auto; 275 | } 276 | 277 | /** 278 | * 1. Correct the odd appearance in Chrome and Safari. 279 | * 2. Correct the outline style in Safari. 280 | */ 281 | 282 | [type="search"] { 283 | -webkit-appearance: textfield; /* 1 */ 284 | outline-offset: -2px; /* 2 */ 285 | } 286 | 287 | /** 288 | * Remove the inner padding in Chrome and Safari on macOS. 289 | */ 290 | 291 | [type="search"]::-webkit-search-decoration { 292 | -webkit-appearance: none; 293 | } 294 | 295 | /** 296 | * 1. Correct the inability to style clickable types in iOS and Safari. 297 | * 2. Change font properties to `inherit` in Safari. 298 | */ 299 | 300 | ::-webkit-file-upload-button { 301 | -webkit-appearance: button; /* 1 */ 302 | font: inherit; /* 2 */ 303 | } 304 | 305 | /* Interactive 306 | ========================================================================== */ 307 | 308 | /* 309 | * Add the correct display in Edge, IE 10+, and Firefox. 310 | */ 311 | 312 | details { 313 | display: block; 314 | } 315 | 316 | /* 317 | * Add the correct display in all browsers. 318 | */ 319 | 320 | summary { 321 | display: list-item; 322 | } 323 | 324 | /* Misc 325 | ========================================================================== */ 326 | 327 | /** 328 | * Add the correct display in IE 10+. 329 | */ 330 | 331 | template { 332 | display: none; 333 | } 334 | 335 | /** 336 | * Add the correct display in IE 10. 337 | */ 338 | 339 | [hidden] { 340 | display: none; 341 | } --------------------------------------------------------------------------------