diff --git a/lang/en-US.json b/lang/en-US.json index a7f853587..a5947fc9d 100644 --- a/lang/en-US.json +++ b/lang/en-US.json @@ -1133,6 +1133,9 @@ "Notification.type.revisionCount": "Revision Count", "Notification.type.system": "System", "Notification.type.team": "Team", + "OSMElementData.controls.viewOSM.label": "View OSM", + "OSMElementData.elementFetchFailed": "Failed to fetch tags for {element}", + "OSMElementData.noOSMElements": "No OSM elements identified in task", "OSMElementHistory.controls.viewOSM.label": "View OSM", "OSMElementHistory.elementFetchFailed": "Failed to fetch history for {element}", "OSMElementHistory.noComment": "(no changeset comment)", @@ -1724,6 +1727,8 @@ "Widgets.LeaderboardWidget.mapper": "Mappers", "Widgets.LeaderboardWidget.reviewer": "Reviewers", "Widgets.LeaderboardWidget.title": "Leaderboard", + "Widgets.OSMDataWidget.label": "OSM Data", + "Widgets.OSMDataWidget.title": "OSM Data", "Widgets.OSMHistoryWidget.label": "OSM History", "Widgets.OSMHistoryWidget.title": "OSM History", "Widgets.ProjectAboutWidget.content": "Projects serve as a means of grouping related challenges together. All\nchallenges must belong to a project.\n\nYou can create as many projects as needed to organize your challenges, and can\ninvite other MapRoulette users to help manage them with you.\n\nProjects must be set to Discoverable before any challenges within them will\nshow up in public browsing or searching.", diff --git a/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx b/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx index 1bcb87eed..209bf6138 100644 --- a/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx +++ b/src/components/HOCs/WithCurrentTask/WithCurrentTask.jsx @@ -27,7 +27,7 @@ import { fetchChallenge, fetchParentProject } import { fetchUser } from '../../../services/User/User' import { TaskLoadMethod } from '../../../services/Task/TaskLoadMethod/TaskLoadMethod' -import { fetchOSMUser, fetchOSMData, fetchOSMElementHistory } +import { fetchOSMUser, fetchOSMData, fetchOSMElement, fetchOSMElementHistory } from '../../../services/OSM/OSM' import { fetchChallengeActions } from '../../../services/Challenge/Challenge' import { renewVirtualChallenge } @@ -277,6 +277,7 @@ export const mapDispatchToProps = (dispatch, ownProps) => { }, fetchOSMElementHistory, + fetchOSMElement, } } diff --git a/src/components/OSMElementData/Messages.js b/src/components/OSMElementData/Messages.js new file mode 100644 index 000000000..44a738629 --- /dev/null +++ b/src/components/OSMElementData/Messages.js @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl' + +/** + * Internationalized messages for use with OSMElementData + */ +export default defineMessages({ + noOSMElements: { + id: "OSMElementData.noOSMElements", + defaultMessage: "No OSM elements identified in task", + }, + + elementFetchFailed: { + id: "OSMElementData.elementFetchFailed", + defaultMessage: "Failed to fetch tags for {element}", + }, + + viewOSMLabel: { + id: "OSMElementData.controls.viewOSM.label", + defaultMessage: "View OSM", + }, +}) diff --git a/src/components/OSMElementData/OSMElementData.jsx b/src/components/OSMElementData/OSMElementData.jsx new file mode 100644 index 000000000..9d5a3dcf9 --- /dev/null +++ b/src/components/OSMElementData/OSMElementData.jsx @@ -0,0 +1,186 @@ +import { Fragment, useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { FormattedMessage, injectIntl } + from 'react-intl' +import _map from 'lodash/map' +import _compact from 'lodash/compact' +import _isEmpty from 'lodash/isEmpty' +import _sortBy from 'lodash/sortBy' +import _find from 'lodash/find' +import _get from 'lodash/get' +import _flatten from 'lodash/flatten' +import AsMappableTask + from '../../interactions/Task/AsMappableTask' +import AsIdentifiableFeature + from '../../interactions/TaskFeature/AsIdentifiableFeature' +import Dropdown from '../Dropdown/Dropdown' +import SvgSymbol from '../SvgSymbol/SvgSymbol' +import BusySpinner from '../BusySpinner/BusySpinner' +import messages from './Messages' +import './OSMElementData.scss' + +const OSM_SERVER = window.env.REACT_APP_OSM_SERVER + +const OSMElementData = props => { + const [selectedFeatureId, setSelectedFeatureId] = useState(null) + const [element, setElement] = useState(null) + const [fetchingElement, setFetchingElement] = useState(null) + const [fetchedElement, setFetchedElement] = useState(null) + const [failedElement, setFailedElement] = useState(null) + const [fetchErr, setFetchErr] = useState(null) + + const { fetchOSMElement, taskBundle } = props + const primaryTask = props.task + const allTasks = taskBundle ? taskBundle.tasks : [primaryTask] + + const featureIds = _flatten(_compact(_map(allTasks, task => { + const geometries = AsMappableTask(task).normalizedGeometries() + return geometries ? _compact(_map( + geometries.features, + f => AsIdentifiableFeature(f).normalizedTypeAndId(true, '/') + )) : null + }))) + + // If there is no selected feature or it is no longer available (e.g. because a task bundle + // was unbundled), then update the selection + useEffect(() => { + if (!selectedFeatureId || featureIds.indexOf(selectedFeatureId) === -1) { + setSelectedFeatureId(featureIds[0] ?? null) + } + }, [featureIds, selectedFeatureId]) + + // Fetch and process the OSM Data + useEffect(() => { + if (selectedFeatureId === null) { + // No features to fetch, cleanup existing to ensure consistency + setElement(null) + setFetchedElement(null) + return + } + + // If we're already fetching data for the active feature, or if the fetch failed, or if we already have its data, + // there's nothing to do + if (fetchingElement === selectedFeatureId || failedElement === selectedFeatureId || fetchedElement === selectedFeatureId) { + return + } + + setFetchingElement(selectedFeatureId) + setFailedElement(null) + setFetchErr(null) + fetchOSMElement(selectedFeatureId).then(element => { + // If for some reason we don't get any element entries, record a failure + // to prevent further attempts to refetch + if (!element) { // TODO Can this happen ? + setFailedElement(selectedFeatureId) + setFetchingElement(null) + return + } + + setFetchedElement(selectedFeatureId) + setElement(element) + setFetchingElement(null) + }).catch(err => { + setFailedElement(selectedFeatureId) + setFetchErr(err) + setFetchingElement(null) + }) + }, [selectedFeatureId, fetchingElement, failedElement, fetchOSMElement]) + + if (fetchingElement) { + return ( +
+ +
+ ) + } + + const activeFeatureId = selectedFeatureId ? selectedFeatureId : featureIds[0] + if (failedElement === activeFeatureId) { + return ( +
+ + {fetchErr && fetchErr.defaultMessage && } +
+ ) + } + + if (!element) { + return ( + + ) + } + + const tagsValues = _map(element.tag, tag =>
{tag.k}
{tag.v}
) + + return ( +
+
+ + + + +
+
+ {tagsValues} +
+
+ ) +} + +OSMElementData.propTypes = { + task: PropTypes.object, + taskBundle: PropTypes.object, + fetchOSMElement: PropTypes.func.isRequired, +} + +const FeatureSelectionDropdown = props => { + const menuItems = + _map(props.featureIds, featureId => ( +
  • + props.selectFeatureId(featureId)}> + {featureId} + +
  • + )) + + if (menuItems.length === 0) { + return null + } + + return ( + ( + + +
    + {props.selectedFeatureId} +
    + +
    +
    + )} + dropdownContent={() => +
      + {menuItems} +
    + } + /> + ); +} + +export default injectIntl(OSMElementData) diff --git a/src/components/OSMElementData/OSMElementData.scss b/src/components/OSMElementData/OSMElementData.scss new file mode 100644 index 000000000..01cd1216d --- /dev/null +++ b/src/components/OSMElementData/OSMElementData.scss @@ -0,0 +1,17 @@ +@import '../../variables.scss'; + +dl.tag-list { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-column-gap: 15px; + grid-row-gap: 0px; + + dt { + font-family: monospace; + font-weight: $weight-bold; + } + + dd { + font-size: $size-7; + } +} diff --git a/src/components/Widgets/OSMDataWidget/Messages.js b/src/components/Widgets/OSMDataWidget/Messages.js new file mode 100644 index 000000000..2df236492 --- /dev/null +++ b/src/components/Widgets/OSMDataWidget/Messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from 'react-intl' + +/** + * Internationalized messages for use with OSMDataWidget + */ +export default defineMessages({ + label: { + id: "Widgets.OSMDataWidget.label", + defaultMessage: "OSM Data", + }, + + title: { + id: "Widgets.OSMDataWidget.title", + defaultMessage: "OSM Data", + }, +}) diff --git a/src/components/Widgets/OSMDataWidget/OSMDataWidget.jsx b/src/components/Widgets/OSMDataWidget/OSMDataWidget.jsx new file mode 100644 index 000000000..bd73cafe0 --- /dev/null +++ b/src/components/Widgets/OSMDataWidget/OSMDataWidget.jsx @@ -0,0 +1,33 @@ +import { Component } from 'react' +import { FormattedMessage } from 'react-intl' +import { WidgetDataTarget, registerWidgetType } + from '../../../services/Widget/Widget' +import OSMElementData from '../../OSMElementData/OSMElementData' +import QuickWidget from '../../QuickWidget/QuickWidget' +import messages from './Messages' + +const descriptor = { + widgetKey: 'OSMDataWidget', + label: messages.label, + targets: [WidgetDataTarget.task], + minWidth: 3, + defaultWidth: 4, + minHeight: 3, + defaultHeight: 6, +} + +export default class OSMDataWidget extends Component { + render() { + return ( + } + > + + + ) + } +} + +registerWidgetType(OSMDataWidget, descriptor) diff --git a/src/components/Widgets/widget_registry.js b/src/components/Widgets/widget_registry.js index 888ce7503..281de3539 100644 --- a/src/components/Widgets/widget_registry.js +++ b/src/components/Widgets/widget_registry.js @@ -9,6 +9,8 @@ export { default as SupplementalMapWidget } from './SupplementalMapWidget/SupplementalMapWidget' export { default as CustomUrlWidget } from './CustomUrlWidget/CustomUrlWidget' +export { default as OSMDataWidget } + from './OSMDataWidget/OSMDataWidget' export { default as OSMHistoryWidget } from './OSMHistoryWidget/OSMHistoryWidget' export { default as ActivityListingWidget }