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&rsquo;t load notes</p>
+                <a onClick={handleRetry}>Refresh</a>.
+            </>;
+        } else if (children.length === 0) {
+            return <p>There aren&rsquo;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