From 66b704cd3f34efde0ef73c62d6d5d2a0b9789210 Mon Sep 17 00:00:00 2001 From: stdavis Date: Fri, 8 Jul 2022 15:53:37 -0600 Subject: [PATCH] feat: port Project Information widget from wasatch choice map Including: * i18n setup * MapWidget Ref: #19 --- .vscode/extensions.json | 5 + .vscode/settings.json | 5 +- package-lock.json | 119 +++++++++++ package.json | 4 + public/config.json | 41 ++++ public/config.schema.json | 96 +++++++++ src/App.jsx | 73 ++++++- src/components/Details.jsx | 60 ++++++ src/components/Details.scss | 26 +++ src/components/Details.stories.jsx | 33 +++ src/components/Filter.jsx | 269 +++++++++++-------------- src/components/Filter.scss | 8 - src/components/MapWidget.jsx | 90 +++++++++ src/components/MapWidget.scss | 42 ++++ src/components/MapWidget.stories.jsx | 19 ++ src/components/ProjectInformation.jsx | 49 +++++ src/components/ProjectInformation.scss | 14 ++ src/i18n.js | 20 ++ src/i18n.test.js | 15 ++ src/services/config.js | 38 ++-- 20 files changed, 843 insertions(+), 183 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 src/components/Details.jsx create mode 100644 src/components/Details.scss create mode 100644 src/components/Details.stories.jsx create mode 100644 src/components/MapWidget.jsx create mode 100644 src/components/MapWidget.scss create mode 100644 src/components/MapWidget.stories.jsx create mode 100644 src/components/ProjectInformation.jsx create mode 100644 src/components/ProjectInformation.scss create mode 100644 src/i18n.js create mode 100644 src/i18n.test.js diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..6dfd533 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "streetsidesoftware.code-spell-checker-spanish" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 47d5fc3..88756d2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,11 +5,14 @@ "fontawesome", "fortawesome", "immer", + "languagedetector", "offcanvas", "pluginutils", "postbump", "Sharrow", + "sherlock", "softprops", "TSQL" - ] + ], + "cSpell.language": "en,es-ES" } diff --git a/package-lock.json b/package-lock.json index e70051e..8ade519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,14 +14,18 @@ "bootstrap": "^5.2.0-beta1", "clsx": "^1.1.1", "downshift": "^6.1.7", + "i18next": "^21.8.13", + "i18next-browser-languagedetector": "^6.1.4", "jsonschema": "^1.4.1", "lodash.debounce": "^4.0.8", "lodash.escaperegexp": "^4.1.2", "lodash.sortby": "^4.7.0", "lodash.uniqwith": "^4.5.0", + "perfect-scrollbar": "^1.5.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", + "react-i18next": "^11.18.0", "react-number-format": "^4.9.3", "reactstrap": "^9.1.1", "sql-formatter": "^7.0.3", @@ -13167,6 +13171,14 @@ "node": ">= 6" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", @@ -13384,6 +13396,36 @@ "node": ">=10.17.0" } }, + "node_modules/i18next": { + "version": "21.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.13.tgz", + "integrity": "sha512-DpzwrJq7Y8tjUHxx6ByOkUIjrGYdQI5Mfv4XEI7q2RWdknQ7TaO9bKi8hS/LqYD6pBV5YGxJLReyLkOCxIpouA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.17.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "dependencies": { + "@babel/runtime": "^7.14.6" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -16696,6 +16738,11 @@ "node": ">=0.12" } }, + "node_modules/perfect-scrollbar": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.5.tgz", + "integrity": "sha512-dzalfutyP3e/FOpdlhVryN4AJ5XDVauVWxybSkLZmakFE2sS3y3pc4JnSprw8tGmHvkaG5Edr5T7LBTZ+WWU2g==" + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -17630,6 +17677,27 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "node_modules/react-i18next": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.0.tgz", + "integrity": "sha512-coJujU20xJ5Wa5rHjTyB5LFKZb1yfXo2A+40RRSyAF0FlZRHyy+3C1Mr92x1JPfS7W7v2TWNn8mRhpDFGJwXVg==", + "dependencies": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 19.0.0", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-inspector": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz", @@ -21660,6 +21728,14 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -32726,6 +32802,14 @@ } } }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, "html-tags": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz", @@ -32904,6 +32988,22 @@ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true }, + "i18next": { + "version": "21.8.13", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.8.13.tgz", + "integrity": "sha512-DpzwrJq7Y8tjUHxx6ByOkUIjrGYdQI5Mfv4XEI7q2RWdknQ7TaO9bKi8hS/LqYD6pBV5YGxJLReyLkOCxIpouA==", + "requires": { + "@babel/runtime": "^7.17.2" + } + }, + "i18next-browser-languagedetector": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.1.4.tgz", + "integrity": "sha512-wukWnFeU7rKIWT66VU5i8I+3Zc4wReGcuDK2+kuFhtoxBRGWGdvYI9UQmqNL/yQH1KogWwh+xGEaIPH8V/i2Zg==", + "requires": { + "@babel/runtime": "^7.14.6" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -35464,6 +35564,11 @@ "sha.js": "^2.4.8" } }, + "perfect-scrollbar": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/perfect-scrollbar/-/perfect-scrollbar-1.5.5.tgz", + "integrity": "sha512-dzalfutyP3e/FOpdlhVryN4AJ5XDVauVWxybSkLZmakFE2sS3y3pc4JnSprw8tGmHvkaG5Edr5T7LBTZ+WWU2g==" + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -36162,6 +36267,15 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "react-i18next": { + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.18.0.tgz", + "integrity": "sha512-coJujU20xJ5Wa5rHjTyB5LFKZb1yfXo2A+40RRSyAF0FlZRHyy+3C1Mr92x1JPfS7W7v2TWNn8mRhpDFGJwXVg==", + "requires": { + "@babel/runtime": "^7.14.5", + "html-parse-stringify": "^3.0.1" + } + }, "react-inspector": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-5.1.1.tgz", @@ -39325,6 +39439,11 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", diff --git a/package.json b/package.json index 8b2bd8d..e7556b0 100644 --- a/package.json +++ b/package.json @@ -62,14 +62,18 @@ "bootstrap": "^5.2.0-beta1", "clsx": "^1.1.1", "downshift": "^6.1.7", + "i18next": "^21.8.13", + "i18next-browser-languagedetector": "^6.1.4", "jsonschema": "^1.4.1", "lodash.debounce": "^4.0.8", "lodash.escaperegexp": "^4.1.2", "lodash.sortby": "^4.7.0", "lodash.uniqwith": "^4.5.0", + "perfect-scrollbar": "^1.5.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", + "react-i18next": "^11.18.0", "react-number-format": "^4.9.3", "reactstrap": "^9.1.1", "sql-formatter": "^7.0.3", diff --git a/public/config.json b/public/config.json index 03e924b..a7d10d8 100644 --- a/public/config.json +++ b/public/config.json @@ -113,5 +113,46 @@ "useAnd": true } } + }, + "sherlock": { + "placeHolder": "trans:searchPlaceholder", + "serviceUrl": "https://gis.wfrc.org/arcgis/rest/services/General/ZoomToPlaceNames/FeatureServer/1", + "searchField": "NAME" + }, + "openOnLoad": { + "projectInfo": true, + "filter": true + }, + "translations": { + "en": { + "translation": { + "searchPlaceholder": "Search...", + "filter": "Filter", + "reset": "reset", + "filterByPhasing": "filter by phasing", + "transFilter": { + "transportation": "Transportation", + "roads": "Roads" + }, + "projectInformation": "Project Information", + "projectInformationPrompt": "Click on a feature for more information", + "transportation": "Transportation" + } + }, + "es": { + "translation": { + "searchPlaceholder": "Buscar...", + "filter": "Filtrar", + "reset": "reiniciar", + "filterByPhasing": "filtrar por fases", + "transFilter": { + "transportation": "Transporte", + "roads": "Carreteras" + }, + "projectInformation": "Información del Proyecto", + "projectInformationPrompt": "Haga clic en una función para obtener más información", + "transportation": "Transporte" + } + } } } diff --git a/public/config.schema.json b/public/config.schema.json index 3f9a5a6..ec59a6b 100644 --- a/public/config.schema.json +++ b/public/config.schema.json @@ -29,11 +29,35 @@ "type": "boolean" } } + }, + "translation": { + "title": "Translation", + "type": "object", + "properties": { + "mapTabsDialog": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "required": ["title", "availableHeader", "selectedHeader", "maxMessage"] + } + }, + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } } }, "title": "WFRC RTP Projects", "type": "object", "additionalProperties": false, + "required": ["infoText", "projectTypes", "sherlock", "openOnLoad", "translations"], "properties": { "infoText": { "title": "Info Popup Content", @@ -93,6 +117,78 @@ } } } + }, + "sherlock": { + "description": "Configuration options for the map search widget", + "type": "object", + "additionalProperties": false, + "properties": { + "serviceUrl": { + "description": "The URL to the service that you would like to search features on.", + "type": "string" + }, + "searchField": { + "description": "The name of the field that you would like the search to be applied to.", + "type": "string" + }, + "placeHolder": { + "description": "The place holder text that shows up in the text box before a user starts typing.", + "type": "string" + } + }, + "required": ["serviceUrl", "searchField"] + }, + "openOnLoad": { + "description": "Controls whether specific map widgets default to be open on page load", + "type": "object", + "additionalProperties": false, + "properties": { + "projectInfo": { + "type": "boolean" + }, + "filter": { + "type": "boolean" + } + } + }, + "translations": { + "description": "Contains the translated strings used in the app. Falls back to `en` if there is no other translation. Most strings in the other configs can be translated by using this format: `trans:`. For example: `trans:visionMapTitle`.", + "title": "Translations", + "type": "object", + "properties": { + "en": { + "type": "object", + "properties": { + "translation": { + "$ref": "#/definitions/translation", + "required": [ + "appTitle", + "tagLine", + "mapTabsDialog", + "searchPlaceholder", + "filter", + "filterByPhasing", + "reset", + "projectInformation", + "projectInformationPrompt" + ] + } + }, + "required": ["translation"], + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "translation": { + "$ref": "#/definitions/translation" + } + }, + "required": ["translation"], + "additionalProperties": false + }, + "required": ["en"] } } } diff --git a/src/App.jsx b/src/App.jsx index 928c647..1166771 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,23 +1,31 @@ import * as reactiveUtils from '@arcgis/core/core/reactiveUtils'; +import Graphic from '@arcgis/core/Graphic'; import MapView from '@arcgis/core/views/MapView'; import WebMap from '@arcgis/core/WebMap'; import Home from '@arcgis/core/widgets/Home'; +import { faFilter, faHandPointer } from '@fortawesome/free-solid-svg-icons'; import React from 'react'; import 'typeface-montserrat'; import './App.scss'; import Filter from './components/Filter'; +import MapWidget from './components/MapWidget'; +import ProjectInformation from './components/ProjectInformation'; import { MapServiceProvider, Sherlock } from './components/Sherlock'; +import { useSpecialTranslation } from './i18n'; import config from './services/config'; function App() { const [mapView, setMapView] = React.useState(null); const [zoomToGraphic, setZoomToGraphic] = React.useState(null); + const t = useSpecialTranslation(); + React.useEffect(() => { const map = new WebMap({ portalItem: { id: '64597762025546ca993bea496f51d302' }, }); const view = new MapView({ map, container: 'mapDiv' }); + view.popup = null; view.ui.add(new Home({ view }), 'top-left'); setMapView(view); @@ -84,17 +92,78 @@ function App() { const sherlockConfig = { provider: new MapServiceProvider(config.sherlock.serviceUrl, config.sherlock.searchField), - placeHolder: 'Search...', + placeHolder: t(config.sherlock.placeHolder), onSherlockMatch, }; + // required for ProjectInformation + const [selectedGraphics, setSelectedGraphics] = React.useState([]); + const [highlight, setHighlight] = React.useState(null); + const [graphic, setGraphic] = React.useState(null); + const highlightGraphic = async (newGraphic) => { + console.log('App:highlightGraphic', newGraphic); + + if (highlight) { + highlight.remove(); + setHighlight(null); + } + + if (graphic) { + mapView.graphics.remove(graphic); + } + + if (newGraphic) { + try { + const layerView = await mapView.whenLayerView(newGraphic.layer); + setHighlight(layerView.highlight(newGraphic)); + } catch { + const symbolizedGraphic = new Graphic({ + ...newGraphic, + symbol: config.SELECTION_SYMBOLS[newGraphic.geometry.type], + }); + + mapView.graphics.add(symbolizedGraphic); + setGraphic(symbolizedGraphic); + } + } else { + setGraphic(null); + } + }; + + React.useEffect(() => { + if (mapView) { + mapView.on('click', async (event) => { + const response = await mapView.hitTest(event); + setSelectedGraphics(response.results.map((result) => result.graphic)); + }); + } + }, [mapView]); + return (

RTP Projects

- + + + + + +
diff --git a/src/components/Details.jsx b/src/components/Details.jsx new file mode 100644 index 0000000..b3932a5 --- /dev/null +++ b/src/components/Details.jsx @@ -0,0 +1,60 @@ +import * as watchUtils from '@arcgis/core/core/watchUtils'; +import Feature from '@arcgis/core/widgets/Feature'; +import PropTypes from 'prop-types'; +import { useEffect, useRef, useState } from 'react'; +import { Collapse } from 'reactstrap'; +import './Details.scss'; + +export default function Details({ graphic, highlightGraphic }) { + const [collapsed, setCollapsed] = useState(true); + const containerRef = useRef(); + const [title, setTitle] = useState(); + + const toggle = () => setCollapsed(!collapsed); + + useEffect(() => { + let feature; + const buildContent = async () => { + feature = new Feature({ + container: document.createElement('div'), + graphic, + visibleElements: { + title: false, + }, + defaultPopupTemplateEnabled: true, + }); + + await watchUtils.once(feature, 'title'); + + setTitle(feature.title); + + containerRef.current.appendChild(feature.container); + }; + + if (graphic) { + buildContent(); + } + + return () => { + if (feature) { + feature.destroy(); + console.log('destroyed'); + } + }; + }, [graphic]); + + return ( +
highlightGraphic(graphic)} onMouseLeave={() => highlightGraphic()}> +
+ {title} +
+ +
+
+
+ ); +} +Details.propTypes = { + graphic: PropTypes.object, + highlightGraphic: PropTypes.object, +}; diff --git a/src/components/Details.scss b/src/components/Details.scss new file mode 100644 index 0000000..b25bb86 --- /dev/null +++ b/src/components/Details.scss @@ -0,0 +1,26 @@ +$border: solid 1px gray; +$padding: 14px; + +.details { + &:first-child { + border-top: $border; + } + font-size: 14px; + border-bottom: $border; + .title { + color: var(--bs-heading-color); + cursor: pointer; + line-height: 1.6; + font-weight: bold; + font-size: 14px; + background-color: #f0f0f0; + padding: calc($padding/2) $padding; + border-bottom: $border; + &:hover { + background-color: white; + } + } + .esri-feature__content-element { + padding: 0; + } +} diff --git a/src/components/Details.stories.jsx b/src/components/Details.stories.jsx new file mode 100644 index 0000000..067d0d7 --- /dev/null +++ b/src/components/Details.stories.jsx @@ -0,0 +1,33 @@ +import Details from './Details'; + +export default { + title: 'Details', + component: Details, +}; + +const feature = { + attributes: { + fieldOne: 'display field value', + fieldTwo: 'hello', + fieldThree: 'world', + }, + layer: { + fields: [ + { + name: 'fieldOne', + alias: 'Field One', + }, + { + name: 'fieldTwo', + alias: 'Field Two', + }, + { + name: 'fieldThree', + alias: 'Field Three', + }, + ], + displayField: 'fieldOne', + }, +}; + +export const Default = () =>
; diff --git a/src/components/Filter.jsx b/src/components/Filter.jsx index a8ea5a5..b677671 100644 --- a/src/components/Filter.jsx +++ b/src/components/Filter.jsx @@ -1,11 +1,8 @@ import FeatureFilter from '@arcgis/core/layers/support/FeatureFilter'; -import { faList } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import clsx from 'clsx'; import PropTypes from 'prop-types'; import React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { Alert, Button, Card, CardBody, CardHeader, Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; +import { Alert, Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; import { format } from 'sql-formatter'; import { useImmerReducer } from 'use-immer'; import config from '../services/config'; @@ -16,8 +13,6 @@ import SimpleControls from './SimpleControls'; import { addOrRemove, getLabelColor, useMapLayers } from './utils'; export function getQuery(state, geometryType, projectConfig) { - // TODO: write some tests for this function - // phase is a numeric field const phaseQuery = `${state.phaseField} IN (${state.phase.join(',')})`; // mode is a text field @@ -176,22 +171,12 @@ ErrorFallback.propTypes = { }; export default function Filter({ mapView }) { - const [isOpen, setIsOpen] = React.useState(true); - const buttonDiv = React.useRef(null); const [state, dispatch] = useImmerReducer(reducer, initialState); - const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(true); + const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(false); const toggleAdvanced = () => setIsAdvancedOpen((current) => !current); const layers = useMapLayers(mapView, config.layerNames); - const toggle = () => setIsOpen((current) => !current); - - React.useEffect(() => { - if (mapView && buttonDiv.current) { - mapView.ui.add(buttonDiv.current, 'top-left'); - } - }, [mapView, buttonDiv]); - // toggle layers React.useEffect(() => { if (layers) { @@ -225,142 +210,120 @@ export default function Filter({ mapView }) { return ( <> -
- -
- - - Filter -
- -
-
- - -
Display RTP Projects by
-
- - -
- - - - - - - - - - -
-
-
+ +
Display RTP Projects by
+
+ + +
+ + + + + + + + + + +
); } diff --git a/src/components/Filter.scss b/src/components/Filter.scss index 9b7ac28..e69de29 100644 --- a/src/components/Filter.scss +++ b/src/components/Filter.scss @@ -1,8 +0,0 @@ -.filter-card { - width: 500px; - .tab-content { - max-height: calc(100vh - 266px); - overflow-y: auto; - overflow-x: hidden; - } -} diff --git a/src/components/MapWidget.jsx b/src/components/MapWidget.jsx new file mode 100644 index 0000000..27dfeb6 --- /dev/null +++ b/src/components/MapWidget.jsx @@ -0,0 +1,90 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import PerfectScrollbar from 'perfect-scrollbar'; +import 'perfect-scrollbar/css/perfect-scrollbar.css'; +import PropTypes from 'prop-types'; +import React, { createContext, useEffect, useRef, useState } from 'react'; +import { Button, Card, CardHeader } from 'reactstrap'; +import { useSpecialTranslation } from '../i18n'; +import './MapWidget.scss'; + +export const MapWidgetContext = createContext(); + +export default function MapWidget({ defaultOpen, position, mapView, children, icon, showReset, onReset, name }) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const scrollBar = useRef(); + const scrollBarContainer = useRef(); + const t = useSpecialTranslation(); + + const toggle = () => { + setIsOpen(!isOpen); + }; + const padding = '15px'; + const cardStyle = { + display: isOpen ? 'flex' : 'none', + top: position === 0 ? padding : `calc(50% - ${padding})`, + bottom: position === 0 ? `calc(50% + 2 * ${padding})` : padding, + }; + const buttonDiv = useRef(); + useEffect(() => { + if (mapView && buttonDiv.current) { + mapView.ui.add(buttonDiv.current, 'top-left'); + } + + const buttonDivRef = buttonDiv.current; + + return () => { + mapView && mapView.ui.remove(buttonDivRef); + }; + }, [buttonDiv, mapView]); + + const updateScrollbar = React.useCallback(() => scrollBar.current?.update(), []); + + React.useEffect(() => { + if (scrollBarContainer.current) { + scrollBar.current = new PerfectScrollbar(scrollBarContainer.current, { suppressScrollX: true }); + } + + return () => { + if (scrollBar.current) { + scrollBar.current.destroy(); + } + }; + }, []); + + return ( +
+ +
+ +
+ + + {name} +
+ {showReset && ( + + )} +
+
+
+ {children} +
+
+
+
+ ); +} + +MapWidget.propTypes = { + defaultOpen: PropTypes.bool, + position: PropTypes.number, + mapView: PropTypes.object, + name: PropTypes.string, + icon: PropTypes.object, + children: PropTypes.node, + showReset: PropTypes.bool, + onReset: PropTypes.func, +}; diff --git a/src/components/MapWidget.scss b/src/components/MapWidget.scss new file mode 100644 index 0000000..afa2aae --- /dev/null +++ b/src/components/MapWidget.scss @@ -0,0 +1,42 @@ +.map-widget-button { + button { + padding: 4px 0; + width: 33px; + border: none; + &:hover { + background: none; + } + } +} + +.map-widget-card { + position: absolute; + right: 15px; + z-index: 5; + width: 500px; + @media only screen and (max-width: 500px) { + width: 100%; + right: 0; + height: 65%; + top: 0; + } + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + .buttons-container { + display: flex; + justify-content: flex-end; + align-items: center; + .reset-button { + padding: 0 10px; + } + } + } + + .card-body { + position: relative; + height: 100%; + width: 100%; + } +} diff --git a/src/components/MapWidget.stories.jsx b/src/components/MapWidget.stories.jsx new file mode 100644 index 0000000..bded835 --- /dev/null +++ b/src/components/MapWidget.stories.jsx @@ -0,0 +1,19 @@ +import { faHandPointer, faList } from '@fortawesome/free-solid-svg-icons'; +import MapWidget from './MapWidget'; + +export default { + title: 'MapWidget', + component: MapWidget, +}; + +export const Filter = () => ( + + child widget content + +); + +export const ProjectInformation = () => ( + + child widget content + +); diff --git a/src/components/ProjectInformation.jsx b/src/components/ProjectInformation.jsx new file mode 100644 index 0000000..875f14e --- /dev/null +++ b/src/components/ProjectInformation.jsx @@ -0,0 +1,49 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Spinner } from 'reactstrap'; +import { useSpecialTranslation } from '../i18n'; +import Details from './Details'; +import { MapWidgetContext } from './MapWidget'; +import './ProjectInformation.scss'; + +export default function ProjectInformation({ graphics, showLoader, highlightGraphic }) { + const { updateScrollbar } = React.useContext(MapWidgetContext); + + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (updateScrollbar) { + console.log('updating scrollbar'); + updateScrollbar(); + } + + // this if statement helps with tests + if (typeof containerRef.current.scrollIntoView === 'function' && graphics.length > 0) { + console.log('scrolling into view'); + containerRef.current.scrollIntoView(); + } + }, [graphics, updateScrollbar]); + + const t = useSpecialTranslation(); + const spinnerSize = '5rem'; + + return ( +
+ {graphics.length === 0 && !showLoader &&

{t('trans:projectInformationPrompt')}

} + {showLoader && ( +
+ +
+ )} + {graphics.map((graphic, index) => ( +
+ ))} +
+ ); +} + +ProjectInformation.propTypes = { + graphics: PropTypes.array, + showLoader: PropTypes.bool, + highlightGraphic: PropTypes.func, +}; diff --git a/src/components/ProjectInformation.scss b/src/components/ProjectInformation.scss new file mode 100644 index 0000000..76748ee --- /dev/null +++ b/src/components/ProjectInformation.scss @@ -0,0 +1,14 @@ +.project-information { + height: 100%; + margin: -1.25rem; + p { + margin: 1.25rem; + } + .loader { + margin-top: 1.25rem; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..53e12a7 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next'; + +export const generateTranslatorFunction = (translator) => { + // wrap i18next translate with our own function that searches + // for the "trans:" prefix + // this is so that we can refer to translation keys from other parts of the config file + return (value) => { + if (value.startsWith('trans:')) { + return translator(value.split(':')[1]); + } + + return value; + }; +}; + +export const useSpecialTranslation = () => { + const { t } = useTranslation(); + + return generateTranslatorFunction(t); +}; diff --git a/src/i18n.test.js b/src/i18n.test.js new file mode 100644 index 0000000..0349afe --- /dev/null +++ b/src/i18n.test.js @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { generateTranslatorFunction } from './i18n'; + +describe('translate', () => { + const mockedTranslatedValue = 'hello'; + const t = generateTranslatorFunction(() => mockedTranslatedValue); + + it('returns the input if no special prefix', () => { + expect(t('test')).toEqual('test'); + }); + + it('returns translated value if special prefix is present', () => { + expect(t('trans:test')).toEqual(mockedTranslatedValue); + }); +}); diff --git a/src/services/config.js b/src/services/config.js index bf13a9d..c2f8de6 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -1,10 +1,9 @@ +import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; import { Validator } from 'jsonschema'; +import { initReactI18next } from 'react-i18next'; const config = { - sherlock: { - serviceUrl: 'https://gis.wfrc.org/arcgis/rest/services/General/ZoomToPlaceNames/FeatureServer/1', - searchField: 'NAME', - }, layerNames: { modePoints: 'Points Displayed by Mode', modeLines: 'Lines Displayed by Mode', @@ -46,7 +45,7 @@ const config = { outFields: ['OBJECTID', 'phase', 'mode', 'improvement_type', 'plan_id', 'breakout', 'bike_type_text'], }; -// optional configSchema is for jest and storybook since they are clumsy when it comes to +// optional configSchema is for vitest and storybook since they are clumsy when it comes to // async setup export const setConfigs = async (appConfigs, configSchema = null) => { // we are fetching this rather than importing it so that it can be hosted publicly and available @@ -66,23 +65,24 @@ export const setConfigs = async (appConfigs, configSchema = null) => { console.error('There is an error in config.json!', error.stack); } - // i18n - // .use(initReactI18next) - // .use(LanguageDetector) - // .init({ - // detection: { - // order: ['navigator'], // only look at the navigator object to determine locale - // caches: [], // disable locale caching - // }, - // resources: appConfigs.translations, - // interpolation: { - // escapeValue: false, - // }, - // fallbackLng: 'en', - // }); + i18n + .use(initReactI18next) + .use(LanguageDetector) + .init({ + detection: { + order: ['navigator'], // only look at the navigator object to determine locale + caches: [], // disable locale caching + }, + resources: appConfigs.translations, + interpolation: { + escapeValue: false, + }, + fallbackLng: 'en', + }); // apply quad word from env Object.assign(config, appConfigs); + console.log('configs set'); }; export default config;