From a02b1502a67f3b5774245a69fbaeb9d4ebe9cbe0 Mon Sep 17 00:00:00 2001 From: Dmitry Tsepelev Date: Tue, 10 Jul 2018 13:05:39 +0300 Subject: [PATCH] Basic redux integration for thread component (#141) --- .../thread/collapsedCommentsMiddleware.js | 28 ++++ .../components/thread/getCollapsedComments.js | 5 + web/app/components/thread/index.js | 7 + .../thread/saveCollapsedComments.js | 5 + web/app/components/thread/thread.actions.js | 6 + web/app/components/thread/thread.getters.js | 14 ++ web/app/components/thread/thread.jsx | 66 ++------- web/app/components/thread/thread.reducers.js | 22 +++ .../components/thread/thread.reducers.test.js | 29 ++++ web/app/remark.js | 10 +- web/app/store.js | 12 ++ web/package.json | 9 +- web/yarn-error.log | 128 ++++++++++++++++++ 13 files changed, 286 insertions(+), 55 deletions(-) create mode 100644 web/app/components/thread/collapsedCommentsMiddleware.js create mode 100644 web/app/components/thread/getCollapsedComments.js create mode 100644 web/app/components/thread/saveCollapsedComments.js create mode 100644 web/app/components/thread/thread.actions.js create mode 100644 web/app/components/thread/thread.getters.js create mode 100644 web/app/components/thread/thread.reducers.js create mode 100644 web/app/components/thread/thread.reducers.test.js create mode 100644 web/app/store.js create mode 100644 web/yarn-error.log diff --git a/web/app/components/thread/collapsedCommentsMiddleware.js b/web/app/components/thread/collapsedCommentsMiddleware.js new file mode 100644 index 00000000..337fc49e --- /dev/null +++ b/web/app/components/thread/collapsedCommentsMiddleware.js @@ -0,0 +1,28 @@ +import { siteId, url } from 'common/settings'; +import { THREAD_SET_COLLAPSE } from './thread.actions'; +import getCollapsedComments from './getCollapsedComments'; +import saveCollapsedComments from './saveCollapsedComments'; + +const collapsedCommentsMiddleware = ({ getState }) => next => action => { + if (action.type === THREAD_SET_COLLAPSE) { + const state = getState(); + const currentCollapsed = state[action.comment.id]; + + if (action.collapsed !== currentCollapsed) { + const lsCollapsedID = `${siteId}_${url}_${action.comment.id}`; + let collapsedComments = getCollapsedComments(); + + if (action.collapsed) { + collapsedComments = [...new Set(collapsedComments.concat(lsCollapsedID))]; + } else { + collapsedComments = collapsedComments.filter(id => id !== lsCollapsedID); + } + + saveCollapsedComments(collapsedComments); + } + } + + next(action); +}; + +export default collapsedCommentsMiddleware; diff --git a/web/app/components/thread/getCollapsedComments.js b/web/app/components/thread/getCollapsedComments.js new file mode 100644 index 00000000..6568e6af --- /dev/null +++ b/web/app/components/thread/getCollapsedComments.js @@ -0,0 +1,5 @@ +import { LS_COLLAPSE_KEY } from 'common/constants'; + +const getCollapsedComments = () => JSON.parse(localStorage.getItem(LS_COLLAPSE_KEY) || '[]'); + +export default getCollapsedComments; diff --git a/web/app/components/thread/index.js b/web/app/components/thread/index.js index 0f6a8dc8..6691e307 100644 --- a/web/app/components/thread/index.js +++ b/web/app/components/thread/index.js @@ -1 +1,8 @@ +import { collapsedThreads } from './thread.reducers'; +import collapsedCommentsMiddleware from './collapsedCommentsMiddleware'; + +export const threadReducers = { collapsedThreads }; + +export const threadMiddlewares = [collapsedCommentsMiddleware]; + export { default } from './thread'; diff --git a/web/app/components/thread/saveCollapsedComments.js b/web/app/components/thread/saveCollapsedComments.js new file mode 100644 index 00000000..3d0179c2 --- /dev/null +++ b/web/app/components/thread/saveCollapsedComments.js @@ -0,0 +1,5 @@ +import { LS_COLLAPSE_KEY } from 'common/constants'; + +const saveCollapsedComments = comments => localStorage.setItem(LS_COLLAPSE_KEY, JSON.stringify(comments)); + +export default saveCollapsedComments; diff --git a/web/app/components/thread/thread.actions.js b/web/app/components/thread/thread.actions.js new file mode 100644 index 00000000..a3693e7b --- /dev/null +++ b/web/app/components/thread/thread.actions.js @@ -0,0 +1,6 @@ +export const THREAD_SET_COLLAPSE = 'THREAD/COLLAPSE_SET'; +export const setCollapse = (comment, collapsed) => ({ + type: THREAD_SET_COLLAPSE, + comment, + collapsed, +}); diff --git a/web/app/components/thread/thread.getters.js b/web/app/components/thread/thread.getters.js new file mode 100644 index 00000000..fc534815 --- /dev/null +++ b/web/app/components/thread/thread.getters.js @@ -0,0 +1,14 @@ +import store from 'common/store'; + +export const getThreadIsCollapsed = (state, comment) => { + let collapsed = state.collapsedThreads[comment.id]; + + if (collapsed !== null && collapsed !== undefined) { + return collapsed; + } + + const config = store.get('config') || {}; + const score = comment.score || 0; + + return score <= config.critical_score; +}; diff --git a/web/app/components/thread/thread.jsx b/web/app/components/thread/thread.jsx index 7786951b..0f5b2b45 100644 --- a/web/app/components/thread/thread.jsx +++ b/web/app/components/thread/thread.jsx @@ -1,63 +1,24 @@ /** @jsx h */ import { h, Component } from 'preact'; - -import { LS_COLLAPSE_KEY } from 'common/constants'; -import { siteId, url } from 'common/settings'; -import store from 'common/store'; +import { connect } from 'preact-redux'; import Comment from 'components/comment'; +import { setCollapse } from './thread.actions'; +import { getThreadIsCollapsed } from './thread.getters'; -export default class Thread extends Component { +class Thread extends Component { constructor(props) { super(props); - - if (this.props.data && this.props.data.comment) { - this.updateCollapsedState(this.props.data.comment); - } - this.onCollapseToggle = this.onCollapseToggle.bind(this); } - componentWillReceiveProps(nextProps) { - if (nextProps.data && nextProps.data.comment) { - this.updateCollapsedState(nextProps.data.comment); - } - } - - updateCollapsedState(comment) { - const config = store.get('config') || {}; - const score = comment.score || 0; - - this.lsCollapsedID = `${siteId}_${url}_${comment.id}`; - - this.state = { - collapsed: - (!this.state.isCollapsedChanged && score <= config.critical_score) || - getCollapsedComments().includes(this.lsCollapsedID), - isCollapsedChanged: true, - }; - } - onCollapseToggle() { - const collapsed = !this.state.collapsed; - - this.setState({ collapsed: !this.state.collapsed }); - - let collapsedComments = getCollapsedComments(); - - if (collapsed) { - if (!collapsedComments.includes(this.lsCollapsedID)) { - collapsedComments = collapsedComments.concat(this.lsCollapsedID); - } - } else { - collapsedComments = collapsedComments.filter(id => id !== this.lsCollapsedID); - } - - saveCollapsedComments(collapsedComments); + this.props.setCollapse(this.props.data.comment, !this.props.collapsed); } - render(props, { collapsed }) { + render(props) { const { + collapsed, data: { comment, replies = [] }, mods = {}, } = props; @@ -79,7 +40,7 @@ export default class Thread extends Component { {!collapsed && !!replies.length && replies.map(thread => ( - ({ collapsed: getThreadIsCollapsed(state, props.data.comment) }), + { setCollapse } +)(Thread); -function saveCollapsedComments(comments) { - localStorage.setItem(LS_COLLAPSE_KEY, JSON.stringify(comments)); -} +export default ConnectedThread; diff --git a/web/app/components/thread/thread.reducers.js b/web/app/components/thread/thread.reducers.js new file mode 100644 index 00000000..ef5adf55 --- /dev/null +++ b/web/app/components/thread/thread.reducers.js @@ -0,0 +1,22 @@ +import { siteId, url } from 'common/settings'; +import { THREAD_SET_COLLAPSE } from './thread.actions'; +import getCollapsedComments from './getCollapsedComments'; + +const collapsedCommentIds = getCollapsedComments() + .map(comment => comment.split('_')) + .filter(components => components[0] === siteId && components[1] === url) + .map(component => component[2]); + +const initialState = collapsedCommentIds.reduce((acc, id) => ({ ...acc, [id]: true }), {}); + +export const collapsedThreads = (state = initialState, action) => { + switch (action.type) { + case THREAD_SET_COLLAPSE: + return { + ...state, + [action.comment.id]: action.collapsed, + }; + default: + return state; + } +}; diff --git a/web/app/components/thread/thread.reducers.test.js b/web/app/components/thread/thread.reducers.test.js new file mode 100644 index 00000000..f48e8931 --- /dev/null +++ b/web/app/components/thread/thread.reducers.test.js @@ -0,0 +1,29 @@ +import { setCollapse } from './thread.actions'; +import { collapsedThreads } from './thread.reducers'; +import { getThreadIsCollapsed } from './thread.getters'; + +describe('collapsedThreads', () => { + const comment = { id: 1 }; + + it('should set collapsed to true', () => { + const collapsed = true; + const action = setCollapse(comment, collapsed); + + const newState = { + collapsedThreads: collapsedThreads({}, action), + }; + + expect(getThreadIsCollapsed(newState, comment)).toEqual(collapsed); + }); + + it('should set collapsed to false', () => { + const collapsed = false; + const action = setCollapse(comment, collapsed); + + const newState = { + collapsedThreads: collapsedThreads({}, action), + }; + + expect(getThreadIsCollapsed(newState, comment)).toEqual(collapsed); + }); +}); diff --git a/web/app/remark.js b/web/app/remark.js index 6d73668f..c8bc6a27 100644 --- a/web/app/remark.js +++ b/web/app/remark.js @@ -3,9 +3,11 @@ import loadPolyfills from 'common/polyfills'; import { h, render } from 'preact'; +import { Provider } from 'preact-redux'; import Root from './components/root'; import UserInfo from 'components/user-info'; import store from 'common/store'; +import reduxStore from './store'; // eslint-disable-next-line no-unused-vars import ListComments from './components/list-comments'; // TODO: temp solution for extracting styles @@ -20,6 +22,12 @@ loadPolyfills().then(() => { } }); +const Main = () => ( + + + +); + function init() { const node = document.getElementById(NODE_ID); @@ -62,6 +70,6 @@ function init() { node ); } else { - render(, node.parentElement, node); + render(
, node.parentElement, node); } } diff --git a/web/app/store.js b/web/app/store.js new file mode 100644 index 00000000..1604ef3b --- /dev/null +++ b/web/app/store.js @@ -0,0 +1,12 @@ +import { createStore, applyMiddleware } from 'redux'; +import { combineReducers } from 'redux'; + +import { threadReducers, threadMiddlewares } from './components/thread'; + +const reducers = combineReducers({ + ...threadReducers, +}); + +const middlewares = applyMiddleware(...threadMiddlewares); + +export default createStore(reducers, middlewares); diff --git a/web/package.json b/web/package.json index 825f362f..8a8b0255 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "html-webpack-plugin": "^2.30.1", "husky": "^0.14.3", "jest": "^23.1.0", + "jest-localstorage-mock": "^2.2.0", "lint-staged": "^7.2.0", "npm-run-all": "^4.1.3", "postcss-calc": "^6.0.1", @@ -53,7 +54,9 @@ "postcss-simple-vars": "^4.1.0", "postcss-url": "^6.3.1", "postcss-wrap": "0.0.4", + "preact-redux": "^2.0.3", "prettier": "^1.13.7", + "redux": "^4.0.0", "style-loader": "^0.19.1", "webpack": "^3.12.0", "webpack-bundle-analyzer": "^2.13.1", @@ -72,11 +75,15 @@ "^.+\\.jsx?$": "/fileTransformer.js" }, "setupFiles": [ - "/injectGlobalVariable.js" + "/injectGlobalVariable.js", + "jest-localstorage-mock" ], "moduleDirectories": [ "node_modules", "/app" + ], + "testMatch": [ + "/**/*.test.js" ] }, "engines": { diff --git a/web/yarn-error.log b/web/yarn-error.log new file mode 100644 index 00000000..2e432a7f --- /dev/null +++ b/web/yarn-error.log @@ -0,0 +1,128 @@ +Arguments: + /Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/bin/node /Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/bin/yarn start + +PATH: + /anaconda2/bin:/Users/dmitrytsepelev/.rvm/gems/ruby-2.5.1/bin:/Users/dmitrytsepelev/.rvm/gems/ruby-2.5.1@global/bin:/Users/dmitrytsepelev/.rvm/rubies/ruby-2.5.1/bin:/usr/local/heroku/bin:/Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/bin:./bin:bin:/Users/dmitrytsepelev/bin:/usr/local/bin:/usr/local/sbin:/usr/local/share/npm/bin:/usr/bin:/bin:/usr/sbin:/sbin::/Users/dmitrytsepelev/.rvm/bin:/usr/local/go/bin:/Users/dmitrytsepelev/go/bin:/Users/dmitrytsepelev/Library/Android/sdk/tools:/Users/dmitrytsepelev/Library/Android/sdk/platform-tools:/Users/dmitrytsepelev/Library/Android/sdk/tools/proguard/bin + +Yarn version: + 1.5.1 + +Node version: + 9.2.0 + +Platform: + darwin x64 + +npm manifest: + { + "name": "remark-ui", + "version": "0.1.0", + "scripts": { + "build": "cross-env NODE_ENV=production webpack --config ./webpack.config.js", + "start": "webpack-dev-server --progress --hot --inline --config ./webpack.config.js", + "lint": "eslint --ext=.js,.jsx .", + "test": "jest", + "prettier": "prettier --write \"./**/*.{js,jsx,scss}\"", + "precommit": "./node_modules/.bin/lint-staged" + }, + "lint-staged": { + "./**/*.{js,jsx}": [ + "eslint --fix", + "git add" + ], + "./**/*.scss": [ + "prettier --write", + "git add" + ] + }, + "devDependencies": { + "autoprefixer": "^7.2.6", + "babel-core": "^6.26.3", + "babel-eslint": "^8.2.5", + "babel-loader": "^7.1.4", + "babel-plugin-syntax-dynamic-import": "^6.18.0", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-plugin-transform-react-jsx": "^6.24.1", + "babel-preset-env": "^1.7.0", + "clean-webpack-plugin": "^0.1.19", + "copy-webpack-plugin": "^4.5.1", + "core-js": "^2.5.7", + "cross-env": "^5.2.0", + "css-loader": "^0.28.11", + "eslint": "^4.19.1", + "eslint-config-prettier": "^2.9.0", + "eslint-plugin-jsx-a11y": "^6.1.0", + "eslint-plugin-prettier": "^2.6.1", + "eslint-plugin-react": "^7.10.0", + "extract-text-webpack-plugin": "^3.0.2", + "file-loader": "^0.11.1", + "html-webpack-plugin": "^2.30.1", + "husky": "^0.14.3", + "jest": "^23.1.0", + "jest-localstorage-mock": "^2.2.0", + "lint-staged": "^7.2.0", + "postcss-calc": "^6.0.1", + "postcss-csso": "^2.0.0", + "postcss-for": "^2.1.1", + "postcss-loader": "^2.1.5", + "postcss-nested": "^3.0.0", + "postcss-simple-vars": "^4.1.0", + "postcss-url": "^6.3.1", + "postcss-wrap": "0.0.4", + "preact-redux": "^2.0.3", + "prettier": "^1.13.7", + "redux": "^4.0.0", + "style-loader": "^0.19.1", + "webpack": "^3.12.0", + "webpack-bundle-analyzer": "^2.13.1", + "webpack-dev-server": "^2.7.1" + }, + "dependencies": { + "axios": "^0.18.0", + "bem-react-helper": "^1.1.2", + "preact": "^8.2.9" + }, + "eslintIgnore": [ + "public" + ], + "jest": { + "transform": { + "^.+\\.jsx?$": "/fileTransformer.js" + }, + "setupFiles": [ + "/injectGlobalVariable.js", + "jest-localstorage-mock" + ], + "moduleDirectories": [ + "node_modules", + "/app" + ], + "testMatch": [ + "/**/*.test.js" + ] + }, + "engines": { + "node": ">=8" + } + } + +yarn manifest: + No manifest + +Lockfile: + No lockfile + +Trace: + Error: Command failed. + Exit code: 1 + Command: sh + Arguments: -c webpack-dev-server --progress --hot --inline --config ./webpack.config.js + Directory: /Users/dmitrytsepelev/dev/remark/web + Output: + + at ProcessTermError.MessageError (/Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/lib/node_modules/yarn/lib/cli.js:186:110) + at new ProcessTermError (/Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/lib/node_modules/yarn/lib/cli.js:226:113) + at ChildProcess. (/Users/dmitrytsepelev/.nvm/versions/node/v9.2.0/lib/node_modules/yarn/lib/cli.js:30281:17) + at ChildProcess.emit (events.js:159:13) + at maybeClose (internal/child_process.js:943:16) + at Process.ChildProcess._handle.onexit (internal/child_process.js:220:5)