diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index ea3e7101324996b16082ff1d3694b1d6b388ec6c..159bd219cf8270dd8e977cd087e633050363973d 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -41,6 +41,9 @@ class BaseActivityQuerySet(models.QuerySet): def visible_to(self, user): return self.filter(visibility__in=self.model.visibility_for(user)) + def newer(self, activity): + return self.filter(timestamp__gt=activity.timestamp) + class ActivityQuerySet(BaseActivityQuerySet): def comments(self): diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py index ece53fb38815dfe6cac8a8578e7adc86c99cba00..cb989821e468641c770e06f16299d216132d7dd6 100644 --- a/opentech/apply/funds/api_views.py +++ b/opentech/apply/funds/api_views.py @@ -108,13 +108,22 @@ class RoundLabList(generics.ListAPIView): pagination_class = StandardResultsSetPagination +class NewerThanFilter(filters.ModelChoiceFilter): + def filter(self, qs, value): + if not value: + return qs + + return qs.newer(value) + + class CommentFilter(filters.FilterSet): since = filters.DateTimeFilter(field_name="timestamp", lookup_expr='gte') before = filters.DateTimeFilter(field_name="timestamp", lookup_expr='lte') + newer = NewerThanFilter(queryset=Activity.comments.all()) class Meta: model = Activity - fields = ['submission', 'visibility', 'since', 'before'] + fields = ['submission', 'visibility', 'since', 'before', 'newer'] class CommentList(generics.ListAPIView): @@ -138,7 +147,7 @@ class CommentListCreate(generics.ListCreateAPIView): permissions.IsAuthenticated, IsApplyStaffUser, ) filter_backends = (filters.DjangoFilterBackend,) - filter_fields = ('visibility',) + filter_class = CommentFilter pagination_class = StandardResultsSetPagination def get_queryset(self): diff --git a/opentech/static_src/src/app/src/api/index.js b/opentech/static_src/src/app/src/api/index.js index e71a708a4cda32b21936364955ec21d3e5bd8fd9..037ed1a4e92fa98a06c62ccd0bbebe783c2c4f14 100644 --- a/opentech/static_src/src/app/src/api/index.js +++ b/opentech/static_src/src/app/src/api/index.js @@ -1,6 +1,6 @@ import { fetchSubmission, fetchSubmissionsByRound, fetchSubmissionsByStatuses } from '@api/submissions'; import { fetchRound, fetchRounds } from '@api/rounds'; -import { createNoteForSubmission, fetchNotesForSubmission } from '@api/notes'; +import { createNoteForSubmission, fetchNotesForSubmission, fetchNewNotesForSubmission } from '@api/notes'; export default { fetchSubmissionsByRound, @@ -11,5 +11,6 @@ export default { fetchRounds, fetchNotesForSubmission, + fetchNewNotesForSubmission, createNoteForSubmission, }; diff --git a/opentech/static_src/src/app/src/api/notes.js b/opentech/static_src/src/app/src/api/notes.js index ec431cf28874a8b7baed6976de7a1988a751c5ab..436c45909048736786d4c85064a16664dc0d4f4d 100644 --- a/opentech/static_src/src/app/src/api/notes.js +++ b/opentech/static_src/src/app/src/api/notes.js @@ -9,6 +9,18 @@ export function fetchNotesForSubmission(submissionID, visibility = 'internal') { } +export function fetchNewNotesForSubmission(submissionID, latestID, visibility = 'internal') { + return { + path: `/apply/api/submissions/${submissionID}/comments/`, + params: { + visibility, + newer: latestID, + page_size: 1000, + } + }; +} + + export function createNoteForSubmission(submissionID, note) { return { path: `/apply/api/submissions/${submissionID}/comments/`, diff --git a/opentech/static_src/src/app/src/components/InlineLoading/index.js b/opentech/static_src/src/app/src/components/InlineLoading/index.js new file mode 100644 index 0000000000000000000000000000000000000000..14ec266d66299f7d1cb8c4b1dcb2087a7b0869dc --- /dev/null +++ b/opentech/static_src/src/app/src/components/InlineLoading/index.js @@ -0,0 +1,17 @@ +import React from 'react' + +import OTFLoadingIcon from '@components/OTFLoadingIcon' + +import './styles.scss'; + +const InlineLoading = () => { + return ( + <div className="loading-inline"> + <div className="loading-inline__icon"> + <OTFLoadingIcon /> + </div> + </div> + ) +} + +export default InlineLoading diff --git a/opentech/static_src/src/app/src/components/InlineLoading/styles.scss b/opentech/static_src/src/app/src/components/InlineLoading/styles.scss new file mode 100644 index 0000000000000000000000000000000000000000..06f6542444ea8378cf307419d05c0cb6f7554301 --- /dev/null +++ b/opentech/static_src/src/app/src/components/InlineLoading/styles.scss @@ -0,0 +1,13 @@ +.loading-inline { + width: 100%; + text-align: center; + + &__icon { + padding: 5px; + + svg { + height: 30px; + width: 30px; + } + } +} diff --git a/opentech/static_src/src/app/src/components/Listing/index.js b/opentech/static_src/src/app/src/components/Listing/index.js index a4d86373e65bac32b4f10ca16c9a5518f08c9c40..5e247b5b99a0fd049fe1d84ddbd4e6f42e7bb56c 100644 --- a/opentech/static_src/src/app/src/components/Listing/index.js +++ b/opentech/static_src/src/app/src/components/Listing/index.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { TransitionGroup } from 'react-transition-group'; import LoadingPanel from '@components/LoadingPanel'; +import InlineLoading from '@components/InlineLoading' import EmptyPanel from '@components/EmptyPanel'; import SadNoteIcon from 'images/sad-note.svg'; @@ -34,30 +35,52 @@ export default class Listing extends React.Component { listRef, } = this.props; - if (isLoading) { - return ( - <div className="listing__list"> - <LoadingPanel /> - </div> - ); - } else if (isError) { - return this.renderError(); - } else if (items.length === 0) { - return <EmptyPanel column={this.props.column} />; + if ( items.length === 0 ) { + if (isLoading) { + return ( + <div className="listing__list"> + <LoadingPanel /> + </div> + ); + } else if (isError) { + return this.renderError(); + } else { + return <EmptyPanel column={this.props.column} />; + } } return ( - <ul className={`listing__list listing__list--${column}`} ref={listRef}> - <TransitionGroup> - {items.map(v => renderItem(v))} - </TransitionGroup> - </ul> + <> + { isLoading && <InlineLoading /> } + <ul className={`listing__list listing__list--${column}`} ref={listRef}> + { isError && this.renderErrorItem() } + <TransitionGroup component={null} > + {items.map(v => renderItem(v))} + </TransitionGroup> + </ul> + </> ); } + renderRetryButton = () => { + const { handleRetry } = this.props; + return <a className="listing__help-link" onClick={handleRetry}>Refresh</a>; + } + + renderErrorItem = () => { + const { handleRetry, error } = this.props; + return ( + <li className={`listing__item listing__item--error`}> + <h5>Something went wrong!</h5> + <p>{ error }</p> + { !navigator.onLine && <p>You appear to be offline.</p>} + { handleRetry && this.renderRetryButton() } + </li> + ) + } + renderError = () => { const { handleRetry, error, column } = this.props; - const retryButton = <a className="listing__help-link" onClick={handleRetry}>Refresh</a>; return ( <div className={`listing__list listing__list--${column} is-blank`}> @@ -67,14 +90,14 @@ export default class Listing extends React.Component { <p>Something went wrong!</p> } - {handleRetry && retryButton && + {handleRetry && <> <div className="listing__blank-icon"> <SadNoteIcon /> </div> <p className="listing__help-text listing__help-text--standout">Something went wrong!</p> <p className="listing__help-text">Sorry we couldn't load the notes</p> - {retryButton} + { this.renderRetryButton() } </> } </div> diff --git a/opentech/static_src/src/app/src/components/Listing/style.scss b/opentech/static_src/src/app/src/components/Listing/style.scss index 618ac101961b8804509915306bdf585d3f97ae57..0f3b6ca6b3689069d14a53e3390c7334d496cc86 100644 --- a/opentech/static_src/src/app/src/components/Listing/style.scss +++ b/opentech/static_src/src/app/src/components/Listing/style.scss @@ -53,6 +53,14 @@ &__item { @include submission-list-item; + &--error { + padding: 5px 20px 5px; + + p { + margin: 5px 0px; + } + } + &.is-active { @include target-edge { margin-left: 8px; @@ -71,6 +79,7 @@ } } + // <a> tags &__link { display: block; diff --git a/opentech/static_src/src/app/src/containers/NoteListing.js b/opentech/static_src/src/app/src/containers/NoteListing.js index a6f80fbf6135cabc2ef70e86f5443ee42ef29756..23eb7fc19699442759727c9f8b2f410d482ea282 100644 --- a/opentech/static_src/src/app/src/containers/NoteListing.js +++ b/opentech/static_src/src/app/src/containers/NoteListing.js @@ -1,54 +1,43 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { CSSTransition } from 'react-transition-group'; -import { fetchNotesForSubmission } from '@actions/notes'; +import useInterval from "@rooks/use-interval" + +import { fetchNewNotesForSubmission } from '@actions/notes'; import Listing from '@components/Listing'; import Note from '@containers/Note'; import { getNotesErrorState, + getNotesErrorMessage, getNoteIDsForSubmissionOfID, getNotesFetchState, } from '@selectors/notes'; -class NoteListing extends React.Component { - static propTypes = { - loadNotes: PropTypes.func, - submissionID: PropTypes.number, - noteIDs: PropTypes.array, - isErrored: PropTypes.bool, - isLoading: PropTypes.bool, - }; - - componentDidUpdate(prevProps) { - const { isLoading, loadNotes, submissionID } = this.props; - const prevSubmissionID = prevProps.submissionID; - if( - submissionID !== null && submissionID !== undefined && - prevSubmissionID !== submissionID && !isLoading - ) { - loadNotes(submissionID); - } - } +const NoteListing = ({loadNotes, submissionID, noteIDs, isErrored, errorMessage, isLoading }) => { + const fetchNotes = () => loadNotes(submissionID) - componentDidMount() { - const { isLoading, loadNotes, submissionID } = this.props; + const {start, stop } = useInterval(fetchNotes, 30000) - if (submissionID && !isLoading) { - loadNotes(submissionID); + useEffect( () => { + if ( submissionID ) { + fetchNotes() + start() + } else { + stop() } - } + }, [submissionID]) + - handleRetry = () => { - if (this.props.isLoading || !this.props.isErrored) { - return; + const handleRetry = () => { + if (!isLoading || isErrored) { + fetchNotes() } - this.props.loadNotes(this.props.submissionID); } - renderItem = noteID => { + const renderItem = noteID => { return ( <CSSTransition key={`note-${noteID}`} timeout={200} classNames="add-note"> <Note key={`note-${noteID}`} noteID={noteID} /> @@ -56,29 +45,38 @@ class NoteListing extends React.Component { ); } - render() { - const { noteIDs } = this.props; - const passProps = { - isLoading: this.props.isLoading, - isError: this.props.isErrored, - handleRetry: this.handleRetry, - renderItem: this.renderItem, - items: noteIDs, - }; - return ( - <Listing {...passProps} column="notes" /> - ); - } + return ( + <Listing + isLoading={ isLoading } + isError={ isErrored } + error={ errorMessage } + handleRetry={ handleRetry } + renderItem={ renderItem } + items={ noteIDs } + column="notes" + /> + ); } +NoteListing.propTypes = { + loadNotes: PropTypes.func, + submissionID: PropTypes.number, + noteIDs: PropTypes.array, + isErrored: PropTypes.bool, + errorMessage: PropTypes.string, + isLoading: PropTypes.bool, +}; + + const mapDispatchToProps = dispatch => ({ - loadNotes: submissionID => dispatch(fetchNotesForSubmission(submissionID)), + loadNotes: submissionID => dispatch(fetchNewNotesForSubmission(submissionID)), }); const mapStateToProps = (state, ownProps) => ({ noteIDs: getNoteIDsForSubmissionOfID(ownProps.submissionID)(state), isLoading: getNotesFetchState(state), isErrored: getNotesErrorState(state), + errorMessage: getNotesErrorMessage(state), }); export default connect(mapStateToProps, mapDispatchToProps)(NoteListing); diff --git a/opentech/static_src/src/app/src/redux/actions/notes.js b/opentech/static_src/src/app/src/redux/actions/notes.js index b0200db84a47cf27a91b4162eaa4fe1bb79c8384..ac9b90a867324d84076b33e45e0884272ae5ef4e 100644 --- a/opentech/static_src/src/app/src/redux/actions/notes.js +++ b/opentech/static_src/src/app/src/redux/actions/notes.js @@ -1,4 +1,5 @@ import { CALL_API } from '@middleware/api' +import { getLatestNoteForSubmissionOfID } from '@selectors/notes' import api from '@api'; @@ -34,3 +35,22 @@ const createNote = (submissionID, note) => ({ }, submissionID, }) + + +export const fetchNewNotesForSubmission = (submissionID) => (dispatch, getState) => { + const latestNoteID = getLatestNoteForSubmissionOfID(submissionID)(getState()); + if ( latestNoteID ) { + return dispatch(fetchNewerNotes(submissionID, latestNoteID)) + } else { + return dispatch(fetchNotes(submissionID)) + } +} + + +const fetchNewerNotes = (submissionID, latestID) => ({ + [CALL_API]: { + types: [ START_FETCHING_NOTES, UPDATE_NOTES, FAIL_FETCHING_NOTES], + endpoint: api.fetchNewNotesForSubmission(submissionID, latestID), + }, + submissionID, +}) diff --git a/opentech/static_src/src/app/src/redux/reducers/notes.js b/opentech/static_src/src/app/src/redux/reducers/notes.js index 4c4a196fde9887c9862d70f3e6a84b7bd6b682b3..f27e5d351d4ad7a9a90173a4aa72fdeeda94dee6 100644 --- a/opentech/static_src/src/app/src/redux/reducers/notes.js +++ b/opentech/static_src/src/app/src/redux/reducers/notes.js @@ -21,13 +21,20 @@ function notesFetching(state = false, action) { } } -function notesErrored(state = false, action) { +function notesErrored(state = {errored: false, message: null}, action) { switch (action.type) { case UPDATE_NOTES: case START_FETCHING_NOTES: - return false; + return { + ...state, + errored: false, + }; case FAIL_FETCHING_NOTES: - return true; + return { + ...state, + message: action.error, + errored: true, + }; default: return state; } @@ -110,7 +117,7 @@ function notesByID(state = {}, action) { export default combineReducers({ byID: notesByID, isFetching: notesFetching, - isErrored: notesErrored, + error: notesErrored, createError: notesFailedCreating, isCreating: notesCreating, }); diff --git a/opentech/static_src/src/app/src/redux/reducers/submissions.js b/opentech/static_src/src/app/src/redux/reducers/submissions.js index 946039d231a22cffcd039e6b96c4c212fd94df09..7d76f0f3eaa44fc2785366054093b05541c1ff60 100644 --- a/opentech/static_src/src/app/src/redux/reducers/submissions.js +++ b/opentech/static_src/src/app/src/redux/reducers/submissions.js @@ -15,7 +15,7 @@ import { import { UPDATE_NOTES, UPDATE_NOTE } from '@actions/notes' -function submission(state, action) { +function submission(state={comments: []}, action) { switch(action.type) { case START_LOADING_SUBMISSION: return { @@ -39,7 +39,10 @@ function submission(state, action) { case UPDATE_NOTES: return { ...state, - comments: action.data.results.map(note => note.id), + comments: action.data.results + .map(note => note.id) + .filter(id => !state.comments.includes(id)) + .concat(state.comments) }; case UPDATE_NOTE: return { diff --git a/opentech/static_src/src/app/src/redux/selectors/notes.js b/opentech/static_src/src/app/src/redux/selectors/notes.js index cd035999ff96834f3262b618a24f43750aa251b8..dd91f2cc0efbe2c19b2d125c450ce2a23ab8b5d4 100644 --- a/opentech/static_src/src/app/src/redux/selectors/notes.js +++ b/opentech/static_src/src/app/src/redux/selectors/notes.js @@ -10,13 +10,20 @@ export const getNoteOfID = (noteID) => createSelector( export const getNotesFetchState = state => state.notes.isFetching === true; -export const getNotesErrorState = state => state.notes.isErrored === true; +export const getNotesErrorState = state => state.notes.error.errored === true; + +export const getNotesErrorMessage = state => state.notes.error.message; export const getNoteIDsForSubmissionOfID = submissionID => createSelector( [getSubmissionOfID(submissionID)], submission => (submission || {}).comments || [] ); +export const getLatestNoteForSubmissionOfID = submissionID => createSelector( + [getNoteIDsForSubmissionOfID(submissionID)], + notes => notes[0] || null +); + const getNoteCreatingErrors = state => state.notes.createError; export const getNoteCreatingErrorForSubmission = submissionID => createSelector( diff --git a/package-lock.json b/package-lock.json index 407c8c2cc2a5689eb9f05e8b66e40a3d8925f530..19268ff324e2a31f659e208d18d2d138b7e91404 100644 --- a/package-lock.json +++ b/package-lock.json @@ -813,6 +813,11 @@ "through2": "^2.0.3" } }, + "@rooks/use-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rooks/use-interval/-/use-interval-1.2.0.tgz", + "integrity": "sha512-gtYrJUNdYgflZRGwT7NjVqLRiD3EVT1ZxDqDCTStMN5jWtgIZulm8OJ3ZzZn62UTXtoSPcE/pJkQDccaD/NzhA==" + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.0.0.tgz", @@ -8505,20 +8510,20 @@ } }, "react-dom": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.7.0.tgz", - "integrity": "sha512-D0Ufv1ExCAmF38P2Uh1lwpminZFRXEINJe53zRAbm4KPwSyd6DY/uDoS0Blj9jvPpn1+wivKpZYc8aAAN/nAkg==", + "version": "16.8.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.1.tgz", + "integrity": "sha512-N74IZUrPt6UiDjXaO7UbDDFXeUXnVhZzeRLy/6iqqN1ipfjrhR60Bp5NuBK+rv3GMdqdIuwIl22u1SYwf330bg==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2", - "scheduler": "^0.12.0" + "scheduler": "^0.13.1" } }, "react-hot-loader": { - "version": "4.6.3", - "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.6.3.tgz", - "integrity": "sha512-FUvRO8dwbeLnc3mgLn8ARuSh8NnLBYJyiRjFn+grY/5GupSyPqv0U7ixgwXro77hwDplhm8z9wU7FlJ8kZqiAQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/react-hot-loader/-/react-hot-loader-4.6.5.tgz", + "integrity": "sha512-ZPAJEWVd8KDdm6dcK0iWrnJiGHruLrcbkIpqn/wQmNjnROpsm2nzrWh23Yh3I/XAjB+35pMa/ZgariwGqwFD9A==", "dev": true, "requires": { "fast-levenshtein": "^2.0.6", @@ -9439,9 +9444,9 @@ "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "scheduler": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.12.0.tgz", - "integrity": "sha512-t7MBR28Akcp4Jm+QoR63XgAi9YgCUmgvDHqf5otgAj4QvdoBE4ImCX0ffehefePPG+aitiYHp0g/mW6s4Tp+dw==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.1.tgz", + "integrity": "sha512-VJKOkiKIN2/6NOoexuypwSrybx13MY7NSy9RNt8wPvZDMRT1CW6qlpF5jXRToXNHz3uWzbm2elNpZfXfGPqP9A==", "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/package.json b/package.json index de38ee37b999164cb0a0d60975d79d09e9d0f1ae..a80398b653ac9d44a095ebeb03afd7cea9f11cd5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ ], "dependencies": { "@babel/polyfill": "^7.2.5", + "@rooks/use-interval": "^1.2.0", "@svgr/webpack": "^4.1.0", "del": "^3.0.0", "gulp": "^4.0.0", @@ -30,7 +31,7 @@ "node-sass-import-once": "^1.2.0", "prop-types": "^15.6.2", "react": "^16.8.1", - "react-dom": "^16.7.0", + "react-dom": "^16.8.1", "react-redux": "^6.0.0", "react-rte": "^0.16.1", "react-transition-group": "^2.5.3", @@ -55,7 +56,7 @@ "gulp-sass-lint": "^1.4.0", "gulp-sourcemaps": "^2.6.4", "node-sass": "^4.11.0", - "react-hot-loader": "^4.6.3", + "react-hot-loader": "^4.6.5", "redux-devtools-extension": "^2.13.7", "sass-loader": "^7.1.0", "style-loader": "^0.23.1",