From 8ea405489417b319653139ca1bd24842155908bc Mon Sep 17 00:00:00 2001 From: Mark Bouslog Date: Tue, 4 Dec 2018 17:29:04 -0600 Subject: [PATCH] Add explorer functionality (#25) * Modularize location-match * Init explorer context * Init explorer refactor * Refactor total classifications stat * Refactor contexts * Refactor favs for explorer view * Refactor heading for explorer view --- src/components/App.jsx | 93 +++++++++++++---------- src/components/SubjectCard.jsx | 34 ++++++--- src/components/UserHeading.jsx | 22 ++++-- src/components/UserStats.jsx | 22 +++--- src/config.js | 14 +--- src/containers/RecentsContainer.jsx | 12 +-- src/containers/StatsContainer.jsx | 37 ++-------- src/context/ExplorerContext.jsx | 111 ++++++++++++++++++++++++++++ src/context/FavoritesContext.jsx | 37 +++++----- src/context/ProjectContext.jsx | 18 ++--- src/index.css | 4 + src/lib/location-match.js | 16 ++++ 12 files changed, 271 insertions(+), 149 deletions(-) create mode 100644 src/context/ExplorerContext.jsx create mode 100644 src/lib/location-match.js diff --git a/src/components/App.jsx b/src/components/App.jsx index b4c4fcf..d109858 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -3,6 +3,7 @@ import GrommetApp from 'grommet/components/App'; import Box from 'grommet/components/Box'; import { ZooFooter, ZooHeader } from 'zooniverse-react-components'; +import { ExplorerProvider, ExplorerContext } from '../context/ExplorerContext'; import { FavoritesProvider, FavoritesContext @@ -27,44 +28,62 @@ const App = () => ( } /> {({ project }) => ( - - - - - - {({ favoriteCollection, linkedSubjects }) => ( - + + {({ explorer, matchesUser }) => ( + + + + - -
- -
- )} -
-
- -
- - Your Badges - -
+ + {({ favoriteCollection, linkedSubjects }) => ( + + +
+
+ {matchesUser ? ( + + ) : null} +
+
+ )} +
+ + + + + Your Badges + + + )} + + )}
diff --git a/src/components/SubjectCard.jsx b/src/components/SubjectCard.jsx index 19de1c6..5dac19b 100644 --- a/src/components/SubjectCard.jsx +++ b/src/components/SubjectCard.jsx @@ -4,9 +4,10 @@ import Box from 'grommet/components/Box'; import { Thumbnail } from 'zooniverse-react-components'; import { config } from '../config'; +import { ExplorerContext } from '../context/ExplorerContext'; +import { FavoritesContext } from '../context/FavoritesContext'; import { ProjectContext } from '../context/ProjectContext'; import getSubjectLocations from '../lib/get-subject-locations'; -import { FavoritesContext } from '../context/FavoritesContext'; import FavoritesButton from './FavoritesButton'; export default function SubjectCard({ subject }) { @@ -46,16 +47,29 @@ export default function SubjectCard({ subject }) { /> - - {({ addSubjectTo, linkedSubjects, removeSubjectFrom }) => ( - + + {({ matchesUser }) => ( +
+ {matchesUser ? ( + + {({ + addSubjectTo, + linkedSubjects, + removeSubjectFrom + }) => ( + + )} + + ) : null} +
)} -
+
)} diff --git a/src/components/UserHeading.jsx b/src/components/UserHeading.jsx index 1f6253b..a025b8b 100644 --- a/src/components/UserHeading.jsx +++ b/src/components/UserHeading.jsx @@ -4,17 +4,21 @@ import Heading from 'grommet/components/Heading'; import { config } from '../config'; -export default function UserHeading({ project, user }) { - if (user && project) { +export default function UserHeading({ project, explorer, matchesUser }) { + if (explorer && project) { const projectLink = ( {project.display_name} ); + const headingClassName = matchesUser + ? 'user-heading' + : 'user-heading user-heading--explorer'; + return ( - - {`${user.display_name}'s `} + + {`${explorer.display_name}'s `} {projectLink} {' Field Book'} @@ -32,9 +36,10 @@ UserHeading.propTypes = { display_name: PropTypes.string, slug: PropTypes.string }), - user: PropTypes.shape({ + explorer: PropTypes.shape({ display_name: PropTypes.string - }) + }), + matchesUser: PropTypes.bool }; UserHeading.defaultProps = { @@ -42,7 +47,8 @@ UserHeading.defaultProps = { display_name: '', slug: '' }, - user: { + explorer: { display_name: '' - } + }, + matchesUser: true }; diff --git a/src/components/UserStats.jsx b/src/components/UserStats.jsx index ca406e7..f23fd84 100644 --- a/src/components/UserStats.jsx +++ b/src/components/UserStats.jsx @@ -7,11 +7,9 @@ import Value from 'grommet/components/Value'; import Title from './Title'; -export default function UserStats({ - activityCount, - userStatsByDay, - userStatsByMonth -}) { +export default function UserStats({ userStatsByDay, userStatsByMonth }) { + let totalClassifications = 0; + let maxDay = { label: '', value: 0 }; if (userStatsByDay) { maxDay = userStatsByDay.reduce( @@ -22,10 +20,12 @@ export default function UserStats({ let maxMonth = { label: '', value: 0 }; if (userStatsByMonth) { - maxMonth = userStatsByMonth.reduce( - (max, stat) => (stat.value > max.value ? stat : max), - maxMonth - ); + userStatsByMonth.forEach(stat => { + if (stat.value > maxMonth.value) { + maxMonth = stat; + } + totalClassifications += stat.value; + }); } return ( @@ -41,7 +41,7 @@ export default function UserStats({ @@ -71,7 +71,6 @@ export default function UserStats({ } UserStats.propTypes = { - activityCount: PropTypes.number, userStatsByDay: PropTypes.arrayOf( PropTypes.shape({ label: PropTypes.string, @@ -87,7 +86,6 @@ UserStats.propTypes = { }; UserStats.defaultProps = { - activityCount: 0, userStatsByDay: null, userStatsByMonth: null }; diff --git a/src/config.js b/src/config.js index 60e5f38..d05f88b 100644 --- a/src/config.js +++ b/src/config.js @@ -7,19 +7,7 @@ By default, this is the development environment, but this can be changed either - The NODE_ENV environment variable on the system running the app. */ -// Try and match the window.location.search property against a regex. Basically mimics -// the CoffeeScript existential operator, in case we're not in a browser. -function locationMatch(regex) { - let match; - if ( - window && - typeof window.location !== 'undefined' && - window.location !== null - ) { - match = window.location.search.match(regex); - } - return match && match[1] ? match[1] : undefined; -} +import locationMatch from './lib/location-match'; const DEFAULT_ENV = 'development'; const envFromBrowser = locationMatch(/\W?env=(\w+)/); diff --git a/src/containers/RecentsContainer.jsx b/src/containers/RecentsContainer.jsx index 73f61c1..e0dfff8 100644 --- a/src/containers/RecentsContainer.jsx +++ b/src/containers/RecentsContainer.jsx @@ -23,7 +23,7 @@ class RecentsContainer extends React.Component { } componentDidUpdate(prevProps) { - if (prevProps.user !== this.props.user) { + if (prevProps.explorer !== this.props.explorer) { this.fetchRecents(); } } @@ -33,8 +33,8 @@ class RecentsContainer extends React.Component { } fetchRecents(page = 1) { - const { user } = this.props; - if (user && user.get) { + const { explorer } = this.props; + if (explorer && explorer.get) { const query = { project_id: config.projectId, sort: '-created_at', @@ -42,7 +42,7 @@ class RecentsContainer extends React.Component { page_size: 3 }; - user + explorer .get('recents', query) .then(recents => { this.setState({ meta: recents[0].getMeta(), recents }); @@ -81,13 +81,13 @@ class RecentsContainer extends React.Component { } RecentsContainer.propTypes = { - user: PropTypes.shape({ + explorer: PropTypes.shape({ get: PropTypes.func }) }; RecentsContainer.defaultProps = { - user: null + explorer: null }; export default RecentsContainer; diff --git a/src/containers/StatsContainer.jsx b/src/containers/StatsContainer.jsx index 525af4d..65a3065 100644 --- a/src/containers/StatsContainer.jsx +++ b/src/containers/StatsContainer.jsx @@ -13,7 +13,6 @@ class StatsContainer extends React.Component { this.state = { collective: false, collectiveStatsByDay: null, - preferences: null, userStatsByDay: null, userStatsByMonth: null }; @@ -22,46 +21,27 @@ class StatsContainer extends React.Component { } componentDidMount() { - this.fetchPreferences(); this.fetchStats(false, 'day'); this.fetchStats(false, 'month'); } componentDidUpdate(prevProps) { - if (prevProps.user !== this.props.user) { - this.fetchPreferences(); + if (prevProps.explorer !== this.props.explorer) { this.fetchStats(false, 'day'); this.fetchStats(false, 'month'); } } - fetchPreferences() { - const { user } = this.props; - - if (user && user.get) { - user - .get('project_preferences', { project_id: config.projectId }) - .then(([preferences]) => this.setState({ preferences })) - .catch(() => { - if (console) { - console.warn('Failed to fetch user preferences'); - } - });; - } else { - this.setState({ preferences: null }); - } - } - fetchStats(collective = false, period = 'day') { - const { user } = this.props; + const { explorer } = this.props; - if (user) { + if (explorer) { statsClient .query({ period, projectID: config.projectId, type: 'classification', - userID: collective ? '' : user.id + userID: collective ? '' : explorer.id }) .then(data => data.map(statObject => ({ @@ -108,9 +88,6 @@ class StatsContainer extends React.Component { return ( @@ -126,13 +103,13 @@ class StatsContainer extends React.Component { } StatsContainer.propTypes = { - user: PropTypes.shape({ - get: PropTypes.func + explorer: PropTypes.shape({ + id: PropTypes.string }) }; StatsContainer.defaultProps = { - user: null + explorer: null }; export default StatsContainer; diff --git a/src/context/ExplorerContext.jsx b/src/context/ExplorerContext.jsx new file mode 100644 index 0000000..5a8d065 --- /dev/null +++ b/src/context/ExplorerContext.jsx @@ -0,0 +1,111 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import apiClient from 'panoptes-client/lib/api-client'; +import locationMatch from '../lib/location-match'; + +export const ExplorerContext = React.createContext(); + +export class ExplorerProvider extends Component { + constructor(props) { + super(props); + this.state = { + explorer: null, + matchesUser: true + }; + } + + componentDidMount() { + const { user } = this.props; + const explorerQuery = locationMatch(/\W?explorer_id=(\w+)/); + if (user) { + this.setExplorer(explorerQuery); + } + } + + componentDidUpdate(prevProps) { + const { project, user } = this.props; + if (prevProps.project !== project || prevProps.user !== user) { + const explorerQuery = locationMatch(/\W?explorer_id=(\w+)/); + this.setExplorer(explorerQuery); + } + } + + setExplorer(explorerQuery) { + const { project, user } = this.props; + if (explorerQuery && project) { + this.checkPermission(); + } else if (user) { + this.setState({ explorer: user, matchesUser: true }); + } else { + this.setState({ explorer: null, matchesUser: true }); + } + } + + fetchRoles() { + const { project, user } = this.props; + project + .get('project_roles') + .then(roles => { + if (roles.length) { + const collaboratorRoles = roles.filter( + role => + role.roles.includes('collaborator') || + role.roles.includes('owner') + ); + return collaboratorRoles.some( + role => role.links.owner.id === user.id + ); + } + return false; + }) + .catch(() => console.warn('Failed to fetch project roles')); + } + + checkPermission() { + const { user } = this.props; + if ((user && user.admin) || this.fetchRoles()) { + this.fetchExplorer(); + } + } + + fetchExplorer() { + const explorerQuery = locationMatch(/\W?explorer_id=(\w+)/); + apiClient + .type('users') + .get({ id: explorerQuery }) + .then(([userResponse]) => { + const matchesUser = this.props.user.id === userResponse.id; + this.setState({ explorer: userResponse, matchesUser }); + }) + .catch(() => console.warn('Failed to fetch explorer')); + } + + render() { + return ( + + {this.props.children} + + ); + } +} + +ExplorerProvider.propTypes = { + children: PropTypes.element.isRequired, + project: PropTypes.shape({ + id: PropTypes.string, + slug: PropTypes.string + }), + user: PropTypes.shape({ + login: PropTypes.string + }) +}; + +ExplorerProvider.defaultProps = { + project: null, + user: null +}; diff --git a/src/context/FavoritesContext.jsx b/src/context/FavoritesContext.jsx index 978d76a..08860ed 100644 --- a/src/context/FavoritesContext.jsx +++ b/src/context/FavoritesContext.jsx @@ -9,8 +9,7 @@ export class FavoritesProvider extends Component { super(props); this.state = { favoriteCollection: null, - linkedSubjects: [], - initialised: false + linkedSubjects: [] }; this.addSubjectTo = this.addSubjectTo.bind(this); @@ -18,39 +17,38 @@ export class FavoritesProvider extends Component { } componentDidMount() { - const { project, user } = this.props; - const { initialised } = this.state; - if (!initialised && project && user) { + const { project, explorer } = this.props; + if (project && explorer) { this.fetchFavoriteCollection(); } } componentDidUpdate(prevProps) { - const { project, user } = this.props; - if (prevProps.project !== project || prevProps.user !== user) { - if (project && user) { + const { project, explorer } = this.props; + if (prevProps.project !== project || prevProps.explorer !== explorer) { + if (project && explorer) { this.fetchFavoriteCollection(); } } } fetchFavoriteCollection() { - const { project, user } = this.props; + const { project, explorer } = this.props; apiClient .type('collections') .get({ - owner: user.login, + owner: explorer.login, project_ids: project.id, favorite: true }) - .then(([collections]) => { - if (collections) { + .then(collections => { + if (collections && collections.length > 0) { + const [collection] = collections; this.setState({ - initialised: true, - favoriteCollection: collections, + favoriteCollection: collection, linkedSubjects: - collections.links && collections.links.subjects - ? collections.links.subjects + collection.links && collection.links.subjects + ? collection.links.subjects : [] }); } @@ -113,14 +111,13 @@ export class FavoritesProvider extends Component { } render() { - const { favoriteCollection, linkedSubjects, initialised } = this.state; + const { favoriteCollection, linkedSubjects } = this.state; return ( @@ -136,12 +133,12 @@ FavoritesProvider.propTypes = { id: PropTypes.string, slug: PropTypes.string }), - user: PropTypes.shape({ + explorer: PropTypes.shape({ login: PropTypes.string }) }; FavoritesProvider.defaultProps = { project: null, - user: null + explorer: null }; diff --git a/src/context/ProjectContext.jsx b/src/context/ProjectContext.jsx index 09505a5..e5b18cb 100644 --- a/src/context/ProjectContext.jsx +++ b/src/context/ProjectContext.jsx @@ -10,25 +10,21 @@ export class ProjectProvider extends Component { constructor(props) { super(props); this.state = { - initialised: false, project: null }; } componentDidMount() { - if (!this.state.initialised) { - apiClient - .type('projects') - .get(config.projectId) - .then(project => this.setState({ initialised: true, project })); - } + apiClient + .type('projects') + .get(config.projectId) + .then(project => this.setState({ project })); } render() { return ( @@ -47,11 +43,7 @@ export function withProject(MyComponent) { return ( {projectState => ( - + )} ); diff --git a/src/index.css b/src/index.css index 9e8dbb9..3e4414f 100644 --- a/src/index.css +++ b/src/index.css @@ -72,4 +72,8 @@ body { color: #5c5c5c; letter-spacing: -.5px; margin: 1em 0; +} + +.user-heading--explorer { + color: red } \ No newline at end of file diff --git a/src/lib/location-match.js b/src/lib/location-match.js new file mode 100644 index 0000000..db764f2 --- /dev/null +++ b/src/lib/location-match.js @@ -0,0 +1,16 @@ +// Try and match the window.location.search property against a regex. Basically mimics +// the CoffeeScript existential operator, in case we're not in a browser. + +function locationMatch(regex) { + let match; + if ( + window && + typeof window.location !== 'undefined' && + window.location !== null + ) { + match = window.location.search.match(regex); + } + return match && match[1] ? match[1] : undefined; +} + +export default locationMatch;