From e0a5acf2f8f23b9684ea9df8e30a211ae9d576dc Mon Sep 17 00:00:00 2001 From: stdavis Date: Thu, 1 Jun 2023 16:40:21 -0600 Subject: [PATCH] fix: switch to createReplica calls from client for downloads per layer --- functions/common/config.js | 5 +- functions/generateZip.js | 109 --------------- functions/index.js | 7 +- package-lock.json | 12 ++ package.json | 1 + src/App.jsx | 42 ++---- src/SearchMachineProvider.jsx | 91 ++++++++---- src/components/Map.jsx | 16 ++- src/components/search-wizard/Download.jsx | 116 ++++++--------- .../search-wizard/Download.stories.jsx | 65 ++++----- .../search-wizard/DownloadProgress.jsx | 74 ++++++++++ .../DownloadProgress.stories.jsx | 58 ++++++++ .../search-wizard/ResultStatusIcons.jsx | 45 ++++++ .../{Progress.jsx => SearchProgress.jsx} | 40 +----- ...stories.jsx => SearchProgress.stories.jsx} | 8 +- src/components/search-wizard/Wizard.jsx | 132 +++++++++++++++--- src/remote_config_defaults.json | 2 +- tests/fixtures/queryLayerResult.json | 3 +- 18 files changed, 486 insertions(+), 340 deletions(-) delete mode 100644 functions/generateZip.js create mode 100644 src/components/search-wizard/DownloadProgress.jsx create mode 100644 src/components/search-wizard/DownloadProgress.stories.jsx create mode 100644 src/components/search-wizard/ResultStatusIcons.jsx rename src/components/search-wizard/{Progress.jsx => SearchProgress.jsx} (51%) rename src/components/search-wizard/{Progress.stories.jsx => SearchProgress.stories.jsx} (77%) diff --git a/functions/common/config.js b/functions/common/config.js index 613193a3..4c37f860 100644 --- a/functions/common/config.js +++ b/functions/common/config.js @@ -176,8 +176,9 @@ export const schemas = { }; export const downloadFormats = { - shapefile: 'shapefile', - geojson: 'geojson', csv: 'csv', + excel: 'excel', filegdb: 'filegdb', + geojson: 'geojson', + shapefile: 'shapefile', }; diff --git a/functions/generateZip.js b/functions/generateZip.js deleted file mode 100644 index 13590f8f..00000000 --- a/functions/generateZip.js +++ /dev/null @@ -1,109 +0,0 @@ -import { getStorage } from 'firebase-admin/storage'; -import { - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from 'fs'; -import gdal from 'gdal-async'; -import got from 'got'; -import JSZip from 'jszip'; -import { downloadFormats } from './common/config.js'; - -async function downloadLayer(layer, folder) { - console.log(`querying ${layer.name}...`); - // execute query against feature service - const geojson = await got - .post(`${layer.featureService}/query`, { - form: { - objectIds: layer.objectIds.join(','), - f: 'geojson', - returnGeometry: true, - outFields: '*', - }, - }) - .json(); - - // TODO: handle pagination using exceededTransferLimit response prop - - // write geojson to temp file - const filePath = `${folder}/${layer.name - .split(' ') - .join('')}_${Date.now()}.geojson`; - writeFileSync(filePath, JSON.stringify(geojson)); - - return filePath; -} - -function convert(geojsonPath, format) { - console.log(`converting ${geojsonPath} to ${format}...`); - const dataset = gdal.open(geojsonPath); - let driver; - let fileExtension; - switch (format) { - case downloadFormats.shapefile: - driver = gdal.drivers.get('ESRI Shapefile'); - fileExtension = 'shp'; - break; - - case downloadFormats.csv: - driver = gdal.drivers.get('CSV'); - fileExtension = 'csv'; - break; - - case downloadFormats.filegdb: - // this is not working ref: https://github.com/mmomtchev/node-gdal-async/issues/84 - driver = gdal.drivers.get('OpenFileGDB'); - fileExtension = 'gdb'; - break; - - default: - throw new Error(`Unknown format: ${format}`); - } - - const outputPath = geojsonPath.replace('.geojson', `.${fileExtension}`); - driver.createCopy(outputPath, dataset).close(); - rmSync(geojsonPath); -} - -export default async function generateZip({ layers, format }) { - const folder = `/tmp/${Date.now()}`; - await mkdirSync(folder); - - let zipFile; - try { - // download layers as geojson concurrently - const geojsonPaths = await Promise.all( - layers.map((layer) => downloadLayer(layer, folder)) - ); - - if (format !== downloadFormats.geojson) { - for (const path of geojsonPaths) { - convert(path, format); - } - } - - // zip all contents in folder - console.log(`zipping ${folder}...`); - const zip = new JSZip(); - for (const file of readdirSync(folder)) { - console.log('file', file); - zip.file(file, readFileSync(`${folder}/${file}`)); - } - - // upload to storage - const bucket = getStorage().bucket(); - zipFile = bucket.file(`${Date.now()}/${format}.zip`); - zip.generateNodeStream({ type: 'nodebuffer', streamFiles: true }).pipe( - zipFile.createWriteStream({ - resumable: false, - }) - ); - } finally { - // delete temp files even if an error was thrown - rmSync(folder, { recursive: true }); - } - - return zipFile.publicUrl(); -} diff --git a/functions/index.js b/functions/index.js index 7806cb68..69b536ee 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,6 +1,5 @@ import admin from 'firebase-admin'; -import { onCall, onRequest } from 'firebase-functions/v2/https'; -import generateZip from './generateZip.js'; +import { onRequest } from 'firebase-functions/v2/https'; import update from './update.js'; admin.initializeApp(); @@ -18,7 +17,3 @@ export const configs = onRequest(commonConfigs, async (_, response) => { response.status(500).send({ error: e.toString() }); } }); - -export const generate = onCall(commonConfigs, async (request) => { - return await generateZip(request.data); -}); diff --git a/package-lock.json b/package-lock.json index 4dee0f08..817dfdc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "clsx": "^1.2.1", "firebase-admin": "^11.8.0", "googleapis": "^118.0.0", + "ky": "^0.33.3", "localforage": "^1.10.0", "react": "^18.2.0", "react-content-loader": "^6.2.1", @@ -25363,6 +25364,17 @@ "node": ">= 8" } }, + "node_modules/ky": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/ky/-/ky-0.33.3.tgz", + "integrity": "sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", diff --git a/package.json b/package.json index 97b05f97..d78512a0 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "clsx": "^1.2.1", "firebase-admin": "^11.8.0", "googleapis": "^118.0.0", + "ky": "^0.33.3", "localforage": "^1.10.0", "react": "^18.2.0", "react-content-loader": "^6.2.1", diff --git a/src/App.jsx b/src/App.jsx index 2adf4f35..85683ef4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,12 +1,7 @@ import { setUtahHeaderSettings } from '@utahdts/utah-design-system-header'; import { getAnalytics } from 'firebase/analytics'; -import { connectFunctionsEmulator, getFunctions } from 'firebase/functions'; import { useEffect } from 'react'; -import { - AnalyticsProvider, - FunctionsProvider, - useFirebaseApp, -} from 'reactfire'; +import { AnalyticsProvider, useFirebaseApp } from 'reactfire'; import RemoteConfigProvider from './RemoteConfigProvider.jsx'; import { SearchMachineProvider } from './SearchMachineProvider.jsx'; import MapComponent from './components/Map.jsx'; @@ -54,29 +49,22 @@ function App() { const app = useFirebaseApp(); - if (import.meta.env.DEV) { - console.log('connecting to functions emulator'); - connectFunctionsEmulator(getFunctions(), 'localhost', 5001); - } - return ( - - - -
-
- - -
-
- - - -
+ + +
+
+ + +
+
+ + +
- - - +
+
+
); } diff --git a/src/SearchMachineProvider.jsx b/src/SearchMachineProvider.jsx index 736ee525..6d7c4336 100644 --- a/src/SearchMachineProvider.jsx +++ b/src/SearchMachineProvider.jsx @@ -3,7 +3,7 @@ import { getItem, setItem } from 'localforage'; import PropTypes from 'prop-types'; import { createContext, useContext } from 'react'; import { assign, createMachine } from 'xstate'; -import { fieldNames } from '../functions/common/config'; +import { downloadFormats, fieldNames } from '../functions/common/config'; const CACHE_KEY = 'searchContext'; function cacheSearchContext(cachedContext) { @@ -28,12 +28,15 @@ const blankContext = { */ resultLayers: null, resultExtent: null, + selectedDownloadLayers: [], + downloadResultLayers: [], + downloadFormat: downloadFormats.shapefile, error: null, }; const machine = createMachine( { - /** @xstate-layout N4IgpgJg5mDOIC5SzAQwE4GMAWA6AjgK5joCeAMqqSbAMQCCAIgGr0ByAwgKKMDaADAF1EoAA4B7WAEsALlPEA7ESAAeiACwBOTbgCsAdgAcARnX8ATLsNHN-AGwAaEKUQBmI7mu6Avt6coMHAJiMkpqdDoAZS56ACUOAAkBYSQQCWk5RWU1BC0dAxMzS2tDW0dnDTtjXHM8w3V9dWNzY35NdV9-NCw8VAgAN1QFTEhaaLjE5OV02XklVJzC3FddTVdbYptjJxcEVzM9TpAAntw+weHRgEUAVS5YgE0AfXJ6B-vIqdSZzPnQHLyeiMpgsVhs9h2bnMhmW+h8fmO3SCJxwUgUUFoEEUYFwaP64gA1jiUXgSWioAg8eJMKhfskvmJJLMsgtEEsVmsNmDStsKggTIcESTcGT0bQSOhxOhcKIADa0gBmUoAtiKkaT1eTKQp8TS6UIGWkmb9sohjMZXDD9Jo7IZzJpzOZXHZXNDIXt1OpBV1Anh0HBCLKZHQOOQYrFDT85qaEMY7PwgYVQSUyu73NVNHCjsL-bBA8GGCx2Nw+EJpsbo6zY-HEyDNqUIXyrOZcPw2-xjLp65pjNn1bhc-m6Ld7s9Xu9Yp8y98Kyz-maawU69zU3zXdUGvCfacJVLaKHw5HZ39VGbjIZdLh1C07Q6nS63XzjNoavCEQpxBA4MoSeWMpX5wQABacpdhAvtfWCEgKCoGg-2ZE8cn2BM2ncOwuxXRtdldHRHS3RFIPOIYRggeCTSrW0dFQ-R0O7XlsKaCDTlFKAyIA08EEzL19HPfR+Gve9XUMNNzH4JigkHIN4Bnf85w468RJbO18OFXd0DYuScmaTNPGadRdAE50hPdUT9E8VwLMtJpNFWF19F8XwgA */ + /** @xstate-layout N4IgpgJg5mDOIC5SzAQwE4GMAWA6AlgHb4Au+qANvgF5gDEEA9oWAYQG6MDWrKGObUuSq0ERTplRlmAbQAMAXXkLEoAA6NYQ5qpAAPRAE4AbIdwB2AEzHj5gKwBmcw8NyALAEYANCACeiSwcHXDlQuQ8guwAOQ0MPOTsAX0SfPiw8FAowTBIAGVRfMHRYOgBBABEANVKAOQBhAFFy5V0NLWlCXQMEeLlLXBcouzs3BztXY3Gffx6HfrCEhIjXUKjk1LR03EzsvIKikoBlBtKAJTqACRakEDbtTpvuiPNjXA9LOzko81i3UPfpogoh43LghoY-kEPsC5OZ1iA0gIdjl8oVinRjrkGnUACoAfVypQAmg1Ttd1Jp7l1EB4PIY7LhLIYHLCQW5PpYPOZAQhzAkwZYmbSolE3JY3MYHPDERkwFkUft0QBVGqY7H4wkksmKVqUjrUnoeUWMhxuNwxBySxw84xyMyWwxWcwvcxRSVSlIIzZIuW7VEHOh1LFncm3PX4HSPGmC17mqION3GGL0o08uzvXBxSau8LMs1rT0y3CoCDsVCETCQDEnc5XHU3O76qMIExmKw2exOFzubx+IEeXCfUKmqym1yC6XevAlssVqsARSVpKJBOJpMOocbEYeoG6rYs1lsjmcrk8PKN-SHcgcdMtZuGcMLU+2U6IUDopwahyVuRxm-Dka7jS4zBBCIxxF8UR9HY543gOhjxuBdKWK6HiTvwsoYW+dCkqcADy2oqA2AE7voNJ-FEFhDjEnKclEgTnqM5gWJMxhuAhzp2JYMToVsMrYXUeEALIAApYjiDT-u024Gs8rzvJ83y-P8ljnrCryISmTqeAkvE+lhhDvnUtSNLkUlUs2yzMeE0ShOB4wQjyLygmMEKcq4cyTJYel4OgcAAK4UCQgbBoRurSYBZEIMMciDiMfLGBEoyxFEPJOKCHyhGKwweFxZo+bgfmwIFwUVNU9RNOZTZAdFnxxW4CVJS4CGwfBsQIVyqHmBEHobBhhUBUFdCLsuq5ahu9YUhFpHdDF9WNaazWpX2CANbFfI3nYPxzFybqPn1WxFSV1ZnJcVUyc2c3DGtiWLSlPL0q8XzjIl4S3a6BVHUN5R4QA6jUuR4RU52RbNdXXQtyUtSt9j9EaXzmgmoyJW4BVMAA7oQFCMCWIU1iDM1GKYB4dse3ZnjDMQDPR8aShK9jGN5T79RjWM4xAdAAEKlHUADSBMGozDKOK4Jjxm6nhuDyUH9EmXxMlBpismjjCY9juM-f9gMVAAkjUADiAuXemjLWOMrr0fFDjSwOV7DsMQuiiravs9W+JqriTRjeuRs1c4DJyELIzsUahg2t8maB9YfzivEW3O2zuPHPiABiBFCaUf6TWG00Gs6LmWrmgcmFyMEreMA7sje5psaa6ao8zWys+rEDYZ+36-r7UXE1yRq2olEHDKpK0IcE4J19eSb0r1Xos6riet4ZOGnPhYXEbnzaCrF0-vA1MRbe8xjnkyrwi3adFWOE+2z0388twJwliQ0Eld90W+Zo4u+uvS3XWE5pqMncNxEECQWTdQTvfJexkKpmWzluUGARLDb0-uKb+B8-4rVpPEMECkbKCkcNxCB7NsLcz5q-RByCbyoP3r-I+mCPCJQGApLkVC3INwOgIIo6BGDoDxiGOBJEDT7nbEeLsp5ewzHeEgwcQs7SeGdE4YwBUuE8JOrWchLZiYiM7CeHsrVrK5TYnSeicwG6ekIIwCAcBdAynChZGqABaCRiAHFPQWO4sIhgmYcLwEQIQlAaBgDsdVKKLowRfCTHlH4cQpYrUCJXF4W07TOiQZ4bxN8fTyj2GieA697FRXeIHAU9FcrpnsI6MuMwoLMRQraFC0cVhKMbgIGc5ZKwQGCRdP2CE3j7wYQ1B2ppYKXjCJ8dM3ZVgFX4oZTpCCegHwsM4W63F2LfGtpgzwwRka2BvJMP4ERPqDRILMwmCBLSvFtLlYEppzTcTsHQmYzhQTw3omad08Qmk+NwM3dmJy85WEzPDLijhgS0liY8qmrknAQncOU6+RYfkljfH85sDU1JDCYeKW08jwKfIyXgFR6AUU1VeZmEuXi4iKOhpIuYsUbJGIQoEcUyRkhAA */ id: 'search', initial: 'initialize', predictableActionArguments: true, @@ -91,6 +94,9 @@ const machine = createMachine( resultLayers: [], resultExtent: null, error: null, + downloadResultLayers: [], + downloadFormat: downloadFormats.shapefile, + selectedDownloadLayers: [], }), on: { RESULT: { @@ -120,6 +126,13 @@ const machine = createMachine( }, }, result: { + entry: assign({ + // set relevant layers as selected by default for download + selectedDownloadLayers: (context) => + context.resultLayers + .filter((result) => result.supportedExportFormats) + .map((result) => result[fieldNames.queryLayers.uniqueId]), + }), on: { CLEAR: { target: 'selectLayers', @@ -140,6 +153,9 @@ const machine = createMachine( }, }, download: { + entry: assign({ + downloadResultLayers: [], + }), on: { CLEAR: { target: 'selectLayers', @@ -148,33 +164,52 @@ const machine = createMachine( BACK: { target: 'result', }, + DOWNLOADING: { + target: 'downloading', + }, + SET_SELECTED_LAYERS: { + actions: assign({ + selectedDownloadLayers: (_, event) => event.selectedLayers, + }), + }, + SET_FORMAT: { + actions: assign({ + downloadFormat: (_, event) => event.format, + }), + }, }, - initial: 'idle', - states: { - idle: { - on: { - GENERATE: { - target: 'busy', - }, - }, - }, - busy: { - on: { - DONE: { - target: 'result', - }, - ERROR: { - target: 'error', - }, - }, - }, - result: {}, - error: { - on: { - BACK: { - target: 'result', - }, - }, + }, + downloading: { + entry: assign({ + error: null, + }), + on: { + RESULT: { + actions: assign({ + downloadResultLayers: (context, event) => [ + ...context.downloadResultLayers, + event.result, + ], + }), + }, + // generic error with download (not specific to a query layer) + ERROR: { + target: 'error', + actions: assign({ + error: (_, event) => event.message, + }), + }, + COMPLETE: { + target: 'result', + actions: assign({ + resultExtent: (_, event) => event.extent, + }), + }, + CANCEL: { + target: 'download', + }, + BACK: { + target: 'download', }, }, }, diff --git a/src/components/Map.jsx b/src/components/Map.jsx index dd99b2f4..ffe59ae9 100644 --- a/src/components/Map.jsx +++ b/src/components/Map.jsx @@ -76,8 +76,22 @@ export default function MapComponent() { await whenOnce(() => layerView.updating === false); const { count, extent } = await layerView.queryExtent(); const featureSet = await layerView.queryFeatures(); + + if (!featureLayer.sourceJSON.supportedExportFormats) { + console.warn( + 'Layer does not support the createReplica endpoint', + layer + ); + } + send('RESULT', { - result: { ...layer, features: featureSet.features, count }, + result: { + ...layer, + features: featureSet.features, + count, + supportedExportFormats: + featureLayer.sourceJSON.supportedExportFormats, + }, }); return extent; diff --git a/src/components/search-wizard/Download.jsx b/src/components/search-wizard/Download.jsx index 60931619..00309aca 100644 --- a/src/components/search-wizard/Download.jsx +++ b/src/components/search-wizard/Download.jsx @@ -1,20 +1,18 @@ import PropTypes from 'prop-types'; -import { useState } from 'react'; import { downloadFormats, fieldNames } from '../../../functions/common/config'; -import Button from '../../utah-design-system/Button'; import Checkbox from '../../utah-design-system/Checkbox'; -import Icon from '../../utah-design-system/Icon'; import RadioGroup from '../../utah-design-system/RadioGroup'; -export default function Download({ searchResultLayers, mutation }) { +export default function Download({ + searchResultLayers, + selectedLayers, + setSelectedLayers, + format, + setFormat, +}) { const relevantResultLayers = searchResultLayers.filter( (result) => !result.error && result.features.length > 0 ); - const [selectedLayers, setSelectedLayers] = useState( - relevantResultLayers.map( - (result) => result[fieldNames.queryLayers.uniqueId] - ) - ); const getOnChangeHandler = (uniqueId) => (checked) => { if (checked) { @@ -24,41 +22,53 @@ export default function Download({ searchResultLayers, mutation }) { } }; - const [format, setFormat] = useState('shapefile'); const formats = [ { - label: 'Shapefile', - value: downloadFormats.shapefile, + label: 'CSV', + value: downloadFormats.csv, + }, + { + label: 'Excel', + value: downloadFormats.excel, + }, + { + label: 'File Geodatabase', + value: downloadFormats.filegdb, }, { label: 'GeoJSON', value: downloadFormats.geojson, }, { - label: 'CSV', - value: downloadFormats.csv, + label: 'Shapefile', + value: downloadFormats.shapefile, }, - // { - // label: 'File Geodatabase', - // value: downloadFormats.filegdb, - // }, ]; return (

Select Data for Download

- {relevantResultLayers.map((result) => ( - - ))} + {relevantResultLayers.map((searchLayer) => { + const uniqueId = searchLayer[fieldNames.queryLayers.uniqueId]; + const layerName = searchLayer[fieldNames.queryLayers.layerName]; + + return ( + + ); + })}

Format

- {mutation.isSuccess ? ( - - {' '} - Download your Data - - ) : null} - {mutation.isError ? ( -

- There was an error generating your zip file! -

- ) : null} -
); } Download.propTypes = { searchResultLayers: PropTypes.arrayOf(PropTypes.object).isRequired, - mutation: PropTypes.object.isRequired, + selectedLayers: PropTypes.arrayOf(PropTypes.string).isRequired, + setSelectedLayers: PropTypes.func.isRequired, + format: PropTypes.string.isRequired, + setFormat: PropTypes.func.isRequired, }; diff --git a/src/components/search-wizard/Download.stories.jsx b/src/components/search-wizard/Download.stories.jsx index 599ed22e..d424929d 100644 --- a/src/components/search-wizard/Download.stories.jsx +++ b/src/components/search-wizard/Download.stories.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { fieldNames } from '../../../functions/common/config'; import queryLayerResult from '../../../tests/fixtures/queryLayerResult.json'; import Download from './Download'; @@ -34,56 +35,44 @@ const noneFoundResult = { [fieldNames.queryLayers.uniqueId]: '5', features: [], }; +const queryLayerResult4 = { + ...queryLayerResult, + [fieldNames.queryLayers.uniqueId]: '6', + [fieldNames.queryLayers.layerName]: 'No export formats', + supportedExportFormats: undefined, +}; const results = [ queryLayerResult, queryLayerResult2, queryLayerResult3, + queryLayerResult4, errorResult, noneFoundResult, ]; -export const Initial = () => { - const mutation = { - isLoading: false, - isError: false, - isSuccess: false, - }; - - return ; -}; +function Test({ searchResultLayers }) { + const [selectedLayers, setSelectedLayers] = useState(['2', '3', '4', '18']); + const [format, setFormat] = useState('csv'); -export const Busy = () => { - const mutation = { - isLoading: true, - isError: false, - isSuccess: false, - }; + return ( + + ); +} - return ; +Test.propTypes = { + searchResultLayers: Download.propTypes.searchResultLayers, }; -export const Result = () => { - const mutation = { - isLoading: false, - isError: false, - isSuccess: true, - data: { - url: 'https://example.com', - }, - }; - - return ; +export const Initial = () => { + return ; }; -export const Error = () => { - const mutation = { - isLoading: false, - isError: true, - isSuccess: false, - error: { - message: 'There was an error generating the zip file', - }, - }; - - return ; +export const Busy = () => { + return ; }; diff --git a/src/components/search-wizard/DownloadProgress.jsx b/src/components/search-wizard/DownloadProgress.jsx new file mode 100644 index 00000000..eb16e9a6 --- /dev/null +++ b/src/components/search-wizard/DownloadProgress.jsx @@ -0,0 +1,74 @@ +import PropTypes from 'prop-types'; +import { useRef } from 'react'; +import { fieldNames } from '../../../functions/common/config'; +import Icon from '../../utah-design-system/Icon'; +import ResultStatusIcons from './ResultStatusIcons'; + +export default function DownloadProgress({ layers, results }) { + const anchorTagRefs = useRef(new Map()); + + return ( +
+

Download Results

+ +

+ This can take up to several minutes for larger datasets. +

+
+ ); +} + +DownloadProgress.propTypes = { + layers: PropTypes.arrayOf(PropTypes.object).isRequired, + results: PropTypes.arrayOf(PropTypes.object), +}; diff --git a/src/components/search-wizard/DownloadProgress.stories.jsx b/src/components/search-wizard/DownloadProgress.stories.jsx new file mode 100644 index 00000000..bc8e13a6 --- /dev/null +++ b/src/components/search-wizard/DownloadProgress.stories.jsx @@ -0,0 +1,58 @@ +import { fieldNames } from '../../../functions/common/config'; +import queryLayerResult from '../../../tests/fixtures/queryLayerResult.json'; +import DownloadProgress from './DownloadProgress'; + +export default { + title: 'DownloadProgress', + component: DownloadProgress, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +const queryLayerResult2 = { + ...queryLayerResult, + [fieldNames.queryLayers.uniqueId]: '2', + [fieldNames.queryLayers.layerName]: 'A longer name than most', +}; +const queryLayerResult3 = { + ...queryLayerResult, + [fieldNames.queryLayers.uniqueId]: '3', + [fieldNames.queryLayers.layerName]: 'Different Name', +}; +const queryLayerResult4 = { + ...queryLayerResult, + [fieldNames.queryLayers.uniqueId]: '4', + [fieldNames.queryLayers.layerName]: + 'Different Name Wrap a wrappity wrap wrap', +}; + +const layers = [ + queryLayerResult, + queryLayerResult2, + queryLayerResult3, + queryLayerResult4, +]; + +export const Default = () => { + const results = [ + { + uniqueId: '18', + error: 'There was an error!', + }, + { + uniqueId: '3', + url: 'https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/SITEREM/FeatureServer/replicafilescache/SITEREM_-1519884480040818284.zip', + }, + { + uniqueId: '4', + url: 'https://services1.arcgis.com/99lidPhWCzftIe9K/ArcGIS/rest/services/SITEREM/FeatureServer/replicafilescache/SITEREM_-1519884480040818284.zip', + }, + ]; + + return ; +}; diff --git a/src/components/search-wizard/ResultStatusIcons.jsx b/src/components/search-wizard/ResultStatusIcons.jsx new file mode 100644 index 00000000..72c9f23d --- /dev/null +++ b/src/components/search-wizard/ResultStatusIcons.jsx @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import Icon from '../../utah-design-system/Icon'; +import Spinner from '../../utah-design-system/Spinner'; +import Tooltip from '../../utah-design-system/Tooltip'; + +export default function ResultStatusIcons({ resultConfig, layerName }) { + const hasError = resultConfig?.error; + + return !resultConfig ? ( + + + + ) : hasError ? ( + + } + > + {resultConfig.error} + + ) : ( + + ); +} + +ResultStatusIcons.propTypes = { + resultConfig: PropTypes.shape({ + error: PropTypes.string, + }), + layerName: PropTypes.string.isRequired, +}; diff --git a/src/components/search-wizard/Progress.jsx b/src/components/search-wizard/SearchProgress.jsx similarity index 51% rename from src/components/search-wizard/Progress.jsx rename to src/components/search-wizard/SearchProgress.jsx index 32fcb1c9..4e82e544 100644 --- a/src/components/search-wizard/Progress.jsx +++ b/src/components/search-wizard/SearchProgress.jsx @@ -1,10 +1,8 @@ import PropTypes from 'prop-types'; import { fieldNames } from '../../../functions/common/config'; -import Icon from '../../utah-design-system/Icon'; -import Spinner from '../../utah-design-system/Spinner'; -import Tooltip from '../../utah-design-system/Tooltip'; +import ResultStatusIcons from './ResultStatusIcons'; -export default function Progress({ searchLayers, results }) { +export default function SearchProgress({ searchLayers, results }) { return (

Search Results

@@ -14,38 +12,14 @@ export default function Progress({ searchLayers, results }) { const resultConfig = results.find( (result) => result[fieldNames.queryLayers.uniqueId] === uniqueId ); - const hasError = resultConfig?.error; const layerName = config[fieldNames.queryLayers.layerName]; return (
  • - {!resultConfig ? ( - - ) : hasError ? ( - - } - > - {resultConfig.error} - - ) : ( - - )} + {layerName} {resultConfig?.features ? ( @@ -64,7 +38,7 @@ export default function Progress({ searchLayers, results }) { ); } -Progress.propTypes = { +SearchProgress.propTypes = { searchLayers: PropTypes.arrayOf(PropTypes.object).isRequired, results: PropTypes.arrayOf(PropTypes.object).isRequired, }; diff --git a/src/components/search-wizard/Progress.stories.jsx b/src/components/search-wizard/SearchProgress.stories.jsx similarity index 77% rename from src/components/search-wizard/Progress.stories.jsx rename to src/components/search-wizard/SearchProgress.stories.jsx index 1611a21a..6f109425 100644 --- a/src/components/search-wizard/Progress.stories.jsx +++ b/src/components/search-wizard/SearchProgress.stories.jsx @@ -1,8 +1,8 @@ -import Progress from './Progress'; +import SearchProgress from './SearchProgress'; export default { - title: 'Progress', - component: Progress, + title: 'SearchProgress', + component: SearchProgress, }; const queryLayers = [ @@ -37,6 +37,6 @@ const results = [ export const Default = () => (
    - +
    ); diff --git a/src/components/search-wizard/Wizard.jsx b/src/components/search-wizard/Wizard.jsx index 680a6f2a..9d9b5528 100644 --- a/src/components/search-wizard/Wizard.jsx +++ b/src/components/search-wizard/Wizard.jsx @@ -1,15 +1,59 @@ import { useMutation } from '@tanstack/react-query'; -import { httpsCallable } from 'firebase/functions'; +import ky from 'ky'; import { useEffect, useState } from 'react'; -import { useFunctions, useRemoteConfigString } from 'reactfire'; +import { useRemoteConfigString } from 'reactfire'; import { fieldNames, schemas } from '../../../functions/common/config.js'; import { useSearchMachine } from '../../SearchMachineProvider.jsx'; import Button from '../../utah-design-system/Button.jsx'; import AdvancedFilter from './AdvancedFilter.jsx'; import Download from './Download.jsx'; -import Progress from './Progress.jsx'; +import DownloadProgress from './DownloadProgress.jsx'; +import Progress from './SearchProgress.jsx'; import SelectMapData from './SelectMapData.jsx'; +async function generateZip(layer, format, send) { + const layerUrl = layer.featureService; + const parentUrl = layerUrl.substring(0, layerUrl.lastIndexOf('/')); + const layerIndex = layerUrl.substring(layerUrl.lastIndexOf('/') + 1); + const params = new URLSearchParams({ + layers: layerIndex, + layerQueries: JSON.stringify({ + 0: { where: `OBJECTID IN (${layer.objectIds.join(',')})` }, + }), + syncModel: 'none', + dataFormat: format, + f: 'json', + }); + + let response; + try { + response = await ky + .post(`${parentUrl}/createReplica`, { + body: params, + timeout: 120 * 60 * 1000, + }) + .json(); + } catch (error) { + response = { error }; + } + + if (response.error) { + send('RESULT', { + result: { + uniqueId: layer.uniqueId, + error: response.error.message, + }, + }); + } else { + send('RESULT', { + result: { + uniqueId: layer.uniqueId, + url: response.responseUrl, + }, + }); + } +} + export default function SearchWizard() { const [state, send] = useSearchMachine(); const [queryLayers, setQueryLayers] = useState([]); @@ -38,10 +82,12 @@ export default function SearchWizard() { } }, [queryLayersConfig]); - const generateZip = httpsCallable(useFunctions(), 'generate'); - const generateZipMutation = useMutation({ - mutationFn: async (data) => { - return await generateZip(data); + const downloadMutation = useMutation({ + mutationFn: async ({ layers, format }) => { + send('DOWNLOADING'); + return await Promise.all( + layers.map((layer) => generateZip(layer, format, send)) + ); }, }); @@ -67,7 +113,23 @@ export default function SearchWizard() { {state.matches('download') ? ( + send('SET_SELECTED_LAYERS', { selectedLayers: newIds }) + } + format={state.context.downloadFormat} + setFormat={(newFormat) => send('SET_FORMAT', { format: newFormat })} + /> + ) : null} + {state.matches('downloading') ? ( + + state.context.selectedDownloadLayers.includes( + layer[fieldNames.queryLayers.uniqueId] + ) + )} + results={state.context.downloadResultLayers} /> ) : null}
    @@ -111,16 +173,50 @@ export default function SearchWizard() { ) : null} {state.matches('download') ? ( - <> - - + + ) : null} + {state.matches('download') || state.matches('downloading') ? ( + ) : null}