├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .storybook ├── addons.js ├── config.js └── storybook-config.json.template ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── fileMock.js ├── getConfig.js └── styleMock.js ├── package.json ├── register.js ├── src ├── api │ ├── db.js │ ├── index.js │ └── slack.js ├── components │ ├── comment │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ ├── index.js │ │ ├── story.js │ │ ├── styles.css │ │ └── test.js │ ├── comments │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ ├── index.js │ │ ├── styles.css │ │ └── test.js │ ├── onlineIndicator │ │ ├── __snapshots__ │ │ │ └── test.js.snap │ │ ├── index.js │ │ ├── styles.css │ │ └── test.js │ ├── panel │ │ ├── index.js │ │ └── styles.css │ ├── register │ │ ├── index.js │ │ ├── story.js │ │ └── styles.css │ └── submitComment │ │ ├── index.js │ │ └── styles.css ├── register.js └── utils │ ├── config.js │ ├── createHash.js │ ├── getConfig.js │ ├── getTimestamp.js │ ├── http.js │ ├── index.js │ ├── url.js │ └── webStorage.js ├── stub └── comments.Button.js ├── test ├── api │ ├── index.test.js │ └── slack.test.js ├── mocha.opts └── support.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | reports 3 | dist 4 | .storybook 5 | src/utils/createHash.js 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb"], 3 | "parser": "babel-eslint", 4 | "env": { 5 | "es6": true, 6 | "browser": true, 7 | "node": true, 8 | "jest": true 9 | }, 10 | "rules": { 11 | "import/prefer-default-export": 0, 12 | "react/forbid-prop-types": 1, 13 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 14 | "react/require-default-props": 1, 15 | "comma-dangle": 0, 16 | "no-underscore-dangle": 0, 17 | "no-plusplus": 0, 18 | "no-unused-expressions": ["error", { "allowShortCircuit": true }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | dist 4 | .idea/ 5 | .DS_Store 6 | .vscode 7 | storybook-config.json 8 | storybook-static 9 | reports 10 | .nyc_output/ 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | example 3 | src 4 | stub 5 | .babelrc 6 | .editorconfig 7 | .eslintignore 8 | .eslintrc 9 | config/blabbr-config.js 10 | __mocks__ 11 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addons'; 2 | import '@storybook/addon-knobs/register'; 3 | import '../src/register'; 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configure, setAddon, addDecorator } from '@storybook/react'; 3 | import { withKnobs } from '@storybook/addon-knobs'; 4 | 5 | addDecorator(withKnobs); 6 | 7 | // Now go through all the stories in the src tree 8 | function requireAll(context) { 9 | return context.keys().map(context) 10 | } 11 | 12 | function loadStories() { 13 | requireAll(require.context('../src/', true, /.+\/story.js$/)); 14 | } 15 | 16 | configure(loadStories, module); 17 | -------------------------------------------------------------------------------- /.storybook/storybook-config.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "storybook": { 3 | "blabbr": { 4 | "db": { 5 | "user": "", 6 | "pwd": "", 7 | "host": "" 8 | }, 9 | "slack": { 10 | "endPoint": "" 11 | }, 12 | "ui": { 13 | "avatar": true 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are more than welcome. Please fork the repo and then submit a pull request. 4 | 5 | You may want to look at the issues to check if anything you are interested in is currently under development. 6 | 7 | For now we don't have any other formal requirements. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Buildit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # storybook-addon-blabbr 2 | 3 | Component reviewer and approver for React Storybook. 4 | 5 | ## Configuration 6 | 7 | blabbr expects to find a `storybook-config.json` configuration file at the root of your host, for static builds, or inside your storybook setup folder (generally `.storybook`). 8 | 9 | Fill out the template located at `.storybook/storybook-config.json.template` and rename it to `storybook-config.json`. 10 | 11 | This should look like this: 12 | 13 | ``` 14 | { 15 | "storybook": { 16 | "blabbr": { 17 | "db": { 18 | "user": "username", 19 | "pwd": "password", 20 | "host": "db-endpoint" 21 | }, 22 | "slack": { 23 | "endPoint": "http://your-slack-endpoint" 24 | }, 25 | "ui": { 26 | "avatar": true 27 | } 28 | } 29 | } 30 | } 31 | ``` 32 | 33 | The host for the blabbr db should be `localhost:5984/blabbr` if using local CouchDB (see below). User and pwd can be omitted in that case. 34 | 35 | ## Storybook registration 36 | 37 | To use the plugin you need to register it, like most Storybook plugins. Simply add the following to your `addons.js` file in the storybook configuration: 38 | 39 | `import '@buildit/storybook-addon-blabbr/register';` 40 | 41 | ## Comment formatting 42 | 43 | Comments are formatted using the [marked](https://www.npmjs.com/package/marked) package. 44 | 45 | ## Local CouchDB setup 46 | 47 | [Install Apache CouchDB](http://couchdb.apache.org/) by downloading the respective installer for your OS. 48 | 49 | Once installation is done, navigate to [http://localhost:5984](http://localhost:5984). Create an admin username and password. 50 | 51 | Go to configuration and CORS and enable CORS. 52 | 53 | Add the correct values to the `storybook-config.json` as mentioned above. 54 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /__mocks__/getConfig.js: -------------------------------------------------------------------------------- 1 | const configFile = require('../.storybook/storybook-config.json').storybook; 2 | 3 | const getConfig = () => 4 | new Promise(resolve => { 5 | resolve(configFile); 6 | }); 7 | 8 | export default getConfig; 9 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@buildit/storybook-addon-blabbr", 3 | "version": "1.1.4", 4 | "description": "Component review and commenting for React Storybook", 5 | "main": "dist/register.js", 6 | "scripts": { 7 | "start": "npm run storybook", 8 | "storybook": "start-storybook -p 9001 -s ./.storybook", 9 | "lint": "./node_modules/prettier/bin/prettier.js --single-quote --list-different '{src,__{tests,mocks}__}/**/*.js'", 10 | "lint-fix": "./node_modules/prettier/bin/prettier.js --single-quote --write '{src,__{tests,mocks}__}/**/*.js'", 11 | "build-storybook": "build-storybook", 12 | "test": "npm run test:jest && npm run test:nyc", 13 | "test:jest": "jest ./src/components --coverage ", 14 | "test:mocha": "mocha ./test", 15 | "test:nyc": "nyc npm run test:mocha", 16 | "build": "webpack --config webpack.config.js", 17 | "build:prod": "webpack -p --config webpack.config.js", 18 | "prepush": "npm run lint && npm run test:jest", 19 | "prepublish": "npm run build:prod" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/buildit/storybook-addon-blabbr.git" 24 | }, 25 | "keywords": [ 26 | "atomic-design", 27 | "blabbr", 28 | "react", 29 | "storybook", 30 | "storybook-addon", 31 | "storybook-addon-blabbr", 32 | "collaboration", 33 | "review" 34 | ], 35 | "author": "Buildit", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/buildit/storybook-addon-blabbr/issues" 39 | }, 40 | "homepage": "https://github.com/buildit/storybook-addon-blabbr#readme", 41 | "devDependencies": { 42 | "@storybook/addon-actions": "^3.1.2", 43 | "@storybook/addon-knobs": "^3.1.2", 44 | "@storybook/addons": "^3.1.2", 45 | "@storybook/react": "^3.1.3", 46 | "babel-cli": "^6.18.0", 47 | "babel-eslint": "^7.2.3", 48 | "babel-jest": "^20.0.3", 49 | "babel-loader": "^7.0.0", 50 | "babel-polyfill": "^6.22.0", 51 | "babel-preset-es2015": "^6.22.0", 52 | "babel-preset-react": "^6.22.0", 53 | "babel-preset-stage-2": "^6.18.0", 54 | "chai": "^4.0.2", 55 | "chai-as-promised": "^7.0.0", 56 | "css-loader": "^0.28.4", 57 | "enzyme": "^2.7.1", 58 | "husky": "^0.13.4", 59 | "jest": "^20.0.4", 60 | "jsdom": "^11.0.0", 61 | "jsdom-global": "^3.0.2", 62 | "json-loader": "^0.5.4", 63 | "mocha": "^3.4.2", 64 | "nock": "^9.0.13", 65 | "node-fetch": "^1.7.1", 66 | "nyc": "^11.0.2", 67 | "pouchdb-browser": "^6.1.1", 68 | "pouchdb-find": "^6.2.0", 69 | "prettier": "^1.4.4", 70 | "prop-types": "^15", 71 | "proxyquire": "^1.8.0", 72 | "react": "^15", 73 | "react-dom": "^15", 74 | "react-test-renderer": "^15.4.2", 75 | "sinon": "^2.3.4", 76 | "sinon-chai": "^2.11.0", 77 | "style-loader": "^0.17.0", 78 | "webpack": "^3.0.0" 79 | }, 80 | "dependencies": { 81 | "events": "^1.1.1", 82 | "extract-text-webpack-plugin": "^3.0.0", 83 | "marked": "^0.3.6", 84 | "react-addons-css-transition-group": "^15.4.2", 85 | "react-alert": "^2.0.1", 86 | "react-tooltip": "^3.2.7" 87 | }, 88 | "peerDependencies": { 89 | "@storybook/addons": "^3.1.2", 90 | "@storybook/react": "^3.1.3", 91 | "css-loader": "^0.28.4", 92 | "prop-types": "^15", 93 | "react": "^15", 94 | "react-dom": "^15", 95 | "style-loader": "^0.17.0" 96 | }, 97 | "jest": { 98 | "coverageDirectory": "reports", 99 | "collectCoverageFrom": [ 100 | "src/components/**/*.{js,jsx}" 101 | ], 102 | "moduleNameMapper": { 103 | "\\.(css|less)$": "/__mocks__/styleMock.js", 104 | "getConfig": "/__mocks__/getConfig.js" 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | require('./dist/register'); 2 | -------------------------------------------------------------------------------- /src/api/db.js: -------------------------------------------------------------------------------- 1 | // Initialize PouchDB 2 | import PouchDB from 'pouchdb-browser'; 3 | import * as pouchDBFind from 'pouchdb-find'; 4 | import { EventEmitter } from 'events'; 5 | import { dbConfig } from '../utils/config'; // eslint-disable-line 6 | 7 | PouchDB.plugin(pouchDBFind); 8 | 9 | // enable debugging 10 | PouchDB.debug.enable('pouchdb:find'); 11 | 12 | const db = new PouchDB('blabbr'); 13 | 14 | const dbEmitter = new EventEmitter(); 15 | 16 | db.createIndex({ 17 | index: { 18 | fields: ['componentId', 'timestamp', 'version'] 19 | } 20 | }); 21 | 22 | dbConfig() 23 | .then(response => { 24 | if (response.host.includes('localhost')) { 25 | db.sync(`http://${response.host}`, { 26 | live: true, 27 | retry: true 28 | }); 29 | } else { 30 | db.sync(`https://${response.user}:${response.pwd}@${response.host}`, { 31 | live: true, 32 | retry: true 33 | }); 34 | } 35 | }) 36 | .catch(response => { 37 | console.log(response); 38 | }); 39 | 40 | const dbEvents = { 41 | change: [], 42 | online: [], 43 | error: [] 44 | }; 45 | 46 | const fireListeners = (eventType, data) => { 47 | let changedDoc = { 48 | eventName: '' 49 | }; 50 | let eventData; 51 | const isStatusEvent = !!data.statusEvent; 52 | 53 | if (eventType === 'change') { 54 | changedDoc = data.doc; 55 | } 56 | 57 | // fire any listeners passing through data 58 | for (let i = 0, l = dbEvents[eventType].length; i < l; i++) { 59 | eventData = dbEvents[eventType][i]; 60 | 61 | if ( 62 | (eventData.eventName === changedDoc.eventName || 63 | isStatusEvent === true) && 64 | eventData.listener 65 | ) { 66 | eventData.listener(data); 67 | } 68 | } 69 | }; 70 | 71 | function updateIndicator() { 72 | // Show a different icon based on offline/online 73 | const data = { 74 | isOnline: navigator.onLine, 75 | statusEvent: true 76 | }; 77 | fireListeners('online', data); 78 | } 79 | 80 | function addOnlineListener() { 81 | // Update the online status icon based on connectivity 82 | window.addEventListener('online', updateIndicator, false); 83 | window.addEventListener('offline', updateIndicator, false); 84 | } 85 | 86 | function removeOnlineListener() { 87 | window.removeEventListener('online', updateIndicator, false); 88 | window.removeEventListener('offline', updateIndicator, false); 89 | } 90 | 91 | dbEmitter.on('change', data => { 92 | fireListeners('change', data); 93 | }); 94 | 95 | const subscribe = (eventType, eventName, listener) => { 96 | if (!dbEvents[eventType]) { 97 | return "No such event type, please use 'change'/'online'/'denied'/'complete'/'error'"; 98 | } 99 | const eventId = `${eventType}${eventName}`; 100 | let eventListener = null; 101 | 102 | if (eventType === 'change') { 103 | eventListener = db 104 | .changes({ 105 | since: 'now', 106 | live: true, 107 | include_docs: true, 108 | filter(doc) { 109 | return doc.eventName === eventName; 110 | } 111 | }) 112 | .on('change', data => { 113 | dbEmitter.emit('change', data); 114 | }) 115 | .on('error', err => { 116 | dbEmitter.emit('error', err); 117 | }); 118 | } 119 | 120 | dbEvents[eventType].push({ eventId, eventListener, eventName, listener }); 121 | 122 | // unique id returned 123 | return eventId; 124 | }; 125 | 126 | const unsubscribe = (eventType, eventId) => { 127 | if (!dbEvents[eventType]) { 128 | return "No such event type, plesae use 'change'/'active'/'denied'/'complete'/'error'"; 129 | } 130 | let eventRemoved = false; 131 | 132 | if (dbEvents[eventType]) { 133 | for (let i = 0, l = dbEvents[eventType].length; i < l; i++) { 134 | if (dbEvents[eventType][i].eventId === eventId) { 135 | if (dbEvents[eventType][i].eventListener) { 136 | dbEvents[eventType][i].eventListener.cancel(); 137 | dbEvents[eventType][i].eventListener.removeAllListeners('change'); 138 | dbEvents[eventType][i].eventListener.removeAllListeners('error'); 139 | dbEvents[eventType][i].eventListener = null; 140 | } 141 | dbEvents[eventType].splice(i, 1); 142 | eventRemoved = true; 143 | break; 144 | } 145 | } 146 | } 147 | return eventRemoved; 148 | }; 149 | 150 | export const dbEventManager = { 151 | subscribe, 152 | unsubscribe, 153 | addOnlineListener, 154 | removeOnlineListener 155 | }; 156 | 157 | export default db; 158 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import db from './db'; 2 | import { 3 | postComment as postSlackComment, 4 | editComment as editSlackComment 5 | } from './slack'; 6 | 7 | // Return all the comments for the particular component and story 8 | // NOTE: Version is ignored for now, all comments are returned 9 | export const getComments = ( 10 | component, 11 | story /* , version = 'version_not_set' */ 12 | ) => 13 | db 14 | .find({ 15 | selector: { 16 | $and: [ 17 | { componentId: component }, 18 | { stateId: story } 19 | // { version }, 20 | ] 21 | }, 22 | sort: [ 23 | { 24 | _id: 'desc' 25 | } 26 | ] 27 | }) 28 | .then(data => ({ 29 | success: true, 30 | ...data 31 | })) 32 | .catch(() => ({ 33 | success: false, 34 | msg: 'No comments available.' 35 | })); 36 | 37 | export const postComment = ({ 38 | timestampId, 39 | userName, 40 | userEmail, 41 | userComment, 42 | component, 43 | story, 44 | version, 45 | eventName 46 | }) => { 47 | const record = { 48 | _id: timestampId, 49 | userName, 50 | userEmail, 51 | componentId: component, 52 | comment: userComment, 53 | timestamp: timestampId, 54 | stateId: story, 55 | version, 56 | edited: false, 57 | lastUpdated: timestampId, 58 | eventName 59 | }; 60 | 61 | postSlackComment({ 62 | userName, 63 | userEmail, 64 | comment: userComment, 65 | componentName: component, 66 | componentUrl: window.location.href 67 | }); 68 | 69 | return db 70 | .put(record) 71 | .then(data => { 72 | if (data.ok) { 73 | return { 74 | success: data.ok, 75 | msg: 'Your comment was added successfully.', 76 | ...record 77 | }; 78 | } 79 | 80 | return Promise.reject(new Error('Request for data was unsuccessful.')); 81 | }) 82 | .catch(error => ({ 83 | success: false, 84 | msg: `There was a problem posting your comment. Error: ${error.message}` 85 | })); 86 | }; 87 | 88 | export const updateComment = ({ 89 | commentId, 90 | component, 91 | userCommentText, 92 | userEmail, 93 | userName 94 | }) => { 95 | const timestampId = `${new Date().getTime()}`; 96 | const userComment = userCommentText && userCommentText.trim(); 97 | 98 | if (!userComment) { 99 | return Promise.resolve({ 100 | success: false, 101 | msg: 'Cannot update with empty comment.' 102 | }); 103 | } 104 | 105 | editSlackComment({ 106 | userName, 107 | userEmail, 108 | comment: userComment, 109 | componentName: component, 110 | componentUrl: window.location.href 111 | }); 112 | 113 | return db 114 | .find({ 115 | selector: { 116 | _id: commentId 117 | } 118 | }) 119 | .then(data => { 120 | const record = data.docs[0]; 121 | 122 | record.comment = userComment; 123 | record.edited = true; 124 | record.lastUpdated = timestampId; 125 | 126 | return db 127 | .put(record) 128 | .then(() => ({ 129 | success: true, 130 | msg: 'Your comment was edited successfully.' 131 | })) 132 | .catch(() => ({ 133 | success: false, 134 | msg: 'There was an error saving your edited comment.' 135 | })); 136 | }) 137 | .catch(() => ({ 138 | success: false, 139 | msg: 'There was an error editing your comment.' 140 | })); 141 | }; 142 | 143 | export const deleteComment = commentId => 144 | db 145 | .find({ 146 | selector: { 147 | _id: commentId 148 | } 149 | }) 150 | .then(data => { 151 | if (data.docs && data.docs.length) { 152 | const record = data.docs[0]; 153 | record._deleted = true; 154 | return db 155 | .put(record) 156 | .then(result => { 157 | if (result.ok) { 158 | return { 159 | success: true, 160 | msg: 'Your comment was removed successfully.' 161 | }; 162 | } 163 | 164 | return Promise.reject(new Error('Deletion unsuccessful.')); 165 | }) 166 | .catch(error => ({ 167 | success: false, 168 | msg: `There was a problem deleting your comment. Error: ${error.message}` 169 | })); 170 | } 171 | 172 | return Promise.reject(new Error('No documents returned.')); 173 | }) 174 | .catch(error => ({ 175 | success: false, 176 | msg: `There was a problem deleting your comment. Not found. Error: ${error.message}` 177 | })); 178 | -------------------------------------------------------------------------------- /src/api/slack.js: -------------------------------------------------------------------------------- 1 | import { slack } from '../utils/config'; 2 | 3 | const getTimestamp = () => Math.floor(Date.now() / 1000); 4 | 5 | const makeRequest = payload => { 6 | const requestHeaders = new Headers(); 7 | 8 | const requestConfig = { 9 | method: 'POST', 10 | headers: requestHeaders, 11 | body: JSON.stringify(payload) 12 | }; 13 | 14 | return slack().then(response => { 15 | return fetch(response.endPoint, requestConfig).then(response => { 16 | return { success: response.ok }; 17 | }); 18 | }); 19 | }; 20 | 21 | export const postComment = ({ 22 | userName, 23 | userEmail, 24 | comment, 25 | componentName, 26 | componentUrl 27 | }) => { 28 | const payload = { 29 | username: 'Blabbr', 30 | attachments: [ 31 | { 32 | fallback: `${userName} added a new comment to ${componentName}.`, 33 | color: '#36a64f', 34 | author_name: `${userName} (${userEmail})`, 35 | title: `Added a new comment to ${componentName}`, 36 | title_link: componentUrl, 37 | text: comment, 38 | ts: getTimestamp() 39 | } 40 | ] 41 | }; 42 | return makeRequest(payload); 43 | }; 44 | 45 | export const editComment = ({ 46 | userName, 47 | userEmail, 48 | comment, 49 | componentName, 50 | componentUrl 51 | }) => { 52 | const payload = { 53 | username: 'Blabbr', 54 | attachments: [ 55 | { 56 | fallback: `${userName} edited their comment on ${componentName}.`, 57 | color: '#ffcc33', 58 | author_name: `${userName} (${userEmail})`, 59 | title: `Edited their comment on ${componentName}`, 60 | title_link: componentUrl, 61 | text: comment, 62 | ts: getTimestamp() 63 | } 64 | ] 65 | }; 66 | 67 | return makeRequest(payload); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/comment/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Comment renders correctly 1`] = ` 4 |
7 |
8 |

9 | username 10 |

11 | 14 | at 15 | 20 | 21 | 22 | 25 | 31 | 38 | 39 |
40 |
This is a comment

44 | ", 45 | } 46 | } 47 | /> 48 |

49 | 50 | (edited - 51 | 2-Jan-17 52 | ) 53 | 54 |

55 |
56 | `; 57 | -------------------------------------------------------------------------------- /src/components/comment/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import marked from 'marked'; 4 | import { createHash } from '../../utils'; 5 | import { ui as uiConfig, versions } from '../../utils/config'; 6 | import './styles.css'; 7 | 8 | const generateLink = (url, regex, target) => { 9 | if (url && regex && target) { 10 | const path = url.pathname; 11 | if (path && path !== '/' && regex) { 12 | const r = new RegExp(regex, 'i'); 13 | const currentVersion = r.exec(path)[1]; 14 | const result = url.pathname.replace(currentVersion, target); 15 | return `${url.protocol}//${url.hostname}:${url.port}${result}${url.search}${url.hash}`; 16 | } 17 | } 18 | 19 | return '#'; 20 | }; 21 | 22 | class Comment extends React.Component { 23 | constructor() { 24 | super(); 25 | 26 | this.state = { 27 | showAvatar: false, 28 | regex: '' 29 | }; 30 | } 31 | 32 | componentWillMount() { 33 | uiConfig().then(response => { 34 | if (response) { 35 | this.setState({ 36 | showAvatar: response.avatar 37 | }); 38 | } 39 | }); 40 | versions().then(response => { 41 | if (response) { 42 | this.setState({ 43 | regex: response.regex 44 | }); 45 | } 46 | }); 47 | } 48 | 49 | render() { 50 | const { 51 | username, 52 | emailId, 53 | timestamp, 54 | comment, 55 | currentUserIsOwner, 56 | commentId, 57 | handleEditUserComment, 58 | handleDeleteUserComment, 59 | edited, 60 | lastUpdated, 61 | version, 62 | activeVersion 63 | } = this.props; 64 | 65 | const emailHash = createHash(emailId); 66 | const output = marked(comment); 67 | const { showAvatar, regex } = this.state; 68 | 69 | const classes = showAvatar ? 'blabbr-comment withAvatar' : 'blabbr-comment'; 70 | let url = ''; 71 | if (window && window.parent) { 72 | url = window.parent.location; 73 | } 74 | 75 | return ( 76 |
77 |
78 |

{`${username}`}

79 | 80 | 81 | at 82 | 83 | 84 | {version && 85 | 86 | about{' '} 87 | {version === activeVersion 88 | ? `v${version}` 89 | : v{version}} 90 | } 91 | 92 | {showAvatar && 93 | {`${username}'s} 98 | 99 | 100 | {!!currentUserIsOwner && 101 | } 104 | {!!currentUserIsOwner && 105 | } 112 | 113 |
114 |
115 | {edited &&

(edited - {lastUpdated})

} 116 |
117 | ); 118 | } 119 | } 120 | 121 | Comment.propTypes = { 122 | emailId: PropTypes.string.isRequired, 123 | username: PropTypes.string.isRequired, 124 | timestamp: PropTypes.string.isRequired, 125 | comment: PropTypes.string.isRequired, 126 | commentId: PropTypes.string.isRequired, 127 | currentUserIsOwner: PropTypes.bool.isRequired, 128 | handleEditUserComment: PropTypes.func.isRequired, 129 | handleDeleteUserComment: PropTypes.func.isRequired, 130 | edited: PropTypes.bool, 131 | lastUpdated: PropTypes.string, 132 | version: PropTypes.string, 133 | activeVersion: PropTypes.string 134 | }; 135 | 136 | Comment.defaultProps = { 137 | edited: false, 138 | lastUpdated: '', 139 | version: '', 140 | activeVersion: '' 141 | }; 142 | 143 | export default Comment; 144 | -------------------------------------------------------------------------------- /src/components/comment/story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { text } from '@storybook/addon-knobs'; // eslint-disable-line 4 | import Comment from './'; 5 | 6 | storiesOf('Comment') 7 | .add('Basic comment', () => 8 | false} 16 | handleDeleteUserComment={() => true} 17 | /> 18 | ) 19 | .add('Long comment', () => 20 | false} 31 | handleDeleteUserComment={() => true} 32 | /> 33 | ); 34 | -------------------------------------------------------------------------------- /src/components/comment/styles.css: -------------------------------------------------------------------------------- 1 | 2 | .blabbr-comment { 3 | /* Box model */ 4 | position: relative; 5 | min-height: 6rem; /* Same as gravatar height + 2x padding! */ 6 | padding: 1rem; 7 | font-family: 8 | -apple-system, 9 | ".SFNSText-Regular", 10 | "San Francisco", 11 | Roboto, 12 | "Segoe UI", 13 | "Helvetica Neue", 14 | "Lucida Grande", 15 | sans-serif; 16 | font-size: 0.8em; 17 | box-sizing: border-box; 18 | width: 100%; 19 | vertical-align: middle; 20 | outline: none; 21 | border: 1px solid rgb(247, 244, 244); 22 | border-radius: 2px; 23 | color: rgb(85, 85, 85); 24 | } 25 | 26 | .blabbr-comment.withAvatar { 27 | padding-left: 6rem; 28 | } 29 | 30 | .blabbr-comment header { 31 | /* Typography */ 32 | font-size: 1em; 33 | } 34 | 35 | .blabbr-comment header h2 { 36 | /* Box model */ 37 | display: inline-block; 38 | margin: 0; 39 | 40 | /* Typography */ 41 | font-size: 1em; 42 | } 43 | 44 | .blabbr-comment header .blabbr-time { 45 | margin-left: 1em; 46 | } 47 | 48 | .blabbr-comment header .blabbr-version { 49 | margin-left: 1em; 50 | } 51 | 52 | .blabbr-comment header .controls { 53 | margin-left: 1em; 54 | } 55 | 56 | .blabbr-comment .controls button { 57 | margin: 0 0 0 6px; 58 | display: inline-block; 59 | padding: 2px 8px; 60 | font-size: 0.9em; 61 | border-width: 1px; 62 | background: #fff; 63 | color: #222; 64 | border-color: #eee; 65 | } 66 | 67 | .blabbr-comment .controls button:hover, 68 | .blabbr-comment .controls button:focus { 69 | background: #1abc9c; 70 | color: #fff; 71 | } 72 | 73 | .blabbr-comment .controls button.remove:hover, 74 | .blabbr-comment .controls button.remove:focus { 75 | background: darkred; 76 | color: #fff; 77 | } 78 | 79 | .blabbr-comment header a { 80 | /* Box model */ 81 | display: inline-block; 82 | } 83 | 84 | .blabbr-comment header img { 85 | /* Box model */ 86 | position: absolute; 87 | top: 1rem; 88 | left: 1rem; 89 | width: 4rem; 90 | height: 4rem; 91 | 92 | /* Visual */ 93 | border-radius: 50%; 94 | } 95 | 96 | .blabbr-comment header + p { 97 | /* Box model */ 98 | margin-top: 0.5rem; 99 | } 100 | 101 | .blabbr-comment p:last-child { 102 | /* Box model */ 103 | margin-bottom: 0; 104 | } 105 | 106 | 107 | /* TODO: Migrate this to Comments */ 108 | .blabbr-comment { 109 | /* Box model */ 110 | margin-bottom: 1rem; 111 | } 112 | 113 | .blabbr-comment p { 114 | font-size: 1.4em; 115 | line-height: 1.5em; 116 | font-family: Georgia; 117 | } -------------------------------------------------------------------------------- /src/components/comment/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; // eslint-disable-line 3 | import Comment from './'; 4 | 5 | test('Comment renders correctly', () => { 6 | const tree = renderer 7 | .create( 8 | {}} 16 | handleDeleteUserComment={() => {}} 17 | edited 18 | lastUpdated="2-Jan-17" 19 | /> 20 | ) 21 | .toJSON(); 22 | expect(tree).toMatchSnapshot(); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/comments/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Comments render correctly 1`] = ` 4 |
5 |
6 |

7 | No comments to show for this story. 8 |

9 |
10 |
11 | `; 12 | -------------------------------------------------------------------------------- /src/components/comments/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 4 | import Comment from '../comment'; 5 | import SubmitCommentForm from '../submitComment'; 6 | import { getTimestamp } from '../../utils'; 7 | import './styles.css'; 8 | 9 | const Comments = ({ 10 | comments, 11 | currentUser, 12 | commentIdBeingEdited, 13 | userCommentBeingUpdated, 14 | handleEditUserComment, 15 | handleEditUserCommentChange, 16 | handleEditUserCommentSubmit, 17 | handleEditUserCommentCancel, 18 | handleDeleteUserComment, 19 | handleShowAllComments, 20 | isShowingAllComments, 21 | activeVersion 22 | }) => { 23 | const commentsComponents = comments.map(comment => { 24 | const timestamp = getTimestamp(comment.timestamp); 25 | const lastUpdated = getTimestamp(comment.lastUpdated); 26 | const beingEdited = comment._id === commentIdBeingEdited; 27 | 28 | let userCommentBeingUpdatedFn; 29 | if (beingEdited) { 30 | userCommentBeingUpdatedFn = userCommentBeingUpdated === null 31 | ? comment.comment 32 | : userCommentBeingUpdated; 33 | } else { 34 | userCommentBeingUpdatedFn = userCommentBeingUpdated; 35 | } 36 | 37 | const commentOrSubmit = !!beingEdited === true 38 | ? 48 | : ; 63 | 64 | return commentOrSubmit; 65 | }); 66 | 67 | const showAllCommentsLink = !isShowingAllComments 68 | ? 71 | : null; 72 | 73 | return ( 74 |
75 | 82 | {comments.length 83 | ? commentsComponents 84 | :

No comments to show for this story.

} 85 |
86 | {showAllCommentsLink} 87 |
88 | ); 89 | }; 90 | 91 | Comments.propTypes = { 92 | comments: PropTypes.array, 93 | commentIdBeingEdited: PropTypes.string, 94 | userCommentBeingUpdated: PropTypes.string, 95 | currentUser: PropTypes.string.isRequired, 96 | handleEditUserComment: PropTypes.func.isRequired, 97 | handleEditUserCommentChange: PropTypes.func.isRequired, 98 | handleEditUserCommentSubmit: PropTypes.func.isRequired, 99 | handleEditUserCommentCancel: PropTypes.func.isRequired, 100 | handleDeleteUserComment: PropTypes.func.isRequired, 101 | handleShowAllComments: PropTypes.func.isRequired, 102 | isShowingAllComments: PropTypes.bool.isRequired, 103 | activeVersion: PropTypes.string.isRequired 104 | }; 105 | 106 | Comment.defaultProps = { 107 | comments: [] 108 | }; 109 | 110 | export default Comments; 111 | -------------------------------------------------------------------------------- /src/components/comments/styles.css: -------------------------------------------------------------------------------- 1 | .comment-container { 2 | opacity: 1; 3 | } 4 | 5 | .comment-enter { 6 | opacity: 0.01; 7 | } 8 | 9 | .comment-enter.comment-enter-active { 10 | opacity: 1; 11 | transition: opacity 500ms ease-in; 12 | } 13 | 14 | .comment-leave { 15 | opacity: 1; 16 | max-height:500px; 17 | } 18 | 19 | .comment-leave.comment-leave-active { 20 | opacity: 0.01; 21 | max-height: 1px; 22 | transition: opacity 500ms ease-out, max-height 500ms ease-out; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/comments/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; // eslint-disable-line 3 | import Comments from './'; 4 | 5 | test('Comments render correctly', () => { 6 | const tree = renderer 7 | .create( 8 | {}} 14 | handleEditUserCommentChange={() => {}} 15 | handleEditUserCommentSubmit={() => {}} 16 | handleEditUserCommentCancel={() => {}} 17 | handleDeleteUserComment={() => {}} 18 | handleShowAllComments={() => {}} 19 | isShowingAllComments 20 | activeVersion="" 21 | /> 22 | ) 23 | .toJSON(); 24 | expect(tree).toMatchSnapshot(); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/onlineIndicator/__snapshots__/test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Online indicator renders correctly when system is offline 1`] = ` 4 |
9 |
13 | 14 |
15 |
16 | `; 17 | 18 | exports[`Online indicator renders correctly when system is online 1`] = ` 19 |
24 |
28 | 29 |
30 |
31 | `; 32 | -------------------------------------------------------------------------------- /src/components/onlineIndicator/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactTooltip from 'react-tooltip'; 4 | import './styles.css'; 5 | 6 | const OnlineIndicator = ({ isOnline }) => { 7 | const indicatorClass = isOnline 8 | ? 'blabbr-status-indicator online' 9 | : 'blabbr-status-indicator offline'; 10 | const tooltip = isOnline ? 'Online' : 'Could not connect to server'; 11 | return ( 12 |
17 | 23 | {tooltip} 24 | 25 |
26 | ); 27 | }; 28 | 29 | OnlineIndicator.propTypes = { 30 | isOnline: PropTypes.bool 31 | }; 32 | 33 | OnlineIndicator.defaultProps = { 34 | isOnline: false 35 | }; 36 | 37 | export default OnlineIndicator; 38 | -------------------------------------------------------------------------------- /src/components/onlineIndicator/styles.css: -------------------------------------------------------------------------------- 1 | .blabbr-status-indicator { 2 | display: inline-block; 3 | width: 0.8em; 4 | height: 0.8em; 5 | border-radius: 1em; 6 | } 7 | 8 | .blabbr-status-indicator.online { 9 | background-color: #47B861; 10 | } 11 | 12 | @keyframes blabbr-offline-animation { 13 | 0% { box-shadow: 0px 0px 10px #FF0000; } 14 | 50% { box-shadow: 0px 0px 0px #FF9235; } 15 | 100% { box-shadow: 0px 0px 10px #FF0000; } 16 | } 17 | 18 | .blabbr-status-indicator.offline { 19 | background-color: #FF0000; 20 | animation-name: blabbr-offline-animation; 21 | animation-duration: 1s; 22 | animation-iteration-count: 3; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/onlineIndicator/test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import renderer from 'react-test-renderer'; // eslint-disable-line 3 | import OnlineIndicator from './'; 4 | 5 | test('Online indicator renders correctly when system is offline', () => { 6 | const tree = renderer.create().toJSON(); 7 | expect(tree).toMatchSnapshot(); 8 | }); 9 | 10 | test('Online indicator renders correctly when system is online', () => { 11 | const tree = renderer.create().toJSON(); 12 | expect(tree).toMatchSnapshot(); 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/panel/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import AlertContainer from 'react-alert'; 4 | import { 5 | getComments, 6 | postComment, 7 | deleteComment, 8 | updateComment 9 | } from '../../api'; 10 | import { hasStorage, cleanToken } from '../../utils'; 11 | import Comments from '../comments'; 12 | import Register from '../register'; 13 | import SubmitComment from '../submitComment'; 14 | import OnlineIndicator from '../onlineIndicator'; 15 | import { dbEventManager } from '../../api/db'; 16 | import { version } from '../../utils/config'; // eslint-disable-line 17 | import './styles.css'; 18 | 19 | function wasActionPerformedByMe(key, obj) { 20 | const isKeyFound = obj.hasOwnProperty(key); // eslint-disable-line no-prototype-builtins 21 | if (isKeyFound) { 22 | delete obj[key]; // eslint-disable-line no-param-reassign 23 | } 24 | return isKeyFound; 25 | } 26 | 27 | const extractVersions = data => { 28 | const entries = (data && data.map(item => item.version)) || []; 29 | return new Set(entries); 30 | }; 31 | 32 | export default class Panel extends Component { 33 | constructor(...args) { 34 | super(...args); 35 | 36 | this.state = { 37 | activeComponent: null, 38 | activeStory: null, 39 | activeVersion: version || '', 40 | eventName: null, 41 | user: { 42 | isUserAuthenticated: false, 43 | userName: '', 44 | userEmail: '' 45 | }, 46 | userComment: '', 47 | comments: [], 48 | isShowingAllComments: true, 49 | userCommentBeingUpdated: null, 50 | commentIdBeingEdited: null, 51 | isUserOnline: navigator.onLine, 52 | serverVersions: [], 53 | versions: [] 54 | }; 55 | 56 | this.commentChannelListener = null; 57 | this.channelListening = false; 58 | this.alertOptions = { 59 | offset: 14, 60 | position: 'bottom right', 61 | theme: 'light', 62 | time: 3000, 63 | transition: 'fade' 64 | }; 65 | 66 | this.userActions = { 67 | added: {}, 68 | removed: {}, 69 | edited: {} 70 | }; 71 | this.commentsThreshold = 5; 72 | this.filteredComments = []; 73 | this.allComments = []; 74 | } 75 | 76 | componentWillMount() { 77 | hasStorage('localStorage') && this.verifyUser(); 78 | } 79 | 80 | componentDidMount() { 81 | const { storybook } = this.props; 82 | storybook.onStory && 83 | storybook.onStory((kind, story) => 84 | this.onStoryChangeHandler(kind, story) 85 | ); 86 | dbEventManager.addOnlineListener(); 87 | dbEventManager.subscribe( 88 | 'online', 89 | 'dbOnline', 90 | this.handleOnlineStatusChange 91 | ); 92 | } 93 | 94 | componentWillUnmount() { 95 | if (this.commentChannelListener) { 96 | dbEventManager.unsubscribe('change', this.commentChannelListener); 97 | this.commentChannelListener = null; 98 | } 99 | if (this.isUserOnlineListener) { 100 | dbEventManager.unsubscribe('online', 'dbOnline'); 101 | } 102 | dbEventManager.removeOnlineListener(); 103 | } 104 | 105 | onStoryChangeHandler = (kind, story) => { 106 | const activeComponent = cleanToken(kind); 107 | const activeStory = cleanToken(story); 108 | 109 | this.setState({ 110 | activeComponent, 111 | activeStory, 112 | eventName: `${activeComponent}${activeStory}`, 113 | userComment: '', 114 | comments: [] 115 | }); 116 | this.filteredComments = []; 117 | this.allComments = []; 118 | 119 | this.fetchComments(activeComponent, activeStory, this.state.activeVersion); 120 | }; 121 | 122 | handleRegisterChange = (key, value) => { 123 | const { user } = this.state; 124 | 125 | this.setState({ 126 | user: Object.assign({}, user, { [key]: value }) 127 | }); 128 | }; 129 | 130 | handleRegisterSubmit = () => { 131 | const { user: { userName, userEmail } } = this.state; 132 | 133 | this.registerUser(userName, userEmail); 134 | }; 135 | 136 | handleNewUserCommentChange = userComment => { 137 | this.setState({ userComment }); 138 | }; 139 | 140 | handleNewUserCommentSubmit = () => { 141 | const { userComment } = this.state; 142 | 143 | this.addComment(userComment && userComment.trim()); 144 | this.setState({ userComment: '' }); 145 | }; 146 | 147 | handleEditUserComment = event => { 148 | event.preventDefault(); 149 | event.stopPropagation(); 150 | 151 | this.setState({ commentIdBeingEdited: event.target.id }); 152 | const commentBeingEdited = this.allComments.find( 153 | comment => comment._id === event.target.id 154 | ); 155 | this.setState({ userCommentBeingUpdated: commentBeingEdited.comment }); 156 | this.userActions.edited[event.target.id] = true; 157 | }; 158 | 159 | handleEditUserCommentChange = userComment => { 160 | this.setState({ userCommentBeingUpdated: userComment }); 161 | }; 162 | 163 | handleEditUserCommentSubmit = commentId => { 164 | const { userCommentBeingUpdated } = this.state; 165 | 166 | this.editComment(commentId, userCommentBeingUpdated); 167 | 168 | this.setState({ 169 | userCommentBeingUpdated: null, 170 | commentIdBeingEdited: null 171 | }); 172 | }; 173 | 174 | handleEditUserCommentCancel = event => { 175 | event.preventDefault(); 176 | event.stopPropagation(); 177 | 178 | this.setState({ commentIdBeingEdited: null }); 179 | delete this.userActions.edited[event.target.id]; 180 | }; 181 | 182 | handleDeleteUserComment = event => { 183 | event.preventDefault(); 184 | event.stopPropagation(); 185 | 186 | this.userActions.removed[event.target.id] = true; 187 | deleteComment(event.target.id).then(data => { 188 | if (data.success) { 189 | global.msg.success(data.msg); 190 | } else { 191 | global.msg.error(data.msg); 192 | } 193 | }); 194 | }; 195 | 196 | handleShowAllComments = () => { 197 | this.setState({ 198 | comments: this.allComments, 199 | isShowingAllComments: true 200 | }); 201 | }; 202 | 203 | isDeletedByMe = dataKey => 204 | wasActionPerformedByMe(dataKey, this.userActions.removed); 205 | 206 | isEditedByMe = dataKey => 207 | wasActionPerformedByMe(dataKey, this.userActions.edited); 208 | 209 | isAddedByMe = dataKey => 210 | wasActionPerformedByMe(dataKey, this.userActions.added); 211 | 212 | isNewComment = dataKey => { 213 | const comments = this.allComments; 214 | let idFound = false; 215 | let commentsLength; 216 | let i; 217 | 218 | for (i = 0, commentsLength = comments.length; i < commentsLength; i++) { 219 | if (comments[i]._id === dataKey) { 220 | idFound = true; 221 | break; 222 | } 223 | } 224 | return !idFound; 225 | }; 226 | 227 | processServerVersions = data => { 228 | this.setState({ serverVersions: data }); 229 | }; 230 | 231 | processComments = data => { 232 | const comments = data.docs; 233 | const commentsLength = comments.length; 234 | const threshold = this.commentsThreshold; 235 | let isShowingAllComments = true; 236 | 237 | this.allComments = comments ? comments.slice(0) : []; 238 | 239 | if (commentsLength > threshold) { 240 | this.filteredComments = comments ? comments.slice(0, threshold) : []; 241 | isShowingAllComments = false; 242 | } 243 | this.setState({ 244 | comments: isShowingAllComments ? this.allComments : this.filteredComments, 245 | versions: [...extractVersions(comments)], 246 | isShowingAllComments 247 | }); 248 | }; 249 | 250 | listenForCommentChanges = (activeComponent, activeStory, activeVersion) => { 251 | const { eventName } = this.state; 252 | // remove listeners for previous comment stream 253 | if (this.commentChannelListener !== null) { 254 | dbEventManager.unsubscribe('change', this.commentChannelListener); 255 | this.commentChannelListener = null; 256 | } 257 | // register listeners 258 | // These listeners use userActions to only fire if you're 259 | // not the current user 260 | this.commentChannelListener = dbEventManager.subscribe( 261 | 'change', 262 | eventName, 263 | change => { 264 | const changedDoc = change.doc; 265 | const changedRecordId = changedDoc._id; 266 | const isDeleted = !!changedDoc._deleted; 267 | const isNewRecord = this.isNewComment(changedRecordId); 268 | 269 | if (isDeleted && !this.isDeletedByMe(changedRecordId)) { 270 | global.msg.info('A comment has been removed.'); 271 | } else if ( 272 | !isDeleted && 273 | isNewRecord && 274 | !this.isAddedByMe(changedRecordId) 275 | ) { 276 | global.msg.info('A new comment was added.'); 277 | } else if ( 278 | !isDeleted && 279 | !isNewRecord && 280 | !this.isEditedByMe(changedRecordId) 281 | ) { 282 | global.msg.info('A comment was edited.'); 283 | } 284 | this.updateView(activeComponent, activeStory, activeVersion); 285 | } 286 | ); 287 | }; 288 | 289 | verifyUser = () => { 290 | const userName = localStorage.getItem('blabbr_userName'); 291 | const userEmail = localStorage.getItem('blabbr_userEmail'); 292 | userName && 293 | userEmail && 294 | this.setState({ 295 | user: { userName, userEmail, isUserAuthenticated: true } 296 | }); 297 | }; 298 | 299 | registerUser(username, email) { 300 | const { user, activeComponent, activeStory, activeVersion } = this.state; 301 | 302 | localStorage.setItem('blabbr_userName', username); 303 | localStorage.setItem('blabbr_userEmail', email); 304 | this.setState({ user: Object.assign(user, { isUserAuthenticated: true }) }); 305 | this.updateView(activeComponent, activeStory, activeVersion); 306 | this.listenForCommentChanges(activeComponent, activeStory, activeVersion); 307 | } 308 | 309 | fetchComments = (activeComponent, activeStory, activeVersion) => { 310 | const { user } = this.state; 311 | 312 | if (user.isUserAuthenticated) { 313 | this.updateView(activeComponent, activeStory, activeVersion); 314 | this.listenForCommentChanges(activeComponent, activeStory, activeVersion); 315 | } 316 | }; 317 | 318 | updateView = (activeComponent, activeStory, activeVersion) => { 319 | getComments(activeComponent, activeStory, activeVersion) 320 | .then(data => { 321 | this.processComments(data); 322 | }) 323 | .catch(error => { 324 | global.msg.error(`Error: ${error.message}`); 325 | }); 326 | }; 327 | 328 | handleOnlineStatusChange = data => { 329 | this.setState({ 330 | isUserOnline: data.isOnline 331 | }); 332 | }; 333 | 334 | addComment = userComment => { 335 | const { 336 | user: { userName, userEmail }, 337 | activeComponent, 338 | activeStory, 339 | activeVersion, 340 | eventName 341 | } = this.state; 342 | const timestampId = `${new Date().getTime()}`; 343 | 344 | this.userActions.added[timestampId] = true; 345 | postComment({ 346 | timestampId, 347 | userComment, 348 | userName, 349 | userEmail, 350 | component: activeComponent, 351 | story: activeStory, 352 | version: activeVersion, 353 | eventName 354 | }) 355 | .then(data => { 356 | if (data.success) { 357 | global.msg.success(data.msg); 358 | } else { 359 | global.msg.error(data.msg); 360 | } 361 | this.updateView(activeComponent, activeStory, activeVersion); 362 | this.listenForCommentChanges( 363 | activeComponent, 364 | activeStory, 365 | activeVersion 366 | ); 367 | }) 368 | .catch(() => { 369 | global.msg.error( 370 | 'An error occured while attempting to post your comment.' 371 | ); 372 | }); 373 | 374 | this.setState({ userComment: '' }); 375 | }; 376 | 377 | editComment = (commentId, editedComment) => { 378 | const { activeComponent, user: { userName, userEmail } } = this.state; 379 | 380 | updateComment({ 381 | commentId, 382 | component: activeComponent, 383 | userCommentText: editedComment, 384 | userEmail, 385 | userName 386 | }).then(data => { 387 | if (data.success) { 388 | global.msg.success(data.msg); 389 | } else { 390 | global.msg.error(data.msg); 391 | } 392 | }); 393 | }; 394 | 395 | render() { 396 | const { 397 | user: { userName, userEmail, isUserAuthenticated }, 398 | userComment, 399 | userCommentBeingUpdated, 400 | comments, 401 | commentIdBeingEdited, 402 | isShowingAllComments, 403 | isUserOnline, 404 | activeVersion, 405 | versions 406 | } = this.state; 407 | 408 | const commentCount = this.allComments.length; 409 | 410 | const commentCountView = commentCount 411 | ? {commentCount} 412 | : null; 413 | 414 | return ( 415 |
416 | (global.msg = a)} {...this.alertOptions} /> 417 |
    418 |
  • {isUserAuthenticated && commentCountView}
  • 419 |
  • 420 |
421 | 422 | {!isUserAuthenticated && 423 | } 429 | 430 | {!!isUserAuthenticated && 431 | } 436 | 437 | {!!isUserAuthenticated && 438 | !!comments && 439 | } 454 |
455 | ); 456 | } 457 | } 458 | 459 | Panel.propTypes = { 460 | // channel: PropTypes.object.isRequired, 461 | storybook: PropTypes.object.isRequired 462 | }; 463 | -------------------------------------------------------------------------------- /src/components/panel/styles.css: -------------------------------------------------------------------------------- 1 | .blabbr-panel-container button { 2 | display: inline-block; 3 | padding: 10px 22px 10px 22px; 4 | margin: 12px 0; 5 | margin-bottom: 10px; 6 | border: 1px solid #16a085; 7 | border-width: 1px 1px 3px; 8 | color: #FFF; 9 | background: #1abc9c; 10 | font-size: 12px; 11 | text-align: center; 12 | font-style: normal; 13 | cursor: pointer; 14 | } 15 | 16 | .blabbr-panel-container .error { 17 | color: #ff0000; 18 | } 19 | 20 | .blabbr-panel-container button:hover, 21 | .blabbr-panel-container button:focus { 22 | background: #16a085; 23 | } 24 | 25 | .blabbr-panel-container { 26 | padding: 5px 10px 10px 10px; 27 | width: 100%; 28 | font-family: 29 | -apple-system, 30 | ".SFNSText-Regular", 31 | "San Francisco", 32 | Roboto, 33 | "Segoe UI", 34 | "Helvetica Neue", 35 | "Lucida Grande", 36 | sans-serif; 37 | color: #444; 38 | } 39 | 40 | .blabbr-panel-container ul.tiles { 41 | display: inline-block; 42 | float: right; 43 | margin-top: -5px; 44 | margin-right: -10px; 45 | font-size: 0.8em; 46 | font-weight: lighter; 47 | } 48 | 49 | .blabbr-panel-container ul.tiles li { 50 | display: inline-block; 51 | padding: 2px 3px 2px 3px; 52 | border-bottom-left-radius: 5px; 53 | border-bottom-right-radius: 5px; 54 | color: #777; 55 | background: rgb(244, 244, 244); 56 | margin-left: 1px; 57 | } 58 | -------------------------------------------------------------------------------- /src/components/register/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './styles.css'; 4 | 5 | class Register extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | validation: { 11 | userName: false, 12 | userEmail: false 13 | } 14 | }; 15 | } 16 | 17 | handleChange = event => { 18 | const { name, validity, value } = event.target; 19 | 20 | this.showInputError(name, validity); 21 | this.props.handleChange(name, value); 22 | }; 23 | 24 | handleSubmit = event => { 25 | event.preventDefault(); 26 | 27 | const invalidForm = Object.keys(this.state.validation).reduce( 28 | (accumulator, value) => accumulator || this.state.validation[value], 29 | false 30 | ); 31 | 32 | if (!invalidForm) { 33 | this.props.handleSubmit(); 34 | } 35 | }; 36 | 37 | showInputError(name, validity) { 38 | if (!validity.valid) { 39 | this.setState({ 40 | validation: Object.assign({}, this.state.validation, { [name]: true }) 41 | }); 42 | } else { 43 | this.setState({ 44 | validation: Object.assign({}, this.state.validation, { [name]: false }) 45 | }); 46 | } 47 | } 48 | 49 | render() { 50 | const { userName, userEmail } = this.props; 51 | 52 | return ( 53 |
54 |

Register to add comments

55 |
56 |
57 | 60 | 68 | {!!this.state.validation.userName && 69 |
70 | Display name must be at least 3 characters. 71 |
} 72 |
73 |
74 | 77 | 84 | {!!this.state.validation.userEmail && 85 |
Please use a valid email address.
} 86 |
87 | 90 |
91 |
92 | ); 93 | } 94 | } 95 | 96 | Register.propTypes = { 97 | handleChange: PropTypes.func.isRequired, 98 | handleSubmit: PropTypes.func.isRequired, 99 | userName: PropTypes.string.isRequired, 100 | userEmail: PropTypes.string.isRequired 101 | }; 102 | 103 | export default Register; 104 | -------------------------------------------------------------------------------- /src/components/register/story.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf } from '@storybook/react'; 3 | import { action } from '@storybook/addon-actions'; //eslint-disable-line 4 | import Register from './'; 5 | 6 | const onChange = () => { 7 | action('register on change'); 8 | }; 9 | 10 | storiesOf('Register').add('User registration', () => 11 | 12 | ); 13 | -------------------------------------------------------------------------------- /src/components/register/styles.css: -------------------------------------------------------------------------------- 1 | .blabbr-register { 2 | font-family: 3 | -apple-system, 4 | ".SFNSText-Regular", 5 | "San Francisco", 6 | Roboto, 7 | "Segoe UI", 8 | "Helvetica Neue", 9 | "Lucida Grande", 10 | sans-serif; 11 | max-width: 460px; 12 | margin: 0 auto; 13 | } 14 | 15 | .blabbr-register h2 { 16 | font-weight: normal; 17 | text-align: center; 18 | } 19 | 20 | .blabbr-register form div { 21 | margin-bottom: 1.25em; 22 | } 23 | 24 | .blabbr-register label { 25 | display: block; 26 | width: 15em; 27 | margin-bottom: 0.25em; 28 | } 29 | 30 | .blabbr-register input { 31 | display: block; 32 | } 33 | 34 | .blabbr-register input[type="text"], 35 | .blabbr-register input[type="email"] { 36 | width: 100%; 37 | padding: 10px; 38 | margin: 0.5em 0; 39 | font-size: 1.5em; 40 | } 41 | 42 | .blabbr-register button { 43 | display: block; 44 | width: 100%; 45 | max-width: 240px; 46 | padding: 19px 39px 18px 39px; 47 | margin: 0 auto; 48 | margin-bottom: 10px; 49 | border: 1px solid #16a085; 50 | border-width: 1px 1px 3px; 51 | color: #FFF; 52 | background: #1abc9c; 53 | font-size: 18px; 54 | text-align: center; 55 | font-style: normal; 56 | } 57 | 58 | .blabbr-register button:focus, 59 | .blabbr-register button:hover { 60 | background: #16a085; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/submitComment/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './styles.css'; 4 | 5 | const FormTitle = props => { 6 | const { type, title, userName, userEmail } = props; 7 | 8 | return type === 'Edit' 9 | ?

{title}: {`${userName} - ${userEmail}`}

10 | :

{title}

; 11 | }; 12 | 13 | FormTitle.propTypes = { 14 | type: PropTypes.string, 15 | title: PropTypes.string, 16 | userName: PropTypes.string, 17 | userEmail: PropTypes.string 18 | }; 19 | 20 | FormTitle.defaultProps = { 21 | type: '', 22 | title: '', 23 | userName: '', 24 | userEmail: '' 25 | }; 26 | 27 | const UpdateOrCancelButtons = props => { 28 | const { _id, handleSubmit, handleCancel } = props; 29 | 30 | return ( 31 |
32 | 42 | 45 |
46 | ); 47 | }; 48 | 49 | UpdateOrCancelButtons.propTypes = { 50 | _id: PropTypes.string, 51 | handleSubmit: PropTypes.func, 52 | handleCancel: PropTypes.func 53 | }; 54 | 55 | const SubmitButton = props => 56 | ; 59 | 60 | SubmitButton.propTypes = { 61 | handleSubmit: PropTypes.func 62 | }; 63 | 64 | class SubmitCommentForm extends Component { 65 | constructor(props) { 66 | super(props); 67 | 68 | this.state = { 69 | hasErrors: false, 70 | userComment: '' 71 | }; 72 | } 73 | 74 | handleChange = event => { 75 | this.setState({ 76 | hasErrors: false, 77 | userComment: event.target.value 78 | }); 79 | this.props.handleChange(event.target.value); 80 | }; 81 | 82 | handleSubmit = event => { 83 | event.preventDefault(); 84 | event.stopPropagation(); 85 | 86 | if (!this.state.userComment || this.state.userComment.length < 1) { 87 | this.setState({ hasErrors: true }); 88 | } else { 89 | this.props.handleSubmit(event.target.id); 90 | } 91 | }; 92 | 93 | render() { 94 | const { 95 | userComment, 96 | title = 'Add a comment:', 97 | type = 'Add', 98 | onCommentCancel, 99 | comment = {} 100 | } = this.props; 101 | 102 | const { _id = null, userEmail = '', userName = '' } = comment; 103 | 104 | return ( 105 |
106 |
107 | 113 |