diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index c080ac52..0b7e3606 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -37,7 +37,7 @@ jobs: - name: Copy env file run: cp .env.example .env - name: Copy config file - run: cp ./src/assets/config.example.js ./src/assets/config.js + run: cp ./public/config/config.example.json ./public/config/config.json - name: Run unit tests run: npm run test - name: Run dev build diff --git a/.gitignore b/.gitignore index 02a98b14..fc518b80 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,8 @@ .env* !.env.example -/src/assets/config*.js -!/src/assets/config.example.js +/public/config/config*.json +!/public/config/config.example.json # dependencies /node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1e64ab..ec5d5be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Moved config reference from `envSetupVars.js` into `search.jsx` - Geosearch moved under zoom control and changed to be collapsed - `VITE_ADVANCED_SEARCH_ENABLED` and `VITE_CART_ENABLED` must be set in `config.js` +- Moved config location from `src/assets/config.js` to `public/config/config.json` +- Refactor config to be a json object instead of separate exported constants +- Changed config key names to remove the word `VITE` (leftover from when they were .env vars) +- Refactor `default.js` to remove dead code and rename vars ### Fixed @@ -34,6 +38,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - When polygon drawn, use as search intersects param instead of map viewport bbox - Added upload geojson feature to allow users to select a geojson file to add to map - Reusable System Message component for showing app alerts +- Load `config.json` into redux on app load once instead of direct imports +- Add pre-initialization page to handle and show error (and not render app) if config is missing ### Removed diff --git a/README.md b/README.md index 1cd4f24e..96d8df94 100644 --- a/README.md +++ b/README.md @@ -55,34 +55,41 @@ Sentinel-2 L2A Mosaic View ### Environment Files -For local development, you should create an `.env` & `./src/assets/config.js` file with appropriate variables outlined in the table below. -The files `.env.example` and `./src/assets/config.example.js` are included in this repository as representative files. - -| Variable | Description | Required | -| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -| PUBLIC_URL | URL for the FilmDrop UI. Useful when using a CDN to host application. | Optional | -| VITE_APP_NAME | Name for this app. (set in `.env`, because it is needed prior to any JS loading) | Optional | -| VITE_LOGO_URL | URL for your custom logo | Optional | -| VITE_LOGO_ALT | Alt image description for your custom logo | Optional | -| VITE_DASHBOARD_BTN_URL | URL for the Dashboard button at the top right of the UI. If not set, the button will not be visible. | Optional | -| VITE_ANALYZE_BTN_URL | URL for the Analyze button at the bottom left of the UI. If not set, the button will not be visible. | Optional | -| VITE_SHOW_PUBLISH_BTN | Flag for displaying the Publish button at the bottom left of the UI. Setting to `true` will display the button, any other value will not display the button. Default is to not display the button. | Optional | -| VITE_STAC_API_URL | URL for STAC API | Required | -| VITE_API_MAX_ITEMS | Maximum number of items requested from API. If not set, the default max items will be 200. | Optional | -| VITE_DEFAULT_COLLECTION | Default collection option for collection dropdown | Optional | -| VITE_SCENE_TILER_URL | URL for map tiling | Required | -| VITE_SCENE_TILER_PARAMS | Per-collection configuration of TiTiler `assets`, `color_formula`, `bidx`, `rescale`, `expression`, and `colormap_name` parameters. Example in [config.example.js](./src/assets/config.example.js) | Optional | -| VITE_MOSAIC_MIN_ZOOM_LEVEL | Minimum zoom level for mosaic view search results. If not set, the default zoom level will be 7. | Optional | -| VITE_CF_TEMPLATE_URL | CloudFormation Template URL used to create a new stack. If not set, the Launch Your Own button will not be visible. | Optional | -| VITE_MOSAIC_TILER_URL | URL for mosaic tiling. If not set, the View Mode selector will not be visible. The app requires the use of the [NASA IMPACT TiTiler fork](https://github.com/NASA-IMPACT/titiler) as it contains the mosaicjson endpoints needed. | Optional | -| VITE_MOSAIC_TILER_PARAMS | Per-collection configuration of TiTiler mosaic `assets`, `color_formula`, `bidx`, `rescale`, `expression`, and `colormap_name` parameters. Example in [config.example.js](./src/assets/config.example.js) | Optional | -| VITE_MOSAIC_MAX_ITEMS | Maximum number of items in mosaic. If not set, the default max items will be 100. | Optional | -| VITE_SEARCH_MIN_ZOOM_LEVELS | Per-collection configuration for minimum zoom levels needed for grid code aggregated results (medium zoom level) and single scene search results (high zoom level). Example: [config.example.js](./src/assets/config.example.js). If no grid code aggregation, set value for `medium` to be the same value as `high` and hex aggregations will be used until the zoom level is reached when individual scenes become available. | Optional | -| VITE_COLORMAP | Color map used in low level hex grid search results. Complete list of colormaps are available here: [bpostlethwaite/colormap](https://github.com/bpostlethwaite/colormap). If not set, the default colormap will be "viridis". | Optional | -| VITE_BASEMAP_URL | URL to specify a basemap provider used by the leaflet map. Must be a raster tile provider as vector tiles are not supported. If not set, the default colormap will be `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. | Optional | -| VITE_BASEMAP_HTML_ATTRIBUTION | String of HTML markup used to set the attribution for the basemap provider used by the leaflet map. Markup is sanitized prior to render with `DOMPurify` and only is retricted to only allow `html`, `'a' tags`, and `'href'` and `'target'` attributes. Custom attribution will not render if `VITE_BASEMAP_URL` is not also set. If not set, the default attribution will be `© OpenStreetMap`. (Note: Raw HTML was used here since attribution is non-standardized.) | Optional | -| VITE_ADVANCED_SEARCH_ENABLED | If set to `true` advanced search options will render and allow users to draw or upload a geojson file to use as search bounds. | Optional | -| VITE_CART_ENABLED | If set to `true` cart features will be enabled. These include: rendering cart button in search controls bar, adding cart management buttons to popup results, render buttons in messages to quickly add some or all scenes to cart after search completes. | Optional | +For local development, you should create an `.env` & `./public/config/config.json` file with appropriate variables outlined in the table below. +The files `.env.example` and `./public/config/config.example.json` are included in this repository as representative files. + +### `.env` + +| Variable | Description | Required | +| ------------- | -------------------------------------------------------------------------------- | -------- | +| VITE_APP_NAME | Name for this app. (set in `.env`, because it is needed prior to any JS loading) | Optional | + +### `config.json` + +| Variable | Description | Required | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| STAC_API_URL | URL for STAC API | Required | +| PUBLIC_URL | URL for the FilmDrop UI. Useful when using a CDN to host application. | Optional | +| LOGO_URL | URL for your custom logo | Optional | +| LOGO_ALT | Alt image description for your custom logo | Optional | +| DASHBOARD_BTN_URL | URL for the Dashboard button at the top right of the UI. If not set, the button will not be visible. | Optional | +| ANALYZE_BTN_URL | URL for the Analyze button at the bottom left of the UI. If not set, the button will not be visible. | Optional | +| SHOW_PUBLISH_BTN | Flag for displaying the Publish button at the bottom left of the UI. Setting to `true` will display the button, any other value will not display the button. Default is to not display the button. | Optional | +| API_MAX_ITEMS | Maximum number of items requested from API. If not set, the default max items will be 200. | Optional | +| DEFAULT_COLLECTION | Default collection option for collection dropdown | Optional | +| SCENE_TILER_URL | URL for map tiling | Optional | +| SCENE_TILER_PARAMS | Per-collection configuration of TiTiler `assets`, `color_formula`, `bidx`, `rescale`, `expression`, and `colormap_name` parameters. Example in [config.example.json](./public/config/config.example.json) | Optional | +| MOSAIC_MIN_ZOOM_LEVEL | Minimum zoom level for mosaic view search results. If not set, the default zoom level will be 7. | Optional | +| CF_TEMPLATE_URL | CloudFormation Template URL used to create a new stack. If not set, the Launch Your Own button will not be visible. | Optional | +| MOSAIC_TILER_URL | URL for mosaic tiling. If not set, the View Mode selector will not be visible. The app requires the use of the [NASA IMPACT TiTiler fork](https://github.com/NASA-IMPACT/titiler) as it contains the mosaicjson endpoints needed. | Optional | +| MOSAIC_TILER_PARAMS | Per-collection configuration of TiTiler mosaic `assets`, `color_formula`, `bidx`, `rescale`, `expression`, and `colormap_name` parameters. Example in [config.example.json](./public/config/config.example.json) | Optional | +| MOSAIC_MAX_ITEMS | Maximum number of items in mosaic. If not set, the default max items will be 100. | Optional | +| SEARCH_MIN_ZOOM_LEVELS | Per-collection configuration for minimum zoom levels needed for grid code aggregated results (medium zoom level) and single scene search results (high zoom level). Example: [config.example.json](./public/config/config.example.json). If no grid code aggregation, set value for `medium` to be the same value as `high` and hex aggregations will be used until the zoom level is reached when individual scenes become available. | Optional | +| CONFIG_COLORMAP | Color map used in low level hex grid search results. Complete list of colormaps are available here: [bpostlethwaite/colormap](https://github.com/bpostlethwaite/colormap). If not set, the default colormap will be "viridis". | Optional | +| BASEMAP_URL | URL to specify a basemap provider used by the leaflet map. Must be a raster tile provider as vector tiles are not supported. If not set, the default colormap will be `https://tile.openstreetmap.org/{z}/{x}/{y}.png`. | Optional | +| BASEMAP_HTML_ATTRIBUTION | String of HTML markup used to set the attribution for the basemap provider used by the leaflet map. Markup is sanitized prior to render with `DOMPurify` and only is retricted to only allow `html`, `'a' tags`, and `'href'` and `'target'` attributes. Custom attribution will not render if `BASEMAP_URL` is not also set. If not set, the default attribution will be `© OpenStreetMap`. (Note: Raw HTML was used here since attribution is non-standardized.) | Optional | +| ADVANCED_SEARCH_ENABLED | If set to `true` advanced search options will render and allow users to draw or upload a geojson file to use as search bounds. | Optional | +| CART_ENABLED | If set to `true` cart features will be enabled. These include: rendering cart button in search controls bar, adding cart management buttons to popup results, render buttons in messages to quickly add some or all scenes to cart after search completes. | Optional | ### Links @@ -96,7 +103,7 @@ This project contains several NPM scripts for common tasks. Runs the app locally at -This uses the env vars found in `.env` and `./src/assets/config.js`. +This uses the env vars found in `.env` and `./public/config/config.json`. ### `npm test` @@ -104,7 +111,7 @@ Launches the test runner. ### `npm run build` -This builds using the env vars found in `.env` and `./src/assets/config.js`. +This builds using the env vars found in `.env` and `./public/config/config.json`. The result will appear in the `build` folder. diff --git a/package-lock.json b/package-lock.json index 14356309..16d63bbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@tsconfig/node18": "^2.0.1", + "@types/leaflet-draw": "^1.0.7", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.4", "@types/testing-library__jest-dom": "^5.14.6", @@ -2271,6 +2272,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "node_modules/@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -2367,6 +2374,24 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==", + "dev": true, + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet-draw": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.7.tgz", + "integrity": "sha512-Tje5jjUC9aPmy9NSYx8HbPIVpX2VT3JyBk6wZ46PqneJzgev+UyBuK72Emvu8xaSmAEBkhlsImR7SACsdItXSw==", + "dev": true, + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.195", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", @@ -13924,6 +13949,12 @@ "@types/estree": "*" } }, + "@types/geojson": { + "version": "7946.0.10", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.10.tgz", + "integrity": "sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==", + "dev": true + }, "@types/hast": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.4.tgz", @@ -14013,6 +14044,24 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/leaflet": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.3.tgz", + "integrity": "sha512-Caa1lYOgKVqDkDZVWkto2Z5JtVo09spEaUt2S69LiugbBpoqQu92HYFMGUbYezZbnBkyOxMNPXHSgRrRY5UyIA==", + "dev": true, + "requires": { + "@types/geojson": "*" + } + }, + "@types/leaflet-draw": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.7.tgz", + "integrity": "sha512-Tje5jjUC9aPmy9NSYx8HbPIVpX2VT3JyBk6wZ46PqneJzgev+UyBuK72Emvu8xaSmAEBkhlsImR7SACsdItXSw==", + "dev": true, + "requires": { + "@types/leaflet": "*" + } + }, "@types/lodash": { "version": "4.14.195", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", diff --git a/package.json b/package.json index ddeddc76..81d57183 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", "@tsconfig/node18": "^2.0.1", + "@types/leaflet-draw": "^1.0.7", "@types/react": "^18.2.14", "@types/react-dom": "^18.2.4", "@types/testing-library__jest-dom": "^5.14.6", diff --git a/public/config/config.example.json b/public/config/config.example.json new file mode 100644 index 00000000..59e7d74e --- /dev/null +++ b/public/config/config.example.json @@ -0,0 +1,104 @@ +{ + "PUBLIC_URL": "http://example.com/", + "LOGO_URL": "./logo.png", + "LOGO_ALT": "Alt description for my custom logo", + "SHOW_PUBLISH_BTN": false, + "DEFAULT_COLLECTION": "collection-name", + "STAC_API_URL": "https://api-endpoint.example.com", + "API_MAX_ITEMS": 200, + "SCENE_TILER_URL": "https://titiler.example.com", + "DASHBOARD_BTN_URL": "https://dashboard.example.com", + "ANALYZE_BTN_URL": "https://dashboard.example.com", + "CF_TEMPLATE_URL": "https://cf-template.example.com", + "SCENE_TILER_PARAMS": { + "sentinel-2-l2a": { + "assets": ["red", "green", "blue"], + "color_formula": "Gamma+RGB+3.2+Saturation+0.8+Sigmoidal+RGB+12+0.35" + }, + "landsat-c2-l2": { + "assets": ["red", "green", "blue"], + "color_formula": "Gamma+RGB+1.7+Saturation+1.7+Sigmoidal+RGB+15+0.35" + }, + "naip": { + "assets": ["image"], + "bidx": "1,2,3" + }, + "cop-dem-glo-30": { + "assets": ["data"], + "colormap_name": "terrain", + "rescale": ["-1000,4000"] + }, + "cop-dem-glo-90": { + "assets": ["data"], + "colormap_name": "terrain", + "rescale": ["-1000,4000"] + }, + "sentinel-1-grd": { + "assets": ["vv"], + "rescale": ["0,250"], + "colormap_name": "plasma" + } + }, + "MOSAIC_TILER_URL": "https://titiler-mosaic.example.com", + "MOSAIC_TILER_PARAMS": { + "sentinel-2-l2a": { + "assets": ["visual"] + }, + "landsat-c2-l2": { + "assets": ["red"], + "color_formula": "Gamma+R+1.7+Sigmoidal+R+15+0.35" + }, + "naip": { + "assets": ["image"], + "bidx": "1,2,3" + }, + "cop-dem-glo-30": { + "assets": ["data"], + "colormap_name": "terrain", + "rescale": ["-1000,4000"] + }, + "cop-dem-glo-90": { + "assets": ["data"], + "colormap_name": "terrain", + "rescale": ["-1000,4000"] + }, + "sentinel-1-grd": { + "assets": ["vv"], + "rescale": ["0,250"], + "colormap_name": "plasma" + } + }, + "MOSAIC_MAX_ITEMS": 100, + "MOSAIC_MIN_ZOOM_LEVEL": 7, + "CONFIG_COLORMAP": "viridis", + "SEARCH_MIN_ZOOM_LEVELS": { + "sentinel-2-l2a": { + "medium": 4, + "high": 7 + }, + "landsat-c2-l2": { + "medium": 4, + "high": 7 + }, + "naip": { + "medium": 10, + "high": 14 + }, + "cop-dem-glo-30": { + "medium": 6, + "high": 8 + }, + "cop-dem-glo-90": { + "medium": 6, + "high": 8 + }, + "sentinel-1-grd": { + "medium": 7, + "high": 7 + } + }, + "BASEMAP_URL": "https://tile-provider.example.com/{z}/{x}/{y}.png", + "BASEMAP_HTML_ATTRIBUTION": "© TileProvider", + "ADVANCED_SEARCH_ENABLED": false, + "CART_ENABLED": false +} diff --git a/src/App.css b/src/App.css index 9d15d6a7..ce42a083 100644 --- a/src/App.css +++ b/src/App.css @@ -8,3 +8,13 @@ .leaflet-tooltip { font-family: 'Inter', sans-serif; } + +.appLoading { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgb(53, 61, 79); + z-index: 0; +} diff --git a/src/App.jsx b/src/App.jsx index 3015dfaa..c7d3e456 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,7 @@ import UploadGeojsonModal from './components/UploadGeojsonModal/UploadGeojsonMod import SystemMessage from './components/SystemMessage/SystemMessage' import { GetCollectionsService } from './services/get-collections-service' +import { LoadConfigIntoStateService } from './services/get-config-service' import { useSelector } from 'react-redux' function App() { @@ -29,23 +30,38 @@ function App() { const _showApplicationAlert = useSelector( (state) => state.mainSlice.showApplicationAlert ) + const _appConfig = useSelector((state) => state.mainSlice.appConfig) + useEffect(() => { - GetCollectionsService() + LoadConfigIntoStateService() }, []) + useEffect(() => { + if (_appConfig) { + GetCollectionsService() + } + }, [_appConfig]) + return ( -
- - - {_showPublishModal ? : null} - {_showLaunchModal ? : null} - {_showLaunchImageModal ? : null} - {_showUploadGeojsonModal ? ( - - ) : null} - {_showApplicationAlert ? : null} -
+ {_appConfig ? ( +
+ + + {_showPublishModal ? : null} + {_showLaunchModal ? : null} + {_showLaunchImageModal ? : null} + {_showUploadGeojsonModal ? ( + + ) : null} + {_showApplicationAlert ? : null} +
+ ) : ( +
+
+ {_showApplicationAlert ? : null} +
+ )}
) } diff --git a/src/App.test.jsx b/src/App.test.jsx index 6a3abb5f..1cadb2d7 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -8,10 +8,13 @@ import { setShowLaunchModal, setShowLaunchImageModal, setshowUploadGeojsonModal, - setshowApplicationAlert + setshowApplicationAlert, + setappConfig } from './redux/slices/mainSlice' import { vi } from 'vitest' import * as CollectionsService from './services/get-collections-service' +import * as LoadConfigService from './services/get-config-service' +import { mockAppConfig } from './testing/shared-mocks' describe('App', () => { const setup = () => @@ -21,7 +24,10 @@ describe('App', () => { ) - describe('on app render', () => { + describe('on app render with config', () => { + beforeEach(() => { + store.dispatch(setappConfig(mockAppConfig)) + }) afterEach(() => { vi.restoreAllMocks() }) @@ -30,6 +36,11 @@ describe('App', () => { setup() expect(spy).toHaveBeenCalledTimes(1) }) + it('should call LoadConfigIntoStateService once', () => { + const spy = vi.spyOn(LoadConfigService, 'LoadConfigIntoStateService') + setup() + expect(spy).toHaveBeenCalledTimes(1) + }) it('should render the PageHeader component', () => { setup() const PageHeaderComponent = screen.queryByTestId('testPageHeader') @@ -114,4 +125,24 @@ describe('App', () => { }) }) }) + describe('on app render without config', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + it('should showAppLoading page', () => { + setup() + const PageHeaderComponent = screen.queryByTestId('testAppLoading') + expect(PageHeaderComponent).not.toBeNull() + }) + it('should call LoadConfigIntoStateService once', () => { + const spy = vi.spyOn(LoadConfigService, 'LoadConfigIntoStateService') + setup() + expect(spy).toHaveBeenCalledTimes(1) + }) + it('should call not GetCollectionsService', () => { + const spy = vi.spyOn(CollectionsService, 'GetCollectionsService') + setup() + expect(spy).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/assets/config.example.js b/src/assets/config.example.js deleted file mode 100644 index 621034e1..00000000 --- a/src/assets/config.example.js +++ /dev/null @@ -1,78 +0,0 @@ -export const PUBLIC_URL = 'http://example.com/' -export const VITE_LOGO_URL = './logo.png' -export const VITE_LOGO_ALT = 'Alt description for my custom logo' -export const VITE_SHOW_PUBLISH_BTN = false -export const VITE_DEFAULT_COLLECTION = 'collection-name' -export const VITE_STAC_API_URL = 'https://api-endpoint.example.com' -export const VITE_API_MAX_ITEMS = 200 -export const VITE_SCENE_TILER_URL = 'https://titiler.example.com' -export const VITE_DASHBOARD_BTN_URL = 'https://dashboard.example.com' -export const VITE_ANALYZE_BTN_URL = 'https://dashboard.example.com' -export const VITE_CF_TEMPLATE_URL = 'https://cf-template.example.com' -export const VITE_SCENE_TILER_PARAMS = { - 'sentinel-2-l2a': { - assets: ['red', 'green', 'blue'], - color_formula: 'Gamma+RGB+3.2+Saturation+0.8+Sigmoidal+RGB+12+0.35' - }, - 'landsat-c2-l2': { - assets: ['red', 'green', 'blue'], - color_formula: 'Gamma+RGB+1.7+Saturation+1.7+Sigmoidal+RGB+15+0.35' - }, - naip: { assets: ['image'], bidx: '1,2,3' }, - 'cop-dem-glo-30': { - assets: ['data'], - colormap_name: 'terrain', - rescale: ['-1000,4000'] - }, - 'cop-dem-glo-90': { - assets: ['data'], - colormap_name: 'terrain', - rescale: ['-1000,4000'] - }, - 'sentinel-1-grd': { - assets: ['vv'], - rescale: ['0,250'], - colormap_name: 'plasma' - } -} -export const VITE_MOSAIC_TILER_URL = 'https://titiler-mosaic.example.com' -export const VITE_MOSAIC_TILER_PARAMS = { - 'sentinel-2-l2a': { assets: ['visual'] }, - 'landsat-c2-l2': { - assets: ['red'], - color_formula: 'Gamma+R+1.7+Sigmoidal+R+15+0.35' - }, - naip: { assets: ['image'], bidx: '1,2,3' }, - 'cop-dem-glo-30': { - assets: ['data'], - colormap_name: 'terrain', - rescale: ['-1000,4000'] - }, - 'cop-dem-glo-90': { - assets: ['data'], - colormap_name: 'terrain', - rescale: ['-1000,4000'] - }, - 'sentinel-1-grd': { - assets: ['vv'], - rescale: ['0,250'], - colormap_name: 'plasma' - } -} -export const VITE_MOSAIC_MAX_ITEMS = 100 -export const VITE_MOSAIC_MIN_ZOOM_LEVEL = 7 -export const VITE_COLORMAP = 'viridis' -export const VITE_SEARCH_MIN_ZOOM_LEVELS = { - 'sentinel-2-l2a': { medium: 4, high: 7 }, - 'landsat-c2-l2': { medium: 4, high: 7 }, - naip: { medium: 10, high: 14 }, - 'cop-dem-glo-30': { medium: 6, high: 8 }, - 'cop-dem-glo-90': { medium: 6, high: 8 }, - 'sentinel-1-grd': { medium: 7, high: 7 } -} -export const VITE_BASEMAP_URL = - 'https://tile-provider.example.com/{z}/{x}/{y}.png' -export const VITE_BASEMAP_HTML_ATTRIBUTION = - '© TileProvider' -export const VITE_ADVANCED_SEARCH_ENABLED = false -export const VITE_CART_ENABLED = false diff --git a/src/components/CollectionDropdown/CollectionDropdown.jsx b/src/components/CollectionDropdown/CollectionDropdown.jsx index 74b25890..c27c60f2 100644 --- a/src/components/CollectionDropdown/CollectionDropdown.jsx +++ b/src/components/CollectionDropdown/CollectionDropdown.jsx @@ -3,7 +3,6 @@ import './CollectionDropdown.css' import Box from '@mui/material/Box' import Grid from '@mui/material/Grid' import NativeSelect from '@mui/material/NativeSelect' -import { VITE_DEFAULT_COLLECTION } from '../../assets/config.js' import { useDispatch, useSelector } from 'react-redux' import { setSelectedCollectionData, @@ -19,8 +18,6 @@ import { } from '../../utils/mapHelper' const Dropdown = () => { - const DEFAULT_COLLECTION = VITE_DEFAULT_COLLECTION - const dispatch = useDispatch() const [collectionId, setCollectionId] = useState('Select Collection') @@ -28,17 +25,17 @@ const Dropdown = () => { (state) => state.mainSlice.collectionsData ) + const _appConfig = useSelector((state) => state.mainSlice.appConfig) + useEffect(() => { if (_collectionsData.length > 0) { const defaultCollectionFound = !!_collectionsData.find( - (o) => o.id === DEFAULT_COLLECTION + (o) => o.id === _appConfig.DEFAULT_COLLECTION ) if (!defaultCollectionFound) { - console.log( - 'Configuration Error: VITE_DEFAULT_COLLECTION not found in API' - ) + console.log('Configuration Error: DEFAULT_COLLECTION not found in API') } else { - setCollectionId(DEFAULT_COLLECTION) + setCollectionId(_appConfig.DEFAULT_COLLECTION) } } }, [_collectionsData]) diff --git a/src/components/CollectionDropdown/CollectionDropdown.test.jsx b/src/components/CollectionDropdown/CollectionDropdown.test.jsx index 74bf7cea..6fd6cd3d 100644 --- a/src/components/CollectionDropdown/CollectionDropdown.test.jsx +++ b/src/components/CollectionDropdown/CollectionDropdown.test.jsx @@ -4,8 +4,8 @@ import { render, screen } from '@testing-library/react' import CollectionDropdown from './CollectionDropdown' import { Provider } from 'react-redux' import { store } from '../../redux/store' -import { setCollectionsData } from '../../redux/slices/mainSlice' -import { mockCollectionsData } from '../../testing/shared-mocks' +import { setCollectionsData, setappConfig } from '../../redux/slices/mainSlice' +import { mockCollectionsData, mockAppConfig } from '../../testing/shared-mocks' import * as mapHelper from '../../utils/mapHelper' import userEvent from '@testing-library/user-event' @@ -19,6 +19,7 @@ describe('CollectionDropdown', () => { beforeEach(() => { vi.mock('../../utils/mapHelper') + store.dispatch(setappConfig(mockAppConfig)) store.dispatch(setCollectionsData(mockCollectionsData)) }) afterEach(() => { diff --git a/src/components/LaunchModal/LaunchModal.css b/src/components/LaunchModal/LaunchModal.css index 9cc8e798..75aa30f1 100644 --- a/src/components/LaunchModal/LaunchModal.css +++ b/src/components/LaunchModal/LaunchModal.css @@ -49,7 +49,7 @@ margin-bottom: 2.5em; } -.launchModal .fieldContainer a { +.linkToHowToModal { color: #6cc24a; display: inline-block; border-bottom: 1px solid transparent; diff --git a/src/components/LaunchModal/LaunchModal.jsx b/src/components/LaunchModal/LaunchModal.jsx index 6f982b91..d6476275 100644 --- a/src/components/LaunchModal/LaunchModal.jsx +++ b/src/components/LaunchModal/LaunchModal.jsx @@ -2,20 +2,20 @@ import { React, useState } from 'react' import './LaunchModal.css' import iconCopy from '../../assets/icon-copy.svg' import iconExternalLink from '../../assets/icon-external-link.svg' -import { APP_NAME } from '../defaults' -import { VITE_CF_TEMPLATE_URL } from '../../assets/config.js' - -import { useDispatch } from 'react-redux' +import { DEFAULT_APP_NAME } from '../defaults' +import { useDispatch, useSelector } from 'react-redux' import { setShowLaunchModal, setShowLaunchImageModal } from '../../redux/slices/mainSlice' +import { Box } from '@mui/material' const LaunchModal = () => { const dispatch = useDispatch() const [copyButtonText, setCopyButtonText] = useState('Copy URL') const [copyButtonState, setCopyButtonState] = useState('default') - const templateURL = VITE_CF_TEMPLATE_URL + + const _appConfig = useSelector((state) => state.mainSlice.appConfig) function onCloseClick() { dispatch(setShowLaunchModal(false)) @@ -31,7 +31,7 @@ const LaunchModal = () => { // copy content to clipboard const onCopyClick = async () => { try { - await navigator.clipboard.writeText(templateURL) + await navigator.clipboard.writeText(_appConfig.CF_TEMPLATE_URL) setCopyButtonText('Copied to Clipboard') setCopyButtonState('success') } catch (err) { @@ -51,10 +51,11 @@ const LaunchModal = () => {

Launch Your Own

-

{APP_NAME}

+

{DEFAULT_APP_NAME}

- Now you can view your own datasets by deploying {APP_NAME} into your - AWS account! Simply follow the instructions below to get started. + Now you can view your own datasets by deploying {DEFAULT_APP_NAME}{' '} + into your AWS account! Simply follow the instructions below to get + started.

  1. Sign In to the Console
  2. @@ -62,7 +63,7 @@ const LaunchModal = () => { Create a new CloudFormation Stack with the template link below
  3. Configure new Stack as needed
  4. -
  5. Launch your own {APP_NAME}
  6. +
  7. Launch your own {DEFAULT_APP_NAME}

@@ -70,7 +71,7 @@ const LaunchModal = () => { URL" field:

-
{templateURL}
+
{_appConfig.CF_TEMPLATE_URL}
- onImageClick()}> + onImageClick()}> Where do I paste this information? - +
)}
- {ANALYZE_LINK && ( + {_appConfig.ANALYZE_BTN_URL && ( )} - {SHOW_PUBLISH_BTN === true && ( + {_appConfig.SHOW_PUBLISH_BTN === true && ( )} - {CF_TEMPLATE_URL && ( + {_appConfig.CF_TEMPLATE_URL && ( @@ -135,7 +128,7 @@ const BottomContent = () => { {_showAppLoading && (
- Loading {APP_NAME} + Loading {DEFAULT_APP_NAME}
)} {_searchType === 'hex' && diff --git a/src/components/Layout/Content/BottomContent/BottomContent.test.jsx b/src/components/Layout/Content/BottomContent/BottomContent.test.jsx index ad2fb9c1..d05a9804 100644 --- a/src/components/Layout/Content/BottomContent/BottomContent.test.jsx +++ b/src/components/Layout/Content/BottomContent/BottomContent.test.jsx @@ -6,12 +6,14 @@ import { Provider } from 'react-redux' import { store } from '../../../../redux/store' import { setSearchResults, - setisDrawingEnabled + setisDrawingEnabled, + setappConfig } from '../../../../redux/slices/mainSlice' import { mockSceneSearchResult, mockHexAggregateSearchResult, - mockGridAggregateSearchResult + mockGridAggregateSearchResult, + mockAppConfig } from '../../../../testing/shared-mocks' import userEvent from '@testing-library/user-event' import * as mapHelper from '../../../../utils/mapHelper' @@ -26,6 +28,7 @@ describe('BottomContent', () => { ) beforeEach(() => { + store.dispatch(setappConfig(mockAppConfig)) vi.mock('../../../../utils/mapHelper') }) afterEach(() => { diff --git a/src/components/Layout/PageHeader/PageHeader.jsx b/src/components/Layout/PageHeader/PageHeader.jsx index 12ac2d5a..d3f07ff2 100644 --- a/src/components/Layout/PageHeader/PageHeader.jsx +++ b/src/components/Layout/PageHeader/PageHeader.jsx @@ -1,45 +1,42 @@ import React from 'react' import './PageHeader.css' - import { OpenInNew } from '@mui/icons-material' import logoFilmDrop from '../../../assets/logo-filmdrop-e84.png' -import { - PUBLIC_URL, - VITE_LOGO_ALT, - VITE_LOGO_URL, - VITE_DASHBOARD_BTN_URL -} from '../../../assets/config.js' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' const PageHeader = () => { - const DASHBOARD_LINK = VITE_DASHBOARD_BTN_URL - const LOGO = VITE_LOGO_URL - const ALT_TEXT = VITE_LOGO_ALT + const _appConfig = useSelector((state) => state.mainSlice.appConfig) function onDashboardClick() { - window.open(DASHBOARD_LINK, '_blank') + window.open(_appConfig.DASHBOARD_BTN_URL, '_blank') } return (
- {LOGO ? ( - {ALT_TEXT} + {_appConfig.LOGO_URL ? ( + {_appConfig.LOGO_ALT} ) : ( FilmDrop default app name logo )}
- {DASHBOARD_LINK && ( -
onDashboardClick()}> + {_appConfig.DASHBOARD_BTN_URL && ( + onDashboardClick()}> Dashboard -
+ )} { const setup = () => @@ -11,6 +13,10 @@ describe('PageHeader', () => { ) + + beforeEach(() => { + store.dispatch(setappConfig(mockAppConfig)) + }) describe('on app render', () => { it('should load the filmdrop logo into the document', () => { setup() diff --git a/src/components/LeafMap/LeafMap.jsx b/src/components/LeafMap/LeafMap.jsx index ebeab733..040195b5 100644 --- a/src/components/LeafMap/LeafMap.jsx +++ b/src/components/LeafMap/LeafMap.jsx @@ -1,6 +1,6 @@ import { React, useEffect, useState, useRef } from 'react' import './LeafMap.css' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { setMap, setmapDrawPolygonHandler } from '../../redux/slices/mainSlice' import * as L from 'leaflet' import 'leaflet-draw' @@ -11,10 +11,6 @@ import 'leaflet-geosearch/dist/geosearch.css' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import { Tooltip } from 'react-tooltip' import 'react-tooltip/dist/react-tooltip.css' -import { - VITE_BASEMAP_URL, - VITE_BASEMAP_HTML_ATTRIBUTION -} from '../../assets/config.js' import DOMPurify from 'dompurify' import { mapClickHandler, @@ -24,6 +20,7 @@ import { const LeafMap = () => { const dispatch = useDispatch() + const _appConfig = useSelector((state) => state.mainSlice.appConfig) // set map ref to itself with useRef const mapRef = useRef() @@ -142,8 +139,8 @@ const LeafMap = () => { }, [map]) useEffect(() => { - if (VITE_BASEMAP_HTML_ATTRIBUTION) { - const output = sanitize(String(VITE_BASEMAP_HTML_ATTRIBUTION)) + if (_appConfig.BASEMAP_HTML_ATTRIBUTION) { + const output = sanitize(String(_appConfig.BASEMAP_HTML_ATTRIBUTION)) setmapAttribution(output) } }, []) @@ -173,9 +170,10 @@ const LeafMap = () => { > {/* set basemap layers here: */} @@ -204,7 +202,7 @@ const LeafMap = () => { Leaflet {' '} {' '} - {VITE_BASEMAP_URL && mapAttribution ? ( + {_appConfig.BASEMAP_URL && mapAttribution ? ( ) : ( diff --git a/src/components/Search/Search.Advanced.test.jsx b/src/components/Search/Search.Advanced.test.jsx deleted file mode 100644 index 8decfaaf..00000000 --- a/src/components/Search/Search.Advanced.test.jsx +++ /dev/null @@ -1,198 +0,0 @@ -import { vi } from 'vitest' -import React from 'react' -import { render, screen } from '@testing-library/react' -import Search from './Search' -import { Provider } from 'react-redux' -import { store } from '../../redux/store' -import { - setCloudCover, - setsearchGeojsonBoundary, - setshowAdvancedSearchOptions -} from '../../redux/slices/mainSlice' -import userEvent from '@testing-library/user-event' -import * as mapHelper from '../../utils/mapHelper' - -describe('Search', () => { - const user = userEvent.setup() - const setup = () => - render( - - - - ) - - beforeEach(() => { - vi.mock('../../utils/mapHelper') - vi.mock('../../utils/searchHelper') - vi.mock('../../assets/config', async () => { - const actualConfig = await vi.importActual('../../assets/config') - return { - ...actualConfig, - VITE_ADVANCED_SEARCH_ENABLED: true - } - }) - }) - - afterEach(() => { - vi.resetAllMocks() - }) - - describe('on render', () => { - it('should not render auto search when VITE_ADVANCED_SEARCH_ENABLED is true', () => { - setup() - expect( - screen.queryByText('test_autoSearchContainer') - ).not.toBeInTheDocument() - }) - it('should not render disabled search bar overlay div', async () => { - setup() - expect( - screen.queryByTestId('test_disableSearchOverlay') - ).not.toBeInTheDocument() - }) - }) - describe('when search options changed', () => { - it('should set showAdvancedSearchOptions to false in redux', () => { - store.dispatch(setshowAdvancedSearchOptions(true)) - setup() - store.dispatch(setCloudCover(5)) - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - }) - }) - describe('when search button clicked', () => { - it('should set showAdvancedSearchOptions to false in redux', async () => { - store.dispatch(setshowAdvancedSearchOptions(true)) - setup() - const searchButton = screen.getByRole('button', { - name: /search/i - }) - await user.click(searchButton) - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - }) - }) - describe('when advanced clicked', () => { - it('should set showAdvancedSearchOptions be the opposite showAdvancedSearchOptions of in redux', async () => { - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeTruthy() - await user.click(advancedButton) - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - }) - }) - describe('when draw boundary button clicked', () => { - it('should not call functions if geom already exists', async () => { - const spyEnableMapPolyDrawing = vi.spyOn( - mapHelper, - 'enableMapPolyDrawing' - ) - store.dispatch( - setsearchGeojsonBoundary({ - type: 'Polygon', - coordinates: [[]] - }) - ) - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const drawBoundaryButton = screen.getByRole('button', { - name: /draw boundary/i - }) - await user.click(drawBoundaryButton) - expect(spyEnableMapPolyDrawing).not.toHaveBeenCalled() - }) - it('should enter drawing state if geom does not exists', async () => { - const spyEnableMapPolyDrawing = vi.spyOn( - mapHelper, - 'enableMapPolyDrawing' - ) - store.dispatch(setshowAdvancedSearchOptions(true)) - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const drawBoundaryButton = screen.getByRole('button', { - name: /draw boundary/i - }) - await user.click(drawBoundaryButton) - expect(spyEnableMapPolyDrawing).toHaveBeenCalled() - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - expect(store.getState().mainSlice.isDrawingEnabled).toBeTruthy() - }) - }) - describe('when clear button clicked', () => { - it('should not call functions if geom does not exists', async () => { - const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const clearButton = screen.getByRole('button', { - name: /clear/i - }) - await user.click(clearButton) - expect(spyClearLayer).not.toHaveBeenCalled() - }) - it('should clear layer and close options if geom exists', async () => { - const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') - store.dispatch( - setsearchGeojsonBoundary({ - type: 'Polygon', - coordinates: [[]] - }) - ) - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const clearButton = screen.getByRole('button', { - name: /clear/i - }) - await user.click(clearButton) - expect(spyClearLayer).toHaveBeenCalled() - expect(store.getState().mainSlice.searchGeojsonBoundary).toBeNull() - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - }) - }) - describe('when upload geojson button clicked', () => { - it('should not call dispatch functions if geom already exists', async () => { - store.dispatch( - setsearchGeojsonBoundary({ - type: 'Polygon', - coordinates: [[]] - }) - ) - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const uploadGeojsonButton = screen.getByRole('button', { - name: /upload geojson/i - }) - await user.click(uploadGeojsonButton) - expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy() - }) - it('should call dispatch functions if geom does not exists', async () => { - store.dispatch(setshowAdvancedSearchOptions(true)) - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const uploadGeojsonButton = screen.getByRole('button', { - name: /upload geojson/i - }) - await user.click(uploadGeojsonButton) - expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() - expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy() - }) - }) - describe('when drawing mode enabled', () => { - it('should render disabled search bar overlay div', async () => { - setup() - const advancedButton = screen.getByText(/advanced/i) - await user.click(advancedButton) - const drawBoundaryButton = screen.getByRole('button', { - name: /draw boundary/i - }) - await user.click(drawBoundaryButton) - expect( - screen.queryByTestId('test_disableSearchOverlay') - ).toBeInTheDocument() - }) - }) -}) diff --git a/src/components/Search/Search.jsx b/src/components/Search/Search.jsx index 89471bc5..3339a315 100644 --- a/src/components/Search/Search.jsx +++ b/src/components/Search/Search.jsx @@ -14,10 +14,6 @@ import DateTimeRangeSelector from '../DateTimeRangeSelector/DateTimeRangeSelecto import CloudSlider from '../CloudSlider/CloudSlider' import CollectionDropdown from '../CollectionDropdown/CollectionDropdown' import ViewSelector from '../ViewSelector/ViewSelector' -import { - VITE_MOSAIC_TILER_URL, - VITE_ADVANCED_SEARCH_ENABLED -} from '../../assets/config' import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' import { newSearch, debounceNewSearch } from '../../utils/searchHelper' @@ -46,7 +42,8 @@ const Search = () => { const _searchGeojsonBoundary = useSelector( (state) => state.mainSlice.searchGeojsonBoundary ) - const mosaicTilerURL = VITE_MOSAIC_TILER_URL || '' + const _appConfig = useSelector((state) => state.mainSlice.appConfig) + const mosaicTilerURL = _appConfig.MOSAIC_TILER_URL || '' useEffect(() => { dispatch(setshowAdvancedSearchOptions(false)) @@ -120,7 +117,7 @@ const Search = () => {
)} - {VITE_ADVANCED_SEARCH_ENABLED ? ( + {_appConfig.ADVANCED_SEARCH_ENABLED ? ( { + const user = userEvent.setup() const setup = () => render( @@ -13,24 +23,190 @@ describe('Search', () => { ) - beforeEach(() => { - vi.mock('../../assets/config', async () => { - const actualConfig = await vi.importActual('../../assets/config') - return { - ...actualConfig, - VITE_ADVANCED_SEARCH_ENABLED: false - } - }) - }) - afterEach(() => { vi.resetAllMocks() }) - describe('on render', () => { - it('should render auto search when VITE_ADVANCED_SEARCH_ENABLED is false', () => { - setup() - expect(screen.getByText(/auto search/i)).toBeInTheDocument() + describe('if advanced search enabled is false', () => { + beforeEach(() => { + store.dispatch(setappConfig(mockAppConfig)) + }) + describe('on render', () => { + it('should render auto search when ADVANCED_SEARCH_ENABLED is false', () => { + setup() + expect(screen.getByText(/auto search/i)).toBeInTheDocument() + }) + }) + }) + describe('if advanced search enabled is true', () => { + beforeEach(() => { + vi.mock('../../utils/mapHelper') + vi.mock('../../utils/searchHelper') + const mockAppConfigSearchEnabled = { + ...mockAppConfig, + ADVANCED_SEARCH_ENABLED: true + } + store.dispatch(setappConfig(mockAppConfigSearchEnabled)) + }) + describe('on render', () => { + it('should not render auto search when ADVANCED_SEARCH_ENABLED is true', () => { + setup() + expect( + screen.queryByText('test_autoSearchContainer') + ).not.toBeInTheDocument() + }) + it('should not render disabled search bar overlay div', async () => { + setup() + expect( + screen.queryByTestId('test_disableSearchOverlay') + ).not.toBeInTheDocument() + }) + }) + describe('when search options changed', () => { + it('should set showAdvancedSearchOptions to false in redux', () => { + store.dispatch(setshowAdvancedSearchOptions(true)) + setup() + store.dispatch(setCloudCover(5)) + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + }) + }) + describe('when search button clicked', () => { + it('should set showAdvancedSearchOptions to false in redux', async () => { + store.dispatch(setshowAdvancedSearchOptions(true)) + setup() + const searchButton = screen.getByRole('button', { + name: /search/i + }) + await user.click(searchButton) + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + }) + }) + describe('when advanced clicked', () => { + it('should set showAdvancedSearchOptions be the opposite showAdvancedSearchOptions of in redux', async () => { + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + expect( + store.getState().mainSlice.showAdvancedSearchOptions + ).toBeTruthy() + await user.click(advancedButton) + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + }) + }) + describe('when draw boundary button clicked', () => { + it('should not call functions if geom already exists', async () => { + const spyEnableMapPolyDrawing = vi.spyOn( + mapHelper, + 'enableMapPolyDrawing' + ) + store.dispatch( + setsearchGeojsonBoundary({ + type: 'Polygon', + coordinates: [[]] + }) + ) + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const drawBoundaryButton = screen.getByRole('button', { + name: /draw boundary/i + }) + await user.click(drawBoundaryButton) + expect(spyEnableMapPolyDrawing).not.toHaveBeenCalled() + }) + it('should enter drawing state if geom does not exists', async () => { + const spyEnableMapPolyDrawing = vi.spyOn( + mapHelper, + 'enableMapPolyDrawing' + ) + store.dispatch(setshowAdvancedSearchOptions(true)) + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const drawBoundaryButton = screen.getByRole('button', { + name: /draw boundary/i + }) + await user.click(drawBoundaryButton) + expect(spyEnableMapPolyDrawing).toHaveBeenCalled() + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + expect(store.getState().mainSlice.isDrawingEnabled).toBeTruthy() + }) + }) + describe('when clear button clicked', () => { + it('should not call functions if geom does not exists', async () => { + const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const clearButton = screen.getByRole('button', { + name: /clear/i + }) + await user.click(clearButton) + expect(spyClearLayer).not.toHaveBeenCalled() + }) + it('should clear layer and close options if geom exists', async () => { + const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') + store.dispatch( + setsearchGeojsonBoundary({ + type: 'Polygon', + coordinates: [[]] + }) + ) + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const clearButton = screen.getByRole('button', { + name: /clear/i + }) + await user.click(clearButton) + expect(spyClearLayer).toHaveBeenCalled() + expect(store.getState().mainSlice.searchGeojsonBoundary).toBeNull() + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + }) + }) + describe('when upload geojson button clicked', () => { + it('should not call dispatch functions if geom already exists', async () => { + store.dispatch( + setsearchGeojsonBoundary({ + type: 'Polygon', + coordinates: [[]] + }) + ) + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const uploadGeojsonButton = screen.getByRole('button', { + name: /upload geojson/i + }) + await user.click(uploadGeojsonButton) + expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy() + }) + it('should call dispatch functions if geom does not exists', async () => { + store.dispatch(setshowAdvancedSearchOptions(true)) + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const uploadGeojsonButton = screen.getByRole('button', { + name: /upload geojson/i + }) + await user.click(uploadGeojsonButton) + expect(store.getState().mainSlice.showAdvancedSearchOptions).toBeFalsy() + expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy() + }) + }) + describe('when drawing mode enabled', () => { + it('should render disabled search bar overlay div', async () => { + setup() + const advancedButton = screen.getByText(/advanced/i) + await user.click(advancedButton) + const drawBoundaryButton = screen.getByRole('button', { + name: /draw boundary/i + }) + await user.click(drawBoundaryButton) + expect( + screen.queryByTestId('test_disableSearchOverlay') + ).toBeInTheDocument() + }) }) }) }) diff --git a/src/components/defaults.js b/src/components/defaults.js index 33712032..f2a8bdee 100644 --- a/src/components/defaults.js +++ b/src/components/defaults.js @@ -1,20 +1,8 @@ -import { - VITE_COLORMAP, - VITE_API_MAX_ITEMS, - VITE_MOSAIC_MAX_ITEMS, - VITE_MOSAIC_MIN_ZOOM_LEVEL -} from '../assets/config.js' - -export const MOSAIC_MIN_ZOOM = VITE_MOSAIC_MIN_ZOOM_LEVEL || 7 -export const APP_NAME = import.meta.env.VITE_APP_NAME || 'FilmDrop Console' -export const MOSAIC_MAX_ITEMS = VITE_MOSAIC_MAX_ITEMS || 100 -export const API_MAX_ITEMS = VITE_API_MAX_ITEMS || 200 +export const DEFAULT_MOSAIC_MIN_ZOOM = 7 +export const DEFAULT_MOSAIC_MAX_ITEMS = 100 +export const DEFAULT_API_MAX_ITEMS = 200 export const DEFAULT_MED_ZOOM = 4 export const DEFAULT_HIGH_ZOOM = 7 -export const COLORMAP = VITE_COLORMAP || 'viridis' -export const SearchTypes = Object.freeze({ - Scene: Symbol('scene'), - GridCode: Symbol('gridCode'), - GridCodeScenes: Symbol('gridCodeScene'), - GeoHex: Symbol('geoHex') -}) +export const DEFAULT_COLORMAP = 'viridis' +export const DEFAULT_APP_NAME = + import.meta.env.VITE_APP_NAME || 'FilmDrop Console' diff --git a/src/redux/slices/mainSlice.js b/src/redux/slices/mainSlice.js index 2caafed7..01d28b9e 100644 --- a/src/redux/slices/mainSlice.js +++ b/src/redux/slices/mainSlice.js @@ -1,5 +1,4 @@ import { createSlice } from '@reduxjs/toolkit' -import { VITE_DEFAULT_COLLECTION } from '../../assets/config.js' // this is the initial state values for the redux store // add to this for new state and set whatever default you want @@ -8,7 +7,6 @@ const initialState = { dateTime: [], cloudCover: 0, showCloudSlider: true, - selectedCollection: VITE_DEFAULT_COLLECTION || null, searchResults: null, clickResults: [], searchLoading: false, @@ -35,7 +33,8 @@ const initialState = { showUploadGeojsonModal: false, showApplicationAlert: false, applicationAlertMessage: 'System Error', - applicationAlertSeverity: 'error' + applicationAlertSeverity: 'error', + appConfig: null } // next, for every key in the initialState @@ -139,6 +138,9 @@ export const mainSlice = createSlice({ }, setapplicationAlertSeverity: (state, action) => { state.applicationAlertSeverity = action.payload + }, + setappConfig: (state, action) => { + state.appConfig = action.payload } } }) @@ -177,5 +179,6 @@ export const { setshowUploadGeojsonModal } = mainSlice.actions export const { setshowApplicationAlert } = mainSlice.actions export const { setapplicationAlertMessage } = mainSlice.actions export const { setapplicationAlertSeverity } = mainSlice.actions +export const { setappConfig } = mainSlice.actions export default mainSlice.reducer diff --git a/src/services/get-aggregate-service.js b/src/services/get-aggregate-service.js index 65b1bf75..7170d2f7 100644 --- a/src/services/get-aggregate-service.js +++ b/src/services/get-aggregate-service.js @@ -1,6 +1,5 @@ import { store } from '../redux/store' import { setSearchLoading, setSearchResults } from '../redux/slices/mainSlice' -import { VITE_STAC_API_URL } from '../assets/config' import { addDataToLayer, buildHexGridLayerOptions, @@ -9,9 +8,14 @@ import { import { mapHexGridFromJson, mapGridCodeFromJson } from '../utils/searchHelper' export async function AggregateSearchService(searchParams, gridType) { - await fetch(`${VITE_STAC_API_URL}/aggregate?${searchParams}`, { - method: 'GET' - }) + await fetch( + `${ + store.getState().mainSlice.appConfig.STAC_API_URL + }/aggregate?${searchParams}`, + { + method: 'GET' + } + ) .then((response) => { if (response.ok) { return response.json() diff --git a/src/services/get-aggregations-service.js b/src/services/get-aggregations-service.js index 9c3498cd..59b5cd1f 100644 --- a/src/services/get-aggregations-service.js +++ b/src/services/get-aggregations-service.js @@ -1,8 +1,10 @@ -import { VITE_STAC_API_URL } from '../assets/config' +import { store } from '../redux/store' export async function GetCollectionAggregationsService(collectionId) { return fetch( - `${VITE_STAC_API_URL}/collections/${collectionId}/aggregations`, + `${ + store.getState().mainSlice.appConfig.STAC_API_URL + }/collections/${collectionId}/aggregations`, { method: 'GET' } diff --git a/src/services/get-collections-service.js b/src/services/get-collections-service.js index 9f3b5132..632d4e83 100644 --- a/src/services/get-collections-service.js +++ b/src/services/get-collections-service.js @@ -3,13 +3,15 @@ import { setCollectionsData, setShowAppLoading } from '../redux/slices/mainSlice' -import { VITE_STAC_API_URL } from '../assets/config' import { buildCollectionsData, loadLocalGridData } from '../utils/dataHelper' export async function GetCollectionsService(searchParams) { - await fetch(`${VITE_STAC_API_URL}/collections`, { - method: 'GET' - }) + await fetch( + `${store.getState().mainSlice.appConfig.STAC_API_URL}/collections`, + { + method: 'GET' + } + ) .then((response) => { if (response.ok) { return response.json() diff --git a/src/services/get-config-service.js b/src/services/get-config-service.js new file mode 100644 index 00000000..b9c4d5bf --- /dev/null +++ b/src/services/get-config-service.js @@ -0,0 +1,24 @@ +import { store } from '../redux/store' +import { setappConfig } from '../redux/slices/mainSlice' +import { showApplicationAlert } from '../utils/alertHelper' + +export async function LoadConfigIntoStateService() { + await fetch(`/config/config.json`, { + method: 'GET' + }) + .then((response) => { + if (response.ok) { + return response.json() + } + throw new Error() + }) + .then((json) => { + store.dispatch(setappConfig(json)) + }) + .catch((error) => { + const message = 'Error Fetching Config File' + // log full error for diagnosing client side errors if needed + console.error(message, error) + showApplicationAlert('error', message, null) + }) +} diff --git a/src/services/get-queryables-service.js b/src/services/get-queryables-service.js index ca6d3129..c4fd1e3f 100644 --- a/src/services/get-queryables-service.js +++ b/src/services/get-queryables-service.js @@ -1,9 +1,14 @@ -import { VITE_STAC_API_URL } from '../assets/config' +import { store } from '../redux/store' export function GetCollectionQueryablesService(collectionId) { - return fetch(`${VITE_STAC_API_URL}/collections/${collectionId}/queryables`, { - method: 'GET' - }) + return fetch( + `${ + store.getState().mainSlice.appConfig.STAC_API_URL + }/collections/${collectionId}/queryables`, + { + method: 'GET' + } + ) .then((response) => { if (response.ok) { return response.json() diff --git a/src/services/get-search-service.js b/src/services/get-search-service.js index c0dde87f..2c51e996 100644 --- a/src/services/get-search-service.js +++ b/src/services/get-search-service.js @@ -5,13 +5,17 @@ import { setSearchResults, setShowPopupModal } from '../redux/slices/mainSlice' -import { VITE_STAC_API_URL } from '../assets/config' import { addDataToLayer, footprintLayerStyle } from '../utils/mapHelper' export async function SearchService(searchParams, typeOfSearch) { - await fetch(`${VITE_STAC_API_URL}/search?${searchParams}`, { - method: 'GET' - }) + await fetch( + `${ + store.getState().mainSlice.appConfig.STAC_API_URL + }/search?${searchParams}`, + { + method: 'GET' + } + ) .then((response) => { if (response.ok) { return response.json() diff --git a/src/services/post-mosaic-service.js b/src/services/post-mosaic-service.js index 2e4dfbb2..f1e1f2ff 100644 --- a/src/services/post-mosaic-service.js +++ b/src/services/post-mosaic-service.js @@ -1,10 +1,9 @@ import { store } from '../redux/store' import { setSearchLoading } from '../redux/slices/mainSlice' -import { VITE_MOSAIC_TILER_URL } from '../assets/config' import { addMosaicLayer } from '../utils/mapHelper' export async function AddMosaicService(reqParams) { - const mosaicTilerURL = VITE_MOSAIC_TILER_URL || '' + const mosaicTilerURL = store.getState().mainSlice.appConfig.MOSAIC_TILER_URL await fetch(`${mosaicTilerURL}/mosaicjson/mosaics`, reqParams) .then((response) => { if (response.ok) { diff --git a/src/testing/shared-mocks.js b/src/testing/shared-mocks.js index 74f1e190..b5270f0a 100644 --- a/src/testing/shared-mocks.js +++ b/src/testing/shared-mocks.js @@ -3442,3 +3442,109 @@ export const mockSceneSearchResult = { } ] } + +export const mockAppConfig = { + PUBLIC_URL: 'http://example.com/', + LOGO_URL: './logo.png', + LOGO_ALT: 'Alt description for my custom logo', + SHOW_PUBLISH_BTN: false, + DEFAULT_COLLECTION: 'collection-name', + STAC_API_URL: 'https://api-endpoint.example.com', + API_MAX_ITEMS: 200, + SCENE_TILER_URL: 'https://titiler.example.com', + DASHBOARD_BTN_URL: 'https://dashboard.example.com', + ANALYZE_BTN_URL: 'https://dashboard.example.com', + CF_TEMPLATE_URL: 'https://cf-template.example.com', + SCENE_TILER_PARAMS: { + 'sentinel-2-l2a': { + assets: ['red', 'green', 'blue'], + color_formula: 'Gamma+RGB+3.2+Saturation+0.8+Sigmoidal+RGB+12+0.35' + }, + 'landsat-c2-l2': { + assets: ['red', 'green', 'blue'], + color_formula: 'Gamma+RGB+1.7+Saturation+1.7+Sigmoidal+RGB+15+0.35' + }, + naip: { + assets: ['image'], + bidx: '1,2,3' + }, + 'cop-dem-glo-30': { + assets: ['data'], + colormap_name: 'terrain', + rescale: ['-1000,4000'] + }, + 'cop-dem-glo-90': { + assets: ['data'], + colormap_name: 'terrain', + rescale: ['-1000,4000'] + }, + 'sentinel-1-grd': { + assets: ['vv'], + rescale: ['0,250'], + colormap_name: 'plasma' + } + }, + MOSAIC_TILER_URL: 'https://titiler-mosaic.example.com', + MOSAIC_TILER_PARAMS: { + 'sentinel-2-l2a': { + assets: ['visual'] + }, + 'landsat-c2-l2': { + assets: ['red'], + color_formula: 'Gamma+R+1.7+Sigmoidal+R+15+0.35' + }, + naip: { + assets: ['image'], + bidx: '1,2,3' + }, + 'cop-dem-glo-30': { + assets: ['data'], + colormap_name: 'terrain', + rescale: ['-1000,4000'] + }, + 'cop-dem-glo-90': { + assets: ['data'], + colormap_name: 'terrain', + rescale: ['-1000,4000'] + }, + 'sentinel-1-grd': { + assets: ['vv'], + rescale: ['0,250'], + colormap_name: 'plasma' + } + }, + MOSAIC_MAX_ITEMS: 100, + MOSAIC_MIN_ZOOM_LEVEL: 7, + CONFIG_COLORMAP: 'viridis', + SEARCH_MIN_ZOOM_LEVELS: { + 'sentinel-2-l2a': { + medium: 4, + high: 7 + }, + 'landsat-c2-l2': { + medium: 4, + high: 7 + }, + naip: { + medium: 10, + high: 14 + }, + 'cop-dem-glo-30': { + medium: 6, + high: 8 + }, + 'cop-dem-glo-90': { + medium: 6, + high: 8 + }, + 'sentinel-1-grd': { + medium: 7, + high: 7 + } + }, + BASEMAP_URL: 'https://tile-provider.example.com/{z}/{x}/{y}.png', + BASEMAP_HTML_ATTRIBUTION: + '© TileProvider', + ADVANCED_SEARCH_ENABLED: false, + CART_ENABLED: false +} diff --git a/src/utils/colorMap.js b/src/utils/colorMap.js index 5850198d..486387d5 100644 --- a/src/utils/colorMap.js +++ b/src/utils/colorMap.js @@ -1,9 +1,11 @@ -import { COLORMAP } from '../components/defaults' +import { DEFAULT_COLORMAP } from '../components/defaults' import colormap from 'colormap' +import { store } from '../redux/store' export const colorMap = (largestRatio) => { return colormap({ - colormap: COLORMAP, + colormap: + store.getState().mainSlice.appConfig.CONFIG_COLORMAP || DEFAULT_COLORMAP, nshades: Math.round(Math.max(9, largestRatio)), format: 'hex' }) diff --git a/src/utils/mapHelper.js b/src/utils/mapHelper.js index 7f43bb2a..2e25b972 100644 --- a/src/utils/mapHelper.js +++ b/src/utils/mapHelper.js @@ -12,14 +12,9 @@ import { } from '../redux/slices/mainSlice' import { searchGridCodeScenes, debounceNewSearch } from './searchHelper' import debounce from './debounce' -import { - VITE_SCENE_TILER_URL, - VITE_SCENE_TILER_PARAMS, - VITE_MOSAIC_MIN_ZOOM_LEVEL, - VITE_MOSAIC_TILER_PARAMS -} from '../assets/config' import { GetMosaicBoundsService } from '../services/get-mosaic-bounds' import GeoJSONValidation from './geojsonValidation' +import { DEFAULT_MOSAIC_MIN_ZOOM } from '../components/defaults' export const footprintLayerStyle = { color: '#3183f5', @@ -298,7 +293,8 @@ export function mapCallDebounceNewSearch() { export const debounceTitilerOverlay = debounce(() => addImageOverlay(), 800) function addImageOverlay() { - const sceneTilerURL = VITE_SCENE_TILER_URL || '' + const sceneTilerURL = + store.getState().mainSlice.appConfig.SCENE_TILER_URL || '' const _currentPopupResult = store.getState().mainSlice.currentPopupResult const _selectedCollectionData = store.getState().mainSlice.selectedCollectionData @@ -346,7 +342,6 @@ function addImageOverlay() { } } else { store.dispatch(setSearchLoading(false)) - console.log('VITE_SCENE_TILER_URL is not set in env variables.') } }) } @@ -364,7 +359,8 @@ function setupBounds(bbox) { } const constructSceneTilerParams = (collection) => { - const envSceneTilerParams = VITE_SCENE_TILER_PARAMS || '' + const envSceneTilerParams = + store.getState().mainSlice.appConfig.SCENE_TILER_PARAMS || '' // retrieve mosaic tiler parameters from env variable const tilerParams = getTilerParams(envSceneTilerParams) @@ -448,7 +444,9 @@ const parameters = { export function setMosaicZoomMessage() { const map = store.getState().mainSlice.map if (map && Object.keys(map).length > 0) { - const MOSAIC_MIN_ZOOM = VITE_MOSAIC_MIN_ZOOM_LEVEL || 7 + const MOSAIC_MIN_ZOOM = + store.getState().mainSlice.appConfig.MOSAIC_MIN_ZOOM_LEVEL || + DEFAULT_MOSAIC_MIN_ZOOM if ( map.getZoom() >= MOSAIC_MIN_ZOOM || store.getState().mainSlice.viewMode === 'scene' @@ -461,7 +459,8 @@ export function setMosaicZoomMessage() { } export const constructMosaicTilerParams = (collection) => { - const mosaicTilerParams = VITE_MOSAIC_TILER_PARAMS || '' + const mosaicTilerParams = + store.getState().mainSlice.appConfig.MOSAIC_TILER_PARAMS || '' // retrieve mosaic tiler parameters from env variable const tilerParams = getTilerParams(mosaicTilerParams) diff --git a/src/utils/searchHelper.js b/src/utils/searchHelper.js index 46da90d4..9cc2dc62 100644 --- a/src/utils/searchHelper.js +++ b/src/utils/searchHelper.js @@ -1,12 +1,10 @@ import { store } from '../redux/store' import { - VITE_SEARCH_MIN_ZOOM_LEVELS, - VITE_API_MAX_ITEMS, - VITE_STAC_API_URL, - VITE_MOSAIC_MAX_ITEMS, - VITE_MOSAIC_TILER_PARAMS -} from '../assets/config' -import { DEFAULT_MED_ZOOM, DEFAULT_HIGH_ZOOM } from '../components/defaults' + DEFAULT_MED_ZOOM, + DEFAULT_HIGH_ZOOM, + DEFAULT_API_MAX_ITEMS, + DEFAULT_MOSAIC_MAX_ITEMS +} from '../components/defaults' import { getCurrentMapZoomLevel, clearAllLayers, @@ -38,12 +36,14 @@ export function newSearch() { const _selectedCollection = store.getState().mainSlice.selectedCollectionData const midZoomLevel = - VITE_SEARCH_MIN_ZOOM_LEVELS[_selectedCollection.id]?.medium || - DEFAULT_MED_ZOOM + store.getState().mainSlice.appConfig.SEARCH_MIN_ZOOM_LEVELS[ + _selectedCollection.id + ]?.medium || DEFAULT_MED_ZOOM const highZoomLevel = - VITE_SEARCH_MIN_ZOOM_LEVELS[_selectedCollection.id]?.high || - DEFAULT_HIGH_ZOOM + store.getState().mainSlice.appConfig.SEARCH_MIN_ZOOM_LEVELS[ + _selectedCollection.id + ]?.high || DEFAULT_HIGH_ZOOM const currentMapZoomLevel = getCurrentMapZoomLevel() @@ -104,7 +104,8 @@ function buildSearchScenesParams(gridCodeToSearchIn) { const _dateTimeRange = convertDateForURL( store.getState().mainSlice.searchDateRangeValue ) - const limit = VITE_API_MAX_ITEMS || 200 + const limit = + store.getState().mainSlice.appConfig.API_MAX_ITEMS || DEFAULT_API_MAX_ITEMS const collections = _selectedCollection.id const _searchGeojsonBoundary = store.getState().mainSlice.searchGeojsonBoundary @@ -423,11 +424,13 @@ function newMosaicSearch() { const bboxFromMap = bboxFromMapBounds() const createMosaicBody = { - stac_api_root: VITE_STAC_API_URL, + stac_api_root: store.getState().mainSlice.appConfig.STAC_API_URL, asset_name: constructMosaicAssetVal(_selectedCollectionData.id), collections: [_selectedCollectionData.id], datetime, - max_items: VITE_MOSAIC_MAX_ITEMS || 100 + max_items: + store.getState().mainSlice.appConfig.MOSAIC_MAX_ITEMS || + DEFAULT_MOSAIC_MAX_ITEMS } if (_searchGeojsonBoundary) { createMosaicBody.intersects = _searchGeojsonBoundary.geometry @@ -456,7 +459,8 @@ function newMosaicSearch() { } const constructMosaicAssetVal = (collection) => { - const envMosaicTilerParams = VITE_MOSAIC_TILER_PARAMS || '' + const envMosaicTilerParams = + store.getState().mainSlice.appConfig.MOSAIC_TILER_PARAMS || '' const asset = getTilerParams(envMosaicTilerParams)[collection]?.assets || '' if (!asset) { console.log(`Assets not defined for ${collection}`)