From 87affab6de83a9905042cca047bb9b90e058e223 Mon Sep 17 00:00:00 2001 From: Tomasz Knapik <hi@tmkn.org> Date: Tue, 22 Jan 2019 16:53:20 +0000 Subject: [PATCH] Add notes list for a current submission --- opentech/static_src/src/app/src/api/index.js | 3 + opentech/static_src/src/app/src/api/notes.js | 9 +++ .../src/app/src/components/NotesPanel.js | 34 +++++++++ .../src/app/src/components/NotesPanelItem.js | 24 +++++++ .../app/src/containers/DisplayPanel/index.js | 5 +- .../src/containers/SubmissionNotesPanel.js | 69 +++++++++++++++++++ opentech/static_src/src/app/src/datetime.js | 8 +++ .../src/app/src/redux/actions/notes.js | 46 +++++++++++++ .../src/app/src/redux/actions/submissions.js | 2 +- .../src/app/src/redux/reducers/index.js | 2 + .../src/app/src/redux/reducers/notes.js | 69 +++++++++++++++++++ .../src/app/src/redux/selectors/notes.js | 24 +++++++ .../static_src/src/app/webpack.base.config.js | 2 +- package-lock.json | 54 ++++++++++++--- package.json | 2 + 15 files changed, 338 insertions(+), 15 deletions(-) create mode 100644 opentech/static_src/src/app/src/api/notes.js create mode 100644 opentech/static_src/src/app/src/components/NotesPanel.js create mode 100644 opentech/static_src/src/app/src/components/NotesPanelItem.js create mode 100644 opentech/static_src/src/app/src/containers/SubmissionNotesPanel.js create mode 100644 opentech/static_src/src/app/src/datetime.js create mode 100644 opentech/static_src/src/app/src/redux/actions/notes.js create mode 100644 opentech/static_src/src/app/src/redux/reducers/notes.js create mode 100644 opentech/static_src/src/app/src/redux/selectors/notes.js diff --git a/opentech/static_src/src/app/src/api/index.js b/opentech/static_src/src/app/src/api/index.js index 6fcd16016..cd360e714 100644 --- a/opentech/static_src/src/app/src/api/index.js +++ b/opentech/static_src/src/app/src/api/index.js @@ -1,6 +1,9 @@ import { fetchSubmission, fetchSubmissionsByRound } from '@api/submissions'; +import { fetchNotesForSubmission } from '@api/notes'; export default { fetchSubmissionsByRound, fetchSubmission, + + fetchNotesForSubmission, }; diff --git a/opentech/static_src/src/app/src/api/notes.js b/opentech/static_src/src/app/src/api/notes.js new file mode 100644 index 000000000..f87fc5184 --- /dev/null +++ b/opentech/static_src/src/app/src/api/notes.js @@ -0,0 +1,9 @@ +import { apiFetch } from '@api/utils'; + + +export function fetchNotesForSubmission(submissionID, visibility = 'internal') { + return apiFetch(`/apply/api/submissions/${submissionID}/comments/`, 'GET', { + //visibility, + page_size: 1000, + }); +} diff --git a/opentech/static_src/src/app/src/components/NotesPanel.js b/opentech/static_src/src/app/src/components/NotesPanel.js new file mode 100644 index 000000000..b297d08df --- /dev/null +++ b/opentech/static_src/src/app/src/components/NotesPanel.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import LoadingPanel from '@components/LoadingPanel'; + +export default class NotesPanelItem extends React.Component { + static propTypes = { + children: PropTypes.node, + isLoading: PropTypes.bool.isRequired, + isErrored: PropTypes.bool.isRequired, + handleRetry: PropTypes.func.isRequired, + }; + + render() { + const { children, handleRetry, isErrored, isLoading } = this.props; + + if (isLoading) { + return <LoadingPanel />; + } else if (isErrored) { + return <> + <p><strong>Something went wrong!</strong>Sorry we couldn’t load notes</p> + <a onClick={handleRetry}>Refresh</a>. + </>; + } else if (children.length === 0) { + return <p>There aren’t any notes for this application yet.</p>; + } + + return ( + <ul> + {children} + </ul> + ); + } +} diff --git a/opentech/static_src/src/app/src/components/NotesPanelItem.js b/opentech/static_src/src/app/src/components/NotesPanelItem.js new file mode 100644 index 000000000..85202c6e6 --- /dev/null +++ b/opentech/static_src/src/app/src/components/NotesPanelItem.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +export default class NotesPanelItem extends React.Component { + static propTypes = { + note: PropTypes.shape({ + user: PropTypes.string, + timestamp: PropTypes.string, + message: PropTypes.string, + }), + }; + + render() { + const { note } = this.props; + + return ( + <div> + <div style={{fontWeight: 'bold'}}>{note.user} - {moment(note.timestamp).format('ll')}</div> + <div>{note.message}</div> + </div> + ); + } +} diff --git a/opentech/static_src/src/app/src/containers/DisplayPanel/index.js b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js index e09e83f96..e3e52a679 100644 --- a/opentech/static_src/src/app/src/containers/DisplayPanel/index.js +++ b/opentech/static_src/src/app/src/containers/DisplayPanel/index.js @@ -13,6 +13,7 @@ import { } from '@selectors/submissions'; import CurrentSubmissionDisplay from '@containers/CurrentSubmissionDisplay' +import SubmissionNotesPanel from '@containers/SubmissionNotesPanel'; import Tabber, {Tab} from '@components/Tabber' import './style.scss'; @@ -28,7 +29,7 @@ class DisplayPanel extends React.Component { }; render() { - const { windowSize: {windowWidth: width} } = this.props; + const { windowSize: {windowWidth: width}, submissionID } = this.props; const { clearSubmission } = this.props; const isMobile = width < 1024; @@ -37,7 +38,7 @@ class DisplayPanel extends React.Component { let tabs = [ <Tab button="Notes" key="note"> - <p>Notes</p> + <SubmissionNotesPanel submissionID={submissionID} /> </Tab>, <Tab button="Status" key="status"> <p>Status</p> diff --git a/opentech/static_src/src/app/src/containers/SubmissionNotesPanel.js b/opentech/static_src/src/app/src/containers/SubmissionNotesPanel.js new file mode 100644 index 000000000..6c13bb5fa --- /dev/null +++ b/opentech/static_src/src/app/src/containers/SubmissionNotesPanel.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { fetchNotesForSubmission } from '@actions/notes'; +import NotesPanel from '@components/NotesPanel'; +import NotesPanelItem from '@components/NotesPanelItem'; +import { + getNotesErrorState, + getNotesForCurrentSubmission, + getNotesFetchState, +} from '@selectors/notes'; + +class SubmissionNotesPanel extends React.Component { + static propTypes = { + loadNotes: PropTypes.func, + submissionID: PropTypes.number, + notes: PropTypes.array, + isErrored: PropTypes.bool, + isLoading: PropTypes.bool, + }; + + componentDidUpdate(prevProps) { + const { submissionID } = this.props; + const prevSubmissionID = prevProps.submissionID; + + if( + submissionID !== null && submissionID !== undefined && + prevSubmissionID !== submissionID && !this.props.isLoading + ) { + this.props.loadNotes(submissionID); + } + } + + handleRetry = () => { + if (this.props.isLoading || !this.props.isErrored) { + return; + } + this.props.loadNotes(this.props.submissionID); + } + + render() { + const { notes } = this.props; + const passProps = { + isLoading: this.props.isLoading, + isErrored: this.props.isErrored, + handleRetry: this.handleRetry, + }; + return ( + <NotesPanel {...passProps}> + {notes.map(v => + <NotesPanelItem key={`note-${v.id}`} note={v} /> + )} + </NotesPanel> + ); + } +} + +const mapDispatchToProps = dispatch => ({ + loadNotes: submissionID => dispatch(fetchNotesForSubmission(submissionID)), +}); + +const mapStateToProps = state => ({ + notes: getNotesForCurrentSubmission(state), + isLoading: getNotesFetchState(state), + isErrored: getNotesErrorState(state), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(SubmissionNotesPanel); diff --git a/opentech/static_src/src/app/src/datetime.js b/opentech/static_src/src/app/src/datetime.js new file mode 100644 index 000000000..f906f3ace --- /dev/null +++ b/opentech/static_src/src/app/src/datetime.js @@ -0,0 +1,8 @@ +import moment from 'moment'; +import 'moment-timezone'; + +// Use GMT globally for all the dates. +moment.tz.setDefault('GMT'); + +// Use en-US locale for all the dates. +moment.locale('en'); diff --git a/opentech/static_src/src/app/src/redux/actions/notes.js b/opentech/static_src/src/app/src/redux/actions/notes.js new file mode 100644 index 000000000..ecac7ad5f --- /dev/null +++ b/opentech/static_src/src/app/src/redux/actions/notes.js @@ -0,0 +1,46 @@ +import { updateSubmission } from '@actions/submissions'; +import api from '@api'; + +export const FAIL_FETCHING_NOTES = 'FAIL_FETCHING_NOTES'; +export const START_FETCHING_NOTES = 'START_FETCHING_NOTES'; +export const UPDATE_NOTES = 'UPDATE_NOTES'; +export const UPDATE_NOTE = 'UPDATE_NOTE'; + +const startFetchingNotes = () => ({ + type: START_FETCHING_NOTES, +}); + +const failFetchingNotes = message => ({ + type: FAIL_FETCHING_NOTES, + message, +}); + +export const fetchNotesForSubmission = submissionID => { + return async function(dispatch) { + dispatch(startFetchingNotes()); + try { + const response = await api.fetchNotesForSubmission(submissionID); + const json = await response.json(); + if (!response.ok) { + return dispatch(failFetchingNotes(json.detail)); + } + return dispatch(updateNotesForSubmission(submissionID, json)); + } catch(e) { + return dispatch(failFetchingNotes(e.message)); + } + }; +}; + +const updateNotesForSubmission = (submissionID, data) => { + return async function(dispatch) { + dispatch(updateNotes(data)); + dispatch(updateSubmission(submissionID, { + comments: data.results.map(v => v.id), + })); + }; +}; + +export const updateNotes = data => ({ + type: UPDATE_NOTES, + data, +}); diff --git a/opentech/static_src/src/app/src/redux/actions/submissions.js b/opentech/static_src/src/app/src/redux/actions/submissions.js index db35aec06..181f755f4 100644 --- a/opentech/static_src/src/app/src/redux/actions/submissions.js +++ b/opentech/static_src/src/app/src/redux/actions/submissions.js @@ -123,7 +123,7 @@ const failLoadingSubmission = submissionID => ({ }); -const updateSubmission = (submissionID, data) => ({ +export const updateSubmission = (submissionID, data) => ({ type: UPDATE_SUBMISSION, submissionID, data, diff --git a/opentech/static_src/src/app/src/redux/reducers/index.js b/opentech/static_src/src/app/src/redux/reducers/index.js index d1e5e2374..f7e4cfa44 100644 --- a/opentech/static_src/src/app/src/redux/reducers/index.js +++ b/opentech/static_src/src/app/src/redux/reducers/index.js @@ -2,8 +2,10 @@ import { combineReducers } from 'redux' import submissions from '@reducers/submissions'; import rounds from '@reducers/rounds'; +import notes from '@reducers/notes'; export default combineReducers({ + notes, submissions, rounds, }); diff --git a/opentech/static_src/src/app/src/redux/reducers/notes.js b/opentech/static_src/src/app/src/redux/reducers/notes.js new file mode 100644 index 000000000..1ce36158b --- /dev/null +++ b/opentech/static_src/src/app/src/redux/reducers/notes.js @@ -0,0 +1,69 @@ +import { combineReducers } from 'redux'; + +import { + UPDATE_NOTE, + UPDATE_NOTES, + START_FETCHING_NOTES, + FAIL_FETCHING_NOTES, +} from '@actions/notes'; + +function notesFetching(state = false, action) { + switch (action.type) { + case START_FETCHING_NOTES: + return true; + case UPDATE_NOTES: + case FAIL_FETCHING_NOTES: + return false; + default: + return state; + } +} + + +function notesErrored(state = false, action) { + switch (action.type) { + case UPDATE_NOTES: + case START_FETCHING_NOTES: + return false; + case FAIL_FETCHING_NOTES: + return true; + default: + return state; + } +} + +function note(state, action) { + switch (action.type) { + case UPDATE_NOTE: + return { + ...state, + ...action.data, + }; + default: + return state; + } +} + +function notesByID(state = {}, action) { + switch(action.type) { + case UPDATE_NOTES: + return { + ...state, + ...action.data.results.reduce((newNotesAccumulator, newNote) => { + newNotesAccumulator[newNote.id] = note(state[newNote.id], { + type: UPDATE_NOTE, + data: newNote, + }); + return newNotesAccumulator; + }, {}), + }; + default: + return state; + } +} + +export default combineReducers({ + byID: notesByID, + isFetching: notesFetching, + isErrored: notesErrored, +}); diff --git a/opentech/static_src/src/app/src/redux/selectors/notes.js b/opentech/static_src/src/app/src/redux/selectors/notes.js new file mode 100644 index 000000000..d4d01b9ea --- /dev/null +++ b/opentech/static_src/src/app/src/redux/selectors/notes.js @@ -0,0 +1,24 @@ +import { createSelector } from 'reselect'; + +import { getCurrentSubmission } from '@selectors/submissions'; + +const getNotes = state => state.notes.byID; + +export const getNotesFetchState = state => state.notes.isFetching === true; + +export const getNotesErrorState = state => state.notes.isErrored === true; + +export const getNotesForCurrentSubmission = createSelector( + [getCurrentSubmission, getNotes], + (submission, notes) => { + if ( + submission === undefined || submission === null || + submission.comments === null || submission.comments === undefined + ) { + return []; + } + return submission.comments.map(commentID => notes[commentID]) + .filter(v => v !== undefined && v !== null) + } +); + diff --git a/opentech/static_src/src/app/webpack.base.config.js b/opentech/static_src/src/app/webpack.base.config.js index b6017a031..b30d026d8 100644 --- a/opentech/static_src/src/app/webpack.base.config.js +++ b/opentech/static_src/src/app/webpack.base.config.js @@ -3,7 +3,7 @@ var path = require('path'); module.exports = { context: __dirname, - entry: ['@babel/polyfill', './src/index'], + entry: ['@babel/polyfill', './src/datetime', './src/index'], output: { filename: '[name]-[hash].js' diff --git a/package-lock.json b/package-lock.json index c03d14147..72ffbc022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4272,7 +4272,8 @@ }, "ansi-regex": { "version": "2.1.1", - "bundled": true + "bundled": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4290,11 +4291,13 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true + "bundled": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4307,15 +4310,18 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "concat-map": { "version": "0.0.1", - "bundled": true + "bundled": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true + "bundled": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4418,7 +4424,8 @@ }, "inherits": { "version": "2.0.3", - "bundled": true + "bundled": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4428,6 +4435,7 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4440,17 +4448,20 @@ "minimatch": { "version": "3.0.4", "bundled": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true + "bundled": true, + "optional": true }, "minipass": { "version": "2.3.5", "bundled": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4467,6 +4478,7 @@ "mkdirp": { "version": "0.5.1", "bundled": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -4539,7 +4551,8 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true + "bundled": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4549,6 +4562,7 @@ "once": { "version": "1.4.0", "bundled": true, + "optional": true, "requires": { "wrappy": "1" } @@ -4624,7 +4638,8 @@ }, "safe-buffer": { "version": "5.1.2", - "bundled": true + "bundled": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -4654,6 +4669,7 @@ "string-width": { "version": "1.0.2", "bundled": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4671,6 +4687,7 @@ "strip-ansi": { "version": "3.0.1", "bundled": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4709,11 +4726,13 @@ }, "wrappy": { "version": "1.0.2", - "bundled": true + "bundled": true, + "optional": true }, "yallist": { "version": "3.0.3", - "bundled": true + "bundled": true, + "optional": true } } }, @@ -7133,6 +7152,19 @@ } } }, + "moment": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", + "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" + }, + "moment-timezone": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.23.tgz", + "integrity": "sha512-WHFH85DkCfiNMDX5D3X7hpNH3/PUhjTGcD0U1SgfBGZxJ3qUmJh5FdvaFjcClxOvB3rzdfj4oRffbI38jEnC1w==", + "requires": { + "moment": ">= 2.9.0" + } + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", diff --git a/package.json b/package.json index 7ad0fcfa4..cbac85369 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "gulp-size": "^3.0.0", "gulp-touch-cmd": "0.0.1", "gulp-uglify": "^3.0.1", + "moment": "^2.24.0", + "moment-timezone": "^0.5.23", "node-sass-import-once": "^1.2.0", "prop-types": "^15.6.2", "react": "^16.7.0", -- GitLab