├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml ├── pull_request_template.md └── workflows │ └── push.yaml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .remarkignore ├── ADRs ├── adr_config_refactor_11.8.23.md └── adr_design_refactor_11.2.23.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config_helper ├── config.example.json └── lint_config.py ├── generate-react-cli.json ├── index.html ├── package-lock.json ├── package.json ├── public ├── ThumbnailNotAvailable.png ├── config │ └── .gitkeep ├── data │ ├── cdem.json │ ├── doqq.json │ ├── mgrs.json │ └── wrs2.json ├── favicon.ico ├── logo.png ├── manifest.json ├── marker-icon.png ├── marker-shadow.png └── robots.txt ├── screenshots ├── cop-dem-hex.jpg ├── cop-dem-mosaic.jpg ├── naip-grid.jpg ├── naip-scene.jpg ├── s1-hex.jpg ├── s1-scene.jpg ├── s2-l2a-grid.jpg └── s2-l2a-mosaic.jpg ├── src ├── App.css ├── App.jsx ├── App.test.jsx ├── assets │ ├── cloudFormationTemplate.png │ ├── icon-copy.svg │ ├── icon-external-link.svg │ ├── logo-filmdrop-e84.png │ └── logo-filmdrop-white.svg ├── components │ ├── Cart │ │ ├── CartButton │ │ │ ├── CartButton.css │ │ │ └── CartButton.jsx │ │ └── CartModal │ │ │ ├── CartModal.css │ │ │ ├── CartModal.jsx │ │ │ └── CartModal.test.jsx │ ├── CloudSlider │ │ ├── CloudSlider.css │ │ └── CloudSlider.jsx │ ├── CollectionDropdown │ │ ├── CollectionDropdown.css │ │ ├── CollectionDropdown.jsx │ │ └── CollectionDropdown.test.jsx │ ├── DateTimeRangeSelector │ │ ├── DateTimeRangeSelector.css │ │ ├── DateTimeRangeSelector.jsx │ │ └── react-calendar-overrides.css │ ├── ExportButton │ │ ├── ExportButton.css │ │ └── ExportButton.jsx │ ├── LayerList │ │ ├── LayerList.css │ │ └── LayerList.jsx │ ├── Layout │ │ ├── Content │ │ │ ├── Content.css │ │ │ ├── Content.jsx │ │ │ ├── LeftContent │ │ │ │ ├── LeftContent.css │ │ │ │ ├── LeftContent.jsx │ │ │ │ └── LeftContent.test.jsx │ │ │ └── RightContent │ │ │ │ ├── RightContent.css │ │ │ │ ├── RightContent.jsx │ │ │ │ └── RightContent.test.jsx │ │ └── PageHeader │ │ │ ├── PageHeader.css │ │ │ ├── PageHeader.jsx │ │ │ └── PageHeader.test.jsx │ ├── LeafMap │ │ ├── LeafMap.css │ │ └── LeafMap.jsx │ ├── Legend │ │ ├── HeatMapSymbology │ │ │ ├── HeatMapSymbology.css │ │ │ └── HeatMapSymbology.jsx │ │ └── LayerLegend │ │ │ ├── LayerLegend.css │ │ │ ├── LayerLegend.jsx │ │ │ └── LayerLegend.test.jsx │ ├── LoadingAnimation │ │ ├── LoadingAnimation.css │ │ └── LoadingAnimation.jsx │ ├── Login │ │ ├── Login.css │ │ └── Login.jsx │ ├── PopupResult │ │ ├── PopupResult.css │ │ ├── PopupResult.jsx │ │ └── PopupResult.test.jsx │ ├── PopupResults │ │ ├── PopupResults.css │ │ ├── PopupResults.jsx │ │ └── PopupResults.test.jsx │ ├── Search │ │ ├── Search.css │ │ ├── Search.jsx │ │ └── Search.test.jsx │ ├── SystemMessage │ │ ├── SystemMessage.css │ │ ├── SystemMessage.jsx │ │ └── SystemMessage.test.jsx │ ├── UploadGeojsonModal │ │ ├── UploadGeojsonModal.css │ │ ├── UploadGeojsonModal.jsx │ │ └── UploadGeojsonModal.test.jsx │ ├── ViewSelector │ │ ├── ViewSelector.css │ │ └── ViewSelector.jsx │ └── defaults.js ├── index.css ├── index.jsx ├── redux │ ├── slices │ │ └── mainSlice.js │ └── store.js ├── services │ ├── get-aggregate-service.js │ ├── get-aggregations-service.js │ ├── get-all-scenes-service.js │ ├── get-collections-service.js │ ├── get-config-service.js │ ├── get-local-grid-data-json-service.js │ ├── get-mosaic-bounds.js │ ├── get-queryables-service.js │ ├── get-search-service.js │ ├── post-auth-service.js │ └── post-mosaic-service.js ├── setupTests.js ├── testing │ └── shared-mocks.js └── utils │ ├── alertHelper.js │ ├── alertHelper.test.js │ ├── authHelper.js │ ├── colorMap.js │ ├── configHelper.js │ ├── configHelper.test.js │ ├── dataHelper.js │ ├── dataHelper.test.js │ ├── datetime.js │ ├── debounce.js │ ├── geojsonValidation.js │ ├── geojsonValidation.test.js │ ├── index.js │ ├── mapHelper.js │ └── searchHelper.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.mts /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/** 2 | **/build/** 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | settings: { 4 | react: { 5 | version: 'detect' 6 | } 7 | }, 8 | env: { 9 | browser: true, 10 | es2021: true, 11 | jest: true 12 | }, 13 | extends: [ 14 | 'plugin:react/recommended', 15 | 'plugin:jsx-a11y/recommended', 16 | 'standard', 17 | 'prettier' 18 | ], 19 | overrides: [ 20 | { 21 | // 3) Now we enable eslint-plugin-testing-library rules or preset only for matching testing files! 22 | files: ['**/src/?(*.)+test.[jt]s?(x)'], 23 | extends: ['plugin:testing-library/react'] 24 | } 25 | ], 26 | parserOptions: { 27 | ecmaVersion: 'latest', 28 | sourceType: 'module' 29 | }, 30 | plugins: ['react'], 31 | rules: {} 32 | } 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' # See documentation for possible values 4 | directory: '/' # Location of package manifests 5 | schedule: 6 | interval: 'weekly' 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Related Issue(s):** 2 | 3 | - # 4 | 5 | **Proposed Changes:** 6 | 7 | 1. 8 | 2. 9 | 10 | **PR Checklist:** 11 | 12 | - [ ] I have added my changes to the [CHANGELOG](https://github.com/Element84/filmdrop-ui/blob/main/CHANGELOG.md) **or** a CHANGELOG entry is not required. 13 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: FilmDrop UI CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - 'feature/**' 7 | pull_request: 8 | branches: 9 | - '**' 10 | jobs: 11 | test: 12 | name: Run static analysis and tests 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version-file: .nvmrc 19 | cache: npm 20 | cache-dependency-path: package.json 21 | - name: Upgrade npm 22 | run: npm install -g npm@latest 23 | - name: Install dependencies 24 | run: npm ci 25 | - name: Run Markdown checks 26 | run: npm run check-markdown 27 | - name: Run format 28 | run: npm run format 29 | - name: Run eslint 30 | run: npm run lint 31 | # - name: Typecheck 32 | # run: npm run typecheck 33 | - name: Run audit (all, with exclusions) 34 | run: npm run audit-all 35 | - name: Run audit (prod, no exclusions) 36 | run: npm run audit-prod 37 | - name: Copy config file 38 | run: mkdir -p ./public/config && cp config_helper/config.example.json ./public/config/config.json 39 | - name: Run unit tests 40 | run: npm run test 41 | - name: Run dev build 42 | run: npm run build 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | /public/config/config*.json 4 | /public/config/*.png 5 | /public/config/*.ico 6 | !/public/config/config.example.json 7 | 8 | # dependencies 9 | /node_modules 10 | /.pnp 11 | .pnp.js 12 | 13 | # testing 14 | /coverage 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | 5 | data/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | /ADRs 2 | /.github 3 | -------------------------------------------------------------------------------- /ADRs/adr_design_refactor_11.2.23.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | --- 4 | 5 | status: proposed 6 | date: 2022-11-07 7 | deciders: Brad Andrick, Matt Hanson, Phil Varner 8 | 9 | --- 10 | 11 | # AD: Modular Architecture Improvement 12 | 13 | ## Context and Problem Statement 14 | 15 | This originated from a discussion after some brainstorming for how we might be able to fit a time slider component into the console. The problem that became apparent was the design of the UI was reaching it’s limits of adding new features. At the time the original app was created, it was supposed to show “with filmdrop you could build something like this” but has since changed into a fully configurable console that is intended to be configured and used by anyone with a STAC API as part of the FilmDrop Ecosystem. Based on this, and the need to keep adding new features to give clients value, a new design will be mocked that considers current Console needs as well as future extensibility while also improving and polishing the application to be fully responsive. Finally, there will be a consideration of application architecture to have the console utilize a side navigation to allow embedding/combing multiple applications into a single console app (so bringing in the dashboard, analytics, and others without those just being links to separate apps that open in a new tab). 16 | 17 | ## Decision Drivers 18 | 19 | - Desire for the application to be able to add new features without bumping up against limitations of the legacy design 20 | - Ability for the project to be extended and modified easily beyond the core components and functionality while not impacting or minimizing impact to the core design 21 | - Community knowledge of patterns and tooling used must align with the goal of being easily understandable by potential contributors or people configuring it for their own use 22 | - Should reuse existing filmdrop-ui application design, build patterns, and configuration files as much as possible to minimize major breaks with existing applications and deployment processes 23 | 24 | ## Considered Options 25 | 26 | 1. Move to constructing the app from a new shared library of components 27 | 2. Micro-frontends with module federation architecture 28 | 3. Keep the current design as a single application and use the existing pattern of just linking to external applications 29 | 4. Nest core applications within the console and document that pattern so it can be reused by others for adding applications outside of the core apps 30 | 31 | ## Decision Outcome 32 | 33 | We decided on option 4: to nest core applications within the console and update directory structure and design to isolate each application's core (with a mechanism for sharing some state between apps) and to be documented with guidelines for extending the console beyond the core apps/functionality. This decision was made because it allows for the most flexibility in how the console is configured and extended. Additionally, it maintains the majority of the same design and patterns as the current console and it does not use uncommon patterns or technologies therefore allowing for easier adoption by other teams. It was determined to be the best combination of refactoring for scalability while also maintaining a fast velocity of development. 34 | 35 | ### Consequences 36 | 37 | - Good: 38 | - most of the core code can be reused from the existing console. 39 | - the console can be extended to add new applications and features without having to rewrite the core code. 40 | - it does not introduce new patterns or uncommon technologies. 41 | - it minimizes the time spent to migrate the existing console to the new design. 42 | - it makes changes to core applications less likely to break a forked console when those changes to core are merged into the fork. 43 | - Bad: 44 | - developers will need documentation on how to extend the console beyond the core apps/functionality. so more work to maintain for the console team. 45 | - the existing console was made using a build once, deploy anywhere approach. This means that the console will need to be rebuilt and redeployed to support new applications. 46 | - configuration file(s) and global state will likely need to be updated to support the new applications. 47 | - applications will be tightly coupled with the tech stack chosen for the console. and new applications will need to be built using the same tech stack (react, redux, vite, etc) as the console. 48 | - this pattern may encourage developers to fork the repo to make customizations in isolation, which could lead to a fork that is not maintained or updated with changes to the core applications. 49 | - it overall adds complexity to the console architecture and design. 50 | 51 | 52 | -------------------------------------------------------------------------------- /config_helper/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "PUBLIC_URL": "http://example.com/", 3 | "LOGO_URL": "./logo.png", 4 | "LOGO_ALT": "Alt description for my custom logo", 5 | "DEFAULT_COLLECTION": "collection-name", 6 | "STAC_API_URL": "https://api-endpoint.example.com", 7 | "STAC_LINK_ENABLED": true, 8 | "SHOW_ITEM_AUTO_ZOOM": true, 9 | "API_MAX_ITEMS": 200, 10 | "SCENE_TILER_URL": "https://titiler.example.com", 11 | "DASHBOARD_BTN_URL": "https://dashboard.example.com", 12 | "ANALYZE_BTN_URL": "https://dashboard.example.com", 13 | "ACTION_BUTTON": { 14 | "text": "Action Text Here", 15 | "url": "https://redirect-url.example.com" 16 | }, 17 | "SCENE_TILER_PARAMS": { 18 | "sentinel-2-l2a": { 19 | "assets": ["red", "green", "blue"], 20 | "color_formula": "Gamma+RGB+3.2+Saturation+0.8+Sigmoidal+RGB+12+0.35" 21 | }, 22 | "landsat-c2-l2": { 23 | "assets": ["red", "green", "blue"], 24 | "color_formula": "Gamma+RGB+1.7+Saturation+1.7+Sigmoidal+RGB+15+0.35" 25 | }, 26 | "naip": { 27 | "assets": ["image"], 28 | "bidx": "1,2,3" 29 | }, 30 | "cop-dem-glo-30": { 31 | "assets": ["data"], 32 | "colormap_name": "terrain", 33 | "rescale": ["-1000,4000"] 34 | }, 35 | "cop-dem-glo-90": { 36 | "assets": ["data"], 37 | "colormap_name": "terrain", 38 | "rescale": ["-1000,4000"] 39 | }, 40 | "sentinel-1-grd": { 41 | "assets": ["vv"], 42 | "rescale": ["0,250"], 43 | "colormap_name": "plasma" 44 | }, 45 | "io-10m-annual-lulc": { 46 | "assets": ["supercell"], 47 | "colormap": { 48 | "0": "#000000", 49 | "1": "#419bdf", 50 | "2": "#397d49", 51 | "3": "#000000", 52 | "4": "#7a87c6", 53 | "5": "#e49635", 54 | "6": "#000000", 55 | "7": "#c4281b", 56 | "8": "#a59b8f", 57 | "9": "#a8ebff", 58 | "10": "#616161", 59 | "11": "#e3e2c3" 60 | } 61 | } 62 | }, 63 | "MOSAIC_TILER_URL": "https://titiler-mosaic.example.com", 64 | "MOSAIC_TILER_PARAMS": { 65 | "sentinel-2-l2a": { 66 | "assets": ["visual"] 67 | }, 68 | "landsat-c2-l2": { 69 | "assets": ["red"], 70 | "color_formula": "Gamma+R+1.7+Sigmoidal+R+15+0.35" 71 | }, 72 | "naip": { 73 | "assets": ["image"], 74 | "bidx": "1,2,3" 75 | }, 76 | "cop-dem-glo-30": { 77 | "assets": ["data"], 78 | "colormap_name": "terrain", 79 | "rescale": ["-1000,4000"] 80 | }, 81 | "cop-dem-glo-90": { 82 | "assets": ["data"], 83 | "colormap_name": "terrain", 84 | "rescale": ["-1000,4000"] 85 | }, 86 | "sentinel-1-grd": { 87 | "assets": ["vv"], 88 | "rescale": ["0,250"], 89 | "colormap_name": "plasma" 90 | }, 91 | "io-10m-annual-lulc": { 92 | "assets": ["supercell"], 93 | "colormap": { 94 | "0": "#000000", 95 | "1": "#419bdf", 96 | "2": "#397d49", 97 | "3": "#000000", 98 | "4": "#7a87c6", 99 | "5": "#e49635", 100 | "6": "#000000", 101 | "7": "#c4281b", 102 | "8": "#a59b8f", 103 | "9": "#a8ebff", 104 | "10": "#616161", 105 | "11": "#e3e2c3" 106 | } 107 | } 108 | }, 109 | "MOSAIC_MAX_ITEMS": 100, 110 | "MOSAIC_MIN_ZOOM_LEVEL": 7, 111 | "CONFIG_COLORMAP": "viridis", 112 | "SEARCH_MIN_ZOOM_LEVELS": { 113 | "sentinel-2-l2a": { 114 | "medium": 4, 115 | "high": 7 116 | }, 117 | "landsat-c2-l2": { 118 | "medium": 4, 119 | "high": 7 120 | }, 121 | "naip": { 122 | "medium": 10, 123 | "high": 14 124 | }, 125 | "cop-dem-glo-30": { 126 | "medium": 6, 127 | "high": 8 128 | }, 129 | "cop-dem-glo-90": { 130 | "medium": 6, 131 | "high": 8 132 | }, 133 | "sentinel-1-grd": { 134 | "medium": 7, 135 | "high": 7 136 | } 137 | }, 138 | "BASEMAP_URL": "https://tile-provider.example.com/{z}/{x}/{y}.png", 139 | "BASEMAP_DARK_THEME": true, 140 | "BASEMAP_HTML_ATTRIBUTION": "© TileProvider", 141 | "SEARCH_BY_GEOM_ENABLED": false, 142 | "CART_ENABLED": false, 143 | "SHOW_BRAND_LOGO": true, 144 | "POPUP_DISPLAY_FIELDS": { 145 | "sentinel-2-l2a": ["datetime", "platform", "eo:cloud_cover"], 146 | "sentinel-2-l1c": ["datetime", "platform", "eo:cloud_cover"], 147 | "landsat-c2-l2": ["datetime", "platform", "instruments", "eo:cloud_cover"], 148 | "naip": ["datetime", "naip:state", "naip:year", "gsd"], 149 | "cop-dem-glo-30": ["datetime"], 150 | "cop-dem-glo-90": ["datetime"], 151 | "sentinel-1-grd": [ 152 | "datetime", 153 | "platform", 154 | "sar:instrument_mode", 155 | "sar:polarizations" 156 | ] 157 | }, 158 | "APP_NAME": "Filmdrop Console", 159 | "APP_FAVICON": "exampleFavicon.ico", 160 | "MAP_ZOOM": 3, 161 | "MAP_CENTER": [30, 0], 162 | "LAYER_LIST_ENABLED": true, 163 | "LAYER_LIST_SERVICES": [ 164 | { 165 | "name": "Service 1", 166 | "type": "wms", 167 | "url": "https://sampleservice1.com/wms", 168 | "layers": [ 169 | { 170 | "name": "layer1_name", 171 | "alias": "Layer 1 Alias", 172 | "default_visibility": true, 173 | "crs": "EPSG:4326" 174 | }, 175 | { 176 | "name": "layer2_name", 177 | "alias": "Layer 2 Alias", 178 | "default_visibility": false 179 | } 180 | ] 181 | }, 182 | { 183 | "name": "Service 2", 184 | "type": "wms", 185 | "url": "https://sampleservice2.com/wms", 186 | "layers": [ 187 | { 188 | "name": "layer1_name", 189 | "alias": "Layer 1 Alias", 190 | "default_visibility": true 191 | } 192 | ] 193 | } 194 | ], 195 | "COLLECTIONS": ["naip", "cop-dem-glo-30", "sentinel-2-l2a"], 196 | "APP_TOKEN_AUTH_ENABLED": true, 197 | "AUTH_URL": "https://sample-auth-service/login", 198 | "SUPPORTS_AGGREGATIONS": false, 199 | "EXPORT_ENABLED": true 200 | } 201 | -------------------------------------------------------------------------------- /config_helper/lint_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Usage: python3 lint_config.py path/to/config.json 3 | 4 | Purpose: Lints a config.json configuration file used by a deployment of the Filmdrop UI application (https://github.com/Element84/filmdrop-ui). 5 | Checks for missing required keys, extra keys, type errors, and optional keys not included. 6 | 7 | Supported console version this works for: 5.0.0 8 | """ 9 | 10 | import sys 11 | import json 12 | import os 13 | 14 | def lint_config(file_path): 15 | # Read the config file 16 | try: 17 | with open(file_path, 'r') as file: 18 | config = json.load(file) 19 | except FileNotFoundError: 20 | print(f"File not found: {file_path}") 21 | sys.exit(1) 22 | except json.JSONDecodeError as e: 23 | print(f"Error parsing JSON: {e}") 24 | sys.exit(1) 25 | 26 | # Define the expected keys and their types 27 | expected_keys = { 28 | "STAC_API_URL": str, 29 | "PUBLIC_URL": str, 30 | "LOGO_URL": str, 31 | "LOGO_ALT": str, 32 | "DASHBOARD_BTN_URL": str, 33 | "ANALYZE_BTN_URL": str, 34 | "API_MAX_ITEMS": int, 35 | "DEFAULT_COLLECTION": str, 36 | "COLLECTIONS": list, 37 | "SCENE_TILER_URL": str, 38 | "SCENE_TILER_PARAMS": dict, 39 | "MOSAIC_MIN_ZOOM_LEVEL": int, 40 | "ACTION_BUTTON": dict, 41 | "MOSAIC_TILER_URL": str, 42 | "MOSAIC_TILER_PARAMS": dict, 43 | "MOSAIC_MAX_ITEMS": int, 44 | "SEARCH_MIN_ZOOM_LEVELS": dict, 45 | "CONFIG_COLORMAP": str, 46 | "BASEMAP_URL": str, 47 | "BASEMAP_DARK_THEME": bool, 48 | "BASEMAP_HTML_ATTRIBUTION": str, 49 | "SEARCH_BY_GEOM_ENABLED": bool, 50 | "CART_ENABLED": bool, 51 | "SHOW_BRAND_LOGO": bool, 52 | "POPUP_DISPLAY_FIELDS": dict, 53 | "APP_NAME": str, 54 | "APP_FAVICON": str, 55 | "MAP_ZOOM": int, 56 | "MAP_CENTER": list, 57 | "LAYER_LIST_ENABLED": bool, 58 | "LAYER_LIST_SERVICES": list, 59 | "STAC_LINK_ENABLED": bool, 60 | "SHOW_ITEM_AUTO_ZOOM": bool, 61 | "FETCH_CREDENTIALS": str, 62 | "APP_TOKEN_AUTH_ENABLED": bool, 63 | "AUTH_URL": str, 64 | "SUPPORTS_AGGREGATIONS": bool 65 | } 66 | 67 | print("*********************************************************************") 68 | print("**************** Running Filmdrop UI Config Lint ********************") 69 | print("*********************************************************************") 70 | 71 | # Check for missing required keys 72 | required_keys = ["STAC_API_URL","SEARCH_MIN_ZOOM_LEVELS"] 73 | missing_required_keys = [key for key in required_keys if key not in config] 74 | if missing_required_keys: 75 | print("Required key(s) missing:") 76 | for key in missing_required_keys: 77 | print(f" - {key}") 78 | print("************************************") 79 | 80 | # Check for extra keys that can't be used 81 | extra_keys = [key for key in config.keys() if key not in expected_keys] 82 | if extra_keys: 83 | print("Extra key(s) found that can't be used:") 84 | for key in extra_keys: 85 | print(f" - {key}") 86 | print("************************************") 87 | 88 | # Check for optional keys not included 89 | optional_keys = [key for key in expected_keys.keys() if key not in config] 90 | if optional_keys: 91 | print("Optional key(s) not included:") 92 | for key in optional_keys: 93 | print(f" - {key}") 94 | print("************************************") 95 | 96 | # Check for type errors 97 | for key, expected_type in expected_keys.items(): 98 | if key in config and not isinstance(config[key], expected_type): 99 | print(f"Type error for key '{key}': expected {expected_type.__name__}, got {type(config[key]).__name__}") 100 | print("************************************") 101 | 102 | # Perform additional validations as needed 103 | 104 | # If everything looks good 105 | if not missing_required_keys and not extra_keys: 106 | print("Configuration looks good!") 107 | 108 | if __name__ == "__main__": 109 | # Get the file path from command line arguments 110 | if len(sys.argv) != 2: 111 | print("Usage: ./lint_config.py path/to/config.json") 112 | sys.exit(1) 113 | 114 | file_path = sys.argv[1] 115 | 116 | # Check if the file is a JSON file 117 | if not file_path.endswith(".json"): 118 | print("Invalid file format. Expected a JSON file.") 119 | sys.exit(1) 120 | 121 | # Check if the file exists 122 | if not os.path.exists(file_path): 123 | print(f"File not found: {file_path}") 124 | sys.exit(1) 125 | 126 | # Lint the config file 127 | lint_config(file_path) 128 | -------------------------------------------------------------------------------- /generate-react-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "usesTypeScript": false, 3 | "usesCssModule": false, 4 | "cssPreprocessor": "css", 5 | "testLibrary": "Testing Library", 6 | "component": { 7 | "default": { 8 | "path": "src/components", 9 | "withStyle": true, 10 | "withTest": true, 11 | "withStory": false, 12 | "withLazy": false 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Loading... 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "filmdrop-ui", 3 | "version": "5.7.1", 4 | "license": "Apache-2.0", 5 | "dependencies": { 6 | "@emotion/react": "^11.11.4", 7 | "@emotion/styled": "^11.11.5", 8 | "@mui/icons-material": "^5.15.17", 9 | "@mui/material": "^5.15.17", 10 | "@reduxjs/toolkit": "^2.2.3", 11 | "@vitejs/plugin-react": "^4.2.1", 12 | "colormap": "^2.3.2", 13 | "dayjs": "^1.11.11", 14 | "dayjs-plugin-utc": "^0.1.2", 15 | "dompurify": "^3.1.2", 16 | "h3-js": "^4.1.0", 17 | "leaflet": "^1.9.4", 18 | "leaflet-draw": "^1.0.4", 19 | "leaflet-geosearch": "^3.9.0", 20 | "react": "^18.2.0", 21 | "react-datepicker": "^6.1.0", 22 | "react-dom": "^18.2.0", 23 | "react-dropzone": "^14.2.3", 24 | "react-leaflet": "^4.2.1", 25 | "react-redux": "^9.1.0", 26 | "react-tooltip": "^5.26.3" 27 | }, 28 | "devDependencies": { 29 | "@testing-library/dom": "^9.3.3", 30 | "@testing-library/jest-dom": "^6.1.2", 31 | "@testing-library/react": "^14.0.0", 32 | "@testing-library/user-event": "^14.4.3", 33 | "@tsconfig/node18": "^18.2.1", 34 | "@types/leaflet-draw": "^1.0.11", 35 | "@types/react": "^18.2.20", 36 | "@types/react-dom": "^18.2.18", 37 | "@typescript-eslint/eslint-plugin": "^7.18.0", 38 | "@typescript-eslint/parser": "^7.18.0", 39 | "@vitest/coverage-v8": "^1.1.3", 40 | "better-npm-audit": "^3.7.3", 41 | "cross-env": "^7.0.3", 42 | "eslint": "^8.53.0", 43 | "eslint-config-prettier": "^9.0.0", 44 | "eslint-config-standard": "^17.0.0", 45 | "eslint-plugin-import": "^2.28.1", 46 | "eslint-plugin-jsx-a11y": "^6.8.0", 47 | "eslint-plugin-n": "^16.3.1", 48 | "eslint-plugin-promise": "^6.1.1", 49 | "eslint-plugin-react": "^7.34.1", 50 | "eslint-plugin-testing-library": "^6.0.1", 51 | "jsdom": "^22.0.0", 52 | "pre-commit": "^1.2.2", 53 | "prettier": "^3.0.0", 54 | "prettier-eslint": "^16.3.0", 55 | "prettier-eslint-cli": "^7.1.0", 56 | "remark-cli": "^12.0.1", 57 | "remark-gfm": "^4.0.0", 58 | "remark-lint": "^9.1.1", 59 | "remark-lint-no-html": "^3.1.1", 60 | "remark-preset-lint-consistent": "^5.1.1", 61 | "remark-preset-lint-markdown-style-guide": "^5.1.3", 62 | "remark-preset-lint-recommended": "^6.1.2", 63 | "remark-validate-links": "^13.0.1", 64 | "resize-observer-polyfill": "^1.5.1", 65 | "typescript": "^5.0.4", 66 | "vite": "^5.1.8", 67 | "vite-plugin-svgr": "^4.2.0", 68 | "vite-tsconfig-paths": "^4.3.2", 69 | "vitest": "^1.1.3" 70 | }, 71 | "scripts": { 72 | "start": "vite", 73 | "build": "tsc && vite build", 74 | "serve": "vite preview --outDir build", 75 | "test": "vitest", 76 | "test-pre-commit": "cross-env NODE_ENV=test vitest --run", 77 | "coverage": "vitest run --coverage ", 78 | "lint": "eslint ./src --ext .js,.ts && echo \"👍 Passed linting check.\n\"", 79 | "lint-fix": "eslint ./src --ext .js,.ts --fix", 80 | "format": "npx prettier --check . && echo \"👍 Passed formatting check.\n\"", 81 | "format-fix": "npx prettier . --write .", 82 | "fix": "npm run format-fix && npm run lint-fix", 83 | "typecheck": "tsc --jsx react", 84 | "audit-all": "npx better-npm-audit audit", 85 | "audit-prod": "npx better-npm-audit audit --production", 86 | "pre-commit": "./node_modules/pre-commit/hook", 87 | "check-markdown": "remark . --frail" 88 | }, 89 | "pre-commit": { 90 | "silent": false, 91 | "run": [ 92 | "check-markdown", 93 | "format", 94 | "lint", 95 | "audit-all", 96 | "audit-prod", 97 | "test-pre-commit", 98 | "build" 99 | ] 100 | }, 101 | "eslintConfig": { 102 | "extends": [ 103 | "react-app", 104 | "react-app/jest" 105 | ] 106 | }, 107 | "browserslist": { 108 | "production": [ 109 | ">0.2%", 110 | "not dead", 111 | "not op_mini all" 112 | ], 113 | "development": [ 114 | "last 1 chrome version", 115 | "last 1 firefox version", 116 | "last 1 safari version" 117 | ] 118 | }, 119 | "remarkConfig": { 120 | "plugins": [ 121 | "remark-gfm", 122 | "validate-links", 123 | "remark-preset-lint-consistent", 124 | "remark-preset-lint-markdown-style-guide", 125 | "remark-preset-lint-recommended", 126 | "lint-no-html", 127 | [ 128 | "remark-lint-emphasis-marker", 129 | "*" 130 | ], 131 | [ 132 | "remark-lint-no-duplicate-headings", 133 | false 134 | ], 135 | "remark-lint-hard-break-spaces", 136 | "remark-lint-blockquote-indentation", 137 | "remark-lint-no-consecutive-blank-lines", 138 | [ 139 | "remark-lint-maximum-line-length", 140 | 150 141 | ], 142 | [ 143 | "remark-lint-fenced-code-flag", 144 | false 145 | ], 146 | "remark-lint-fenced-code-marker", 147 | "remark-lint-no-shell-dollars", 148 | [ 149 | "remark-lint-code-block-style", 150 | "fenced" 151 | ], 152 | "remark-lint-heading-increment", 153 | "remark-lint-no-multiple-toplevel-headings", 154 | "remark-lint-no-heading-punctuation", 155 | [ 156 | "remark-lint-maximum-heading-length", 157 | 70 158 | ], 159 | [ 160 | "remark-lint-heading-style", 161 | "atx" 162 | ], 163 | [ 164 | "remark-lint-no-shortcut-reference-link", 165 | false 166 | ], 167 | "remark-lint-list-item-bullet-indent", 168 | "remark-lint-ordered-list-marker-style", 169 | "remark-lint-ordered-list-marker-value", 170 | "remark-lint-checkbox-character-style", 171 | [ 172 | "remark-lint-unordered-list-marker-style", 173 | "-" 174 | ], 175 | [ 176 | "remark-lint-list-item-indent", 177 | "space" 178 | ], 179 | "remark-lint-table-pipes", 180 | "remark-lint-no-literal-urls", 181 | [ 182 | "remark-lint-no-file-name-irregular-characters", 183 | false 184 | ], 185 | [ 186 | "remark-lint-list-item-spacing", 187 | false 188 | ] 189 | ] 190 | }, 191 | "jest": { 192 | "transformIgnorePatterns": [ 193 | "node_modules/(?!react-leaflet)/" 194 | ] 195 | }, 196 | "engines": { 197 | "node": ">=18", 198 | "npm": ">=8" 199 | }, 200 | "overrides": { 201 | "semver": "^7.5.3" 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /public/ThumbnailNotAvailable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/ThumbnailNotAvailable.png -------------------------------------------------------------------------------- /public/config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/config/.gitkeep -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/logo.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "FilmDrop UI", 3 | "name": "FilmDrop UI", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/marker-icon.png -------------------------------------------------------------------------------- /public/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/public/marker-shadow.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /screenshots/cop-dem-hex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/cop-dem-hex.jpg -------------------------------------------------------------------------------- /screenshots/cop-dem-mosaic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/cop-dem-mosaic.jpg -------------------------------------------------------------------------------- /screenshots/naip-grid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/naip-grid.jpg -------------------------------------------------------------------------------- /screenshots/naip-scene.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/naip-scene.jpg -------------------------------------------------------------------------------- /screenshots/s1-hex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/s1-hex.jpg -------------------------------------------------------------------------------- /screenshots/s1-scene.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/s1-scene.jpg -------------------------------------------------------------------------------- /screenshots/s2-l2a-grid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/s2-l2a-grid.jpg -------------------------------------------------------------------------------- /screenshots/s2-l2a-mosaic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/screenshots/s2-l2a-mosaic.jpg -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100%; 3 | width: 100%; 4 | font-family: 'Inter', sans-serif; 5 | background-color: #333; 6 | } 7 | 8 | .leaflet-tooltip { 9 | font-family: 'Inter', sans-serif; 10 | } 11 | 12 | .appLoading { 13 | position: fixed; 14 | top: 0; 15 | left: 0; 16 | width: 100%; 17 | height: 100%; 18 | background: rgb(53, 61, 79); 19 | z-index: 0; 20 | } 21 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import './App.css' 3 | import './index.css' 4 | import Content from './components/Layout/Content/Content' 5 | import PageHeader from './components/Layout/PageHeader/PageHeader' 6 | import UploadGeojsonModal from './components/UploadGeojsonModal/UploadGeojsonModal' 7 | import SystemMessage from './components/SystemMessage/SystemMessage' 8 | import { GetCollectionsService } from './services/get-collections-service' 9 | import { LoadConfigIntoStateService } from './services/get-config-service' 10 | import { useDispatch, useSelector } from 'react-redux' 11 | import CartModal from './components/Cart/CartModal/CartModal' 12 | import { InitializeAppFromConfig } from './utils/configHelper' 13 | import Login from './components/Login/Login' 14 | import { setauthTokenExists } from './redux/slices/mainSlice' 15 | 16 | function App() { 17 | const dispatch = useDispatch() 18 | const _showUploadGeojsonModal = useSelector( 19 | (state) => state.mainSlice.showUploadGeojsonModal 20 | ) 21 | const _showApplicationAlert = useSelector( 22 | (state) => state.mainSlice.showApplicationAlert 23 | ) 24 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 25 | const _showCartModal = useSelector((state) => state.mainSlice.showCartModal) 26 | const _authTokenExists = useSelector( 27 | (state) => state.mainSlice.authTokenExists 28 | ) 29 | const [showLogin, setShowLogin] = useState(false) 30 | 31 | useEffect(() => { 32 | if (localStorage.getItem('APP_AUTH_TOKEN')) { 33 | dispatch(setauthTokenExists(true)) 34 | } 35 | LoadConfigIntoStateService() 36 | try { 37 | console.log('Version: ' + process.env.REACT_APP_VERSION) 38 | } catch (err) { 39 | console.error('Error logging version:', err) 40 | } 41 | }, []) 42 | 43 | useEffect(() => { 44 | if (_appConfig) { 45 | if (_appConfig.APP_TOKEN_AUTH_ENABLED && !_authTokenExists) { 46 | setShowLogin(true) 47 | return 48 | } 49 | setShowLogin(false) 50 | InitializeAppFromConfig() 51 | GetCollectionsService() 52 | } 53 | }, [_appConfig, _authTokenExists]) 54 | 55 | return ( 56 | 57 | {_appConfig ? ( 58 | showLogin ? ( 59 |
60 | 61 | {_showApplicationAlert ? : null} 62 |
63 | ) : ( 64 |
65 | 66 | 67 | {_showUploadGeojsonModal ? ( 68 | 69 | ) : null} 70 | {_showApplicationAlert ? : null} 71 | {_showCartModal ? : null} 72 |
73 | ) 74 | ) : ( 75 |
76 |
77 | {_showApplicationAlert ? : null} 78 |
79 | )} 80 |
81 | ) 82 | } 83 | 84 | export default App 85 | -------------------------------------------------------------------------------- /src/App.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import App from './App' 4 | import { Provider } from 'react-redux' 5 | import { store } from './redux/store' 6 | import { 7 | setshowUploadGeojsonModal, 8 | setshowApplicationAlert, 9 | setappConfig, 10 | setshowCartModal 11 | } from './redux/slices/mainSlice' 12 | import { vi } from 'vitest' 13 | import * as CollectionsService from './services/get-collections-service' 14 | import * as LoadConfigService from './services/get-config-service' 15 | import { mockAppConfig } from './testing/shared-mocks' 16 | import * as ConfigHelper from './utils/configHelper' 17 | 18 | describe('App', () => { 19 | const setup = () => 20 | render( 21 | 22 | 23 | 24 | ) 25 | 26 | describe('on app render with config', () => { 27 | beforeEach(() => { 28 | store.dispatch(setappConfig(mockAppConfig)) 29 | }) 30 | afterEach(() => { 31 | vi.restoreAllMocks() 32 | }) 33 | it('should call GetCollectionsService once', () => { 34 | const spy = vi.spyOn(CollectionsService, 'GetCollectionsService') 35 | setup() 36 | expect(spy).toHaveBeenCalledTimes(1) 37 | }) 38 | it('should call InitializeAppFromConfig once', () => { 39 | const spy = vi.spyOn(ConfigHelper, 'InitializeAppFromConfig') 40 | setup() 41 | expect(spy).toHaveBeenCalledTimes(1) 42 | }) 43 | it('should call LoadConfigIntoStateService once', () => { 44 | const spy = vi.spyOn(LoadConfigService, 'LoadConfigIntoStateService') 45 | setup() 46 | expect(spy).toHaveBeenCalledTimes(1) 47 | }) 48 | it('should render the PageHeader component', () => { 49 | setup() 50 | const PageHeaderComponent = screen.queryByTestId('testPageHeader') 51 | expect(PageHeaderComponent).not.toBeNull() 52 | }) 53 | it('should render the Content Component', () => { 54 | setup() 55 | const ContentComponent = screen.queryByTestId('testContent') 56 | expect(ContentComponent).not.toBeNull() 57 | }) 58 | describe('when conditionally rendering UploadGeojsonModal', () => { 59 | it('should not render UploadGeojsonModal if showUploadGeojsonModal in state is false', () => { 60 | setup() 61 | const UploadGeojsonModalComponent = screen.queryByTestId( 62 | 'testUploadGeojsonModal' 63 | ) 64 | expect(UploadGeojsonModalComponent).toBeNull() 65 | }) 66 | it('should render UploadGeojsonModal if showUploadGeojsonModal in state is true', () => { 67 | store.dispatch(setshowUploadGeojsonModal(true)) 68 | setup() 69 | const UploadGeojsonModalComponent = screen.queryByTestId( 70 | 'testUploadGeojsonModal' 71 | ) 72 | expect(UploadGeojsonModalComponent).not.toBeNull() 73 | }) 74 | }) 75 | describe('when conditionally rendering SystemMessage', () => { 76 | it('should not render SystemMessage if showApplicationAlert in state is false', () => { 77 | setup() 78 | const SystemMessageComponent = screen.queryByTestId('testSystemMessage') 79 | expect(SystemMessageComponent).toBeNull() 80 | }) 81 | it('should render SystemMessage if showApplicationAlert in state is true', () => { 82 | store.dispatch(setshowApplicationAlert(true)) 83 | setup() 84 | const SystemMessageComponent = screen.queryByTestId('testSystemMessage') 85 | expect(SystemMessageComponent).not.toBeNull() 86 | }) 87 | }) 88 | describe('when conditionally rendering Cart Modal', () => { 89 | it('should not render CartModal if showCartModal in state is false', () => { 90 | setup() 91 | const CartModalComponent = screen.queryByTestId('testCartModal') 92 | expect(CartModalComponent).toBeNull() 93 | }) 94 | it('should render CartModal if showCartModal in state is true', () => { 95 | store.dispatch(setshowCartModal(true)) 96 | setup() 97 | const CartModalComponent = screen.queryByTestId('testCartModal') 98 | expect(CartModalComponent).not.toBeNull() 99 | }) 100 | }) 101 | }) 102 | describe('on app render without config', () => { 103 | afterEach(() => { 104 | vi.restoreAllMocks() 105 | }) 106 | it('should showAppLoading page', () => { 107 | setup() 108 | const PageHeaderComponent = screen.queryByTestId('testAppLoading') 109 | expect(PageHeaderComponent).not.toBeNull() 110 | }) 111 | it('should call LoadConfigIntoStateService once', () => { 112 | const spy = vi.spyOn(LoadConfigService, 'LoadConfigIntoStateService') 113 | setup() 114 | expect(spy).toHaveBeenCalledTimes(1) 115 | }) 116 | it('should call not GetCollectionsService', () => { 117 | const spy = vi.spyOn(CollectionsService, 'GetCollectionsService') 118 | setup() 119 | expect(spy).not.toHaveBeenCalled() 120 | }) 121 | it('should call not InitializeAppFromConfig', () => { 122 | const spy = vi.spyOn(ConfigHelper, 'InitializeAppFromConfig') 123 | setup() 124 | expect(spy).not.toHaveBeenCalled() 125 | }) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /src/assets/cloudFormationTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/src/assets/cloudFormationTemplate.png -------------------------------------------------------------------------------- /src/assets/icon-copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/icon-external-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo-filmdrop-e84.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Element84/filmdrop-ui/eed77c847eb3b5df9a3ef889ee76bf4f3f981f43/src/assets/logo-filmdrop-e84.png -------------------------------------------------------------------------------- /src/assets/logo-filmdrop-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 65 | -------------------------------------------------------------------------------- /src/components/Cart/CartButton/CartButton.css: -------------------------------------------------------------------------------- 1 | .cartButton { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | justify-content: center; 6 | height: 30px; 7 | width: 35px; 8 | border-radius: 5px; 9 | border-style: solid; 10 | border-width: 1px; 11 | padding: 10px; 12 | font-size: 16px; 13 | font-weight: bold; 14 | border-color: #76829c; 15 | color: #a9b0c1; 16 | user-select: none; 17 | } 18 | 19 | .cartButtonEnabled { 20 | cursor: pointer; 21 | color: #12171a; 22 | background-color: #6cc24a; 23 | border-color: #6cc24a; 24 | } 25 | 26 | .cartCountContainer { 27 | min-width: 25px; 28 | height: 30px; 29 | padding-left: 3px; 30 | padding-right: 3px; 31 | background-color: #5d626d; 32 | color: #fff; 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | margin-top: 3px; 37 | border-radius: 5px; 38 | font-weight: normal; 39 | } 40 | 41 | .cartCountContainerEnabled { 42 | background-color: #4f8f36; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Cart/CartButton/CartButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './CartButton.css' 3 | import { Stack } from '@mui/material' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | import { setshowCartModal } from '../../../redux/slices/mainSlice' 6 | 7 | const CartButton = () => { 8 | const dispatch = useDispatch() 9 | const _cartItems = useSelector((state) => state.mainSlice.cartItems) 10 | 11 | function onCartButtonClick() { 12 | if (_cartItems.length === 0) { 13 | return 14 | } 15 | dispatch(setshowCartModal(true)) 16 | } 17 | return ( 18 |
19 | 0 ? 'cartButton cartButtonEnabled' : 'cartButton' 22 | } 23 | data-testid="testCartButton" 24 | onClick={onCartButtonClick} 25 | > 26 | Cart 27 |
0 30 | ? 'cartCountContainer cartCountContainerEnabled' 31 | : 'cartCountContainer' 32 | } 33 | data-testid="testCartCount" 34 | > 35 | {_cartItems.length} 36 |
37 |
38 |
39 | ) 40 | } 41 | 42 | export default CartButton 43 | -------------------------------------------------------------------------------- /src/components/Cart/CartModal/CartModal.css: -------------------------------------------------------------------------------- 1 | .cartModal { 2 | background-color: #00000090; 3 | height: 100%; 4 | width: 100%; 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | position: absolute; 9 | z-index: 100; 10 | } 11 | 12 | .cartModalContents { 13 | position: absolute; 14 | z-index: 1; 15 | height: 90%; 16 | width: 95%; 17 | min-height: 300px; 18 | min-width: 500px; 19 | background-color: #13171a; 20 | border-radius: 5px; 21 | } 22 | 23 | .cartModalTopBar { 24 | position: absolute; 25 | top: 0px; 26 | height: 50px; 27 | width: 100%; 28 | align-items: center; 29 | background-color: #6cc24a; 30 | color: #000; 31 | display: flex; 32 | flex-direction: row; 33 | font-size: 18px; 34 | font-weight: bold; 35 | border-top-left-radius: 5px; 36 | border-top-right-radius: 5px; 37 | justify-content: space-between; 38 | } 39 | 40 | .cartModalTopBarText { 41 | margin-left: 15px; 42 | } 43 | 44 | .closeCartModal { 45 | background-color: transparent; 46 | color: #000; 47 | border: 0px; 48 | font-size: 24px; 49 | cursor: pointer; 50 | -webkit-touch-callout: none; 51 | /* iOS Safari */ 52 | -webkit-user-select: none; 53 | /* Safari */ 54 | -khtml-user-select: none; 55 | /* Konqueror HTML */ 56 | -moz-user-select: none; 57 | /* Old versions of Firefox */ 58 | -ms-user-select: none; 59 | /* Internet Explorer/Edge */ 60 | user-select: none; 61 | margin-right: 15px; 62 | } 63 | -------------------------------------------------------------------------------- /src/components/Cart/CartModal/CartModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './CartModal.css' 3 | import { useDispatch } from 'react-redux' 4 | import { setshowCartModal } from '../../../redux/slices/mainSlice' 5 | 6 | const CartModal = () => { 7 | const dispatch = useDispatch() 8 | 9 | function onCartModalCloseClick() { 10 | dispatch(setshowCartModal(false)) 11 | } 12 | return ( 13 |
14 |
15 |
16 | Your cart 17 | 23 |
24 |
25 |
26 | ) 27 | } 28 | 29 | export default CartModal 30 | -------------------------------------------------------------------------------- /src/components/Cart/CartModal/CartModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen, fireEvent } from '@testing-library/react' 3 | import { Provider } from 'react-redux' 4 | import { store } from '../../../redux/store' 5 | import { setappConfig, setshowCartModal } from '../../../redux/slices/mainSlice' 6 | import { mockAppConfig } from '../../../testing/shared-mocks' 7 | import CartModal from './CartModal' 8 | 9 | describe('CartModal', () => { 10 | const setup = () => 11 | render( 12 | 13 | 14 | 15 | ) 16 | 17 | beforeEach(() => { 18 | store.dispatch(setappConfig(mockAppConfig)) 19 | }) 20 | 21 | describe('on close clicked', () => { 22 | it('should set setshowCartModal to false in redux state', () => { 23 | store.dispatch(setshowCartModal(true)) 24 | setup() 25 | expect(store.getState().mainSlice.showCartModal).toBeTruthy() 26 | const closeButton = screen.getByText('✕') 27 | fireEvent.click(closeButton) 28 | expect(store.getState().mainSlice.showCartModal).toBeFalsy() 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/components/CloudSlider/CloudSlider.css: -------------------------------------------------------------------------------- 1 | .cloudSliderInputs { 2 | padding-left: 10px; 3 | } 4 | 5 | .cloudSlider.disabled { 6 | opacity: 30%; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/CloudSlider/CloudSlider.jsx: -------------------------------------------------------------------------------- 1 | import { React, useState, useEffect } from 'react' 2 | import './CloudSlider.css' 3 | import { styled, createTheme, ThemeProvider } from '@mui/material/styles' 4 | import { Stack } from '@mui/material' 5 | import Grid from '@mui/material/Grid' 6 | import Slider from '@mui/material/Slider' 7 | import MuiInput from '@mui/material/Input' 8 | import { useDispatch, useSelector } from 'react-redux' 9 | import { setCloudCover, setShowCloudSlider } from '../../redux/slices/mainSlice' 10 | 11 | const Input = styled(MuiInput)` 12 | width: 42px; 13 | color: #dedede; 14 | ` 15 | 16 | const CloudSlider = () => { 17 | const _selectedCollectionData = useSelector( 18 | (state) => state.mainSlice.selectedCollectionData 19 | ) 20 | const _cloudCover = useSelector((state) => state.mainSlice.cloudCover) 21 | 22 | const dispatch = useDispatch() 23 | const [value, setValue] = useState(_cloudCover) 24 | const [disabled, setDisabled] = useState(false) 25 | 26 | useEffect(() => { 27 | if (_selectedCollectionData) { 28 | const supportsCloudCover = 29 | _selectedCollectionData.queryables?.['eo:cloud_cover'] 30 | if (supportsCloudCover) { 31 | setDisabled(!supportsCloudCover) 32 | dispatch(setShowCloudSlider(true)) 33 | } else { 34 | setDisabled(true) 35 | dispatch(setShowCloudSlider(false)) 36 | } 37 | } 38 | }, [_selectedCollectionData]) 39 | 40 | useEffect(() => { 41 | dispatch(setCloudCover(value)) 42 | }, [value]) 43 | 44 | const handleSliderChange = (event, newValue) => { 45 | setValue(newValue) 46 | } 47 | 48 | const handleInputChange = (event) => { 49 | setValue(event.target.value === '' ? 0 : Number(event.target.value)) 50 | } 51 | 52 | const handleBlur = () => { 53 | if (value < 0) { 54 | setValue(0) 55 | } else if (value > 100) { 56 | setValue(100) 57 | } 58 | } 59 | 60 | const theme = createTheme({ 61 | palette: { 62 | primary: { 63 | main: '#76829c' 64 | }, 65 | secondary: { 66 | main: '#edf2ff' 67 | } 68 | } 69 | }) 70 | 71 | return ( 72 | 73 | 74 | 75 | 81 | 82 | 94 | 95 | 96 | 116 | 117 | 118 | 119 | 120 | ) 121 | } 122 | 123 | export default CloudSlider 124 | -------------------------------------------------------------------------------- /src/components/CollectionDropdown/CollectionDropdown.css: -------------------------------------------------------------------------------- 1 | .collectionDropdown .MuiInput-underline:before { 2 | border-color: #76829c; 3 | } 4 | 5 | .collectionDropdown svg { 6 | color: rgba(255, 255, 255, 0.5); 7 | } 8 | 9 | #collectionDropdown { 10 | border-color: white; 11 | color: white; 12 | background-color: #12171a; 13 | } 14 | 15 | #collectionDropdown option { 16 | background-color: #12171a !important; 17 | } 18 | 19 | .collectionDropdown.error-true select { 20 | color: #bc3131 !important; 21 | font-weight: bold; 22 | border-bottom: 1px solid #bc3131; 23 | } 24 | 25 | .collectionDropdown div[label='Collection'] { 26 | top: -2.5px; 27 | padding-top: 1px; 28 | min-width: 100%; 29 | } 30 | 31 | .collectionRangeText { 32 | display: flex; 33 | flex-direction: row; 34 | width: 100%; 35 | font-size: 12px; 36 | font-weight: 100; 37 | margin-top: 10px; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/CollectionDropdown/CollectionDropdown.jsx: -------------------------------------------------------------------------------- 1 | import { React, useState, useEffect } from 'react' 2 | import './CollectionDropdown.css' 3 | import { Stack } from '@mui/material' 4 | import Grid from '@mui/material/Grid' 5 | import NativeSelect from '@mui/material/NativeSelect' 6 | import { useDispatch, useSelector } from 'react-redux' 7 | import { 8 | setSelectedCollectionData, 9 | setShowZoomNotice, 10 | setSearchLoading, 11 | sethasCollectionChanged, 12 | setSelectedCollection 13 | } from '../../redux/slices/mainSlice' 14 | import { 15 | zoomToCollectionExtent, 16 | clearAllLayers, 17 | clearMapSelection 18 | } from '../../utils/mapHelper' 19 | 20 | const Dropdown = () => { 21 | const _selectedCollection = useSelector( 22 | (state) => state.mainSlice.selectedCollection 23 | ) 24 | const _selectedCollectionData = useSelector( 25 | (state) => state.mainSlice.selectedCollectionData 26 | ) 27 | const dispatch = useDispatch() 28 | const [collectionId, setCollectionId] = useState(_selectedCollection) 29 | const _collectionsData = useSelector( 30 | (state) => state.mainSlice.collectionsData 31 | ) 32 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 33 | 34 | useEffect(() => { 35 | if (_collectionsData.length > 0) { 36 | if (_selectedCollection !== 'Select Collection') { 37 | setCollectionId(_selectedCollection) 38 | return 39 | } 40 | if (!_appConfig.DEFAULT_COLLECTION) { 41 | setCollectionId(_collectionsData[0].id) 42 | return 43 | } 44 | const defaultCollectionFound = !!_collectionsData.find( 45 | (o) => o.id === _appConfig.DEFAULT_COLLECTION 46 | ) 47 | if (!defaultCollectionFound) { 48 | console.log('Configuration Error: DEFAULT_COLLECTION not found in API') 49 | setCollectionId(_collectionsData[0].id) 50 | } else { 51 | setCollectionId(_appConfig.DEFAULT_COLLECTION) 52 | } 53 | } 54 | }, [_collectionsData]) 55 | 56 | useEffect(() => { 57 | const selectedCollection = _collectionsData?.find( 58 | (e) => e.id === collectionId 59 | ) 60 | if (selectedCollection) { 61 | dispatch(setSelectedCollection(collectionId)) 62 | dispatch(setSelectedCollectionData(selectedCollection)) 63 | dispatch(setShowZoomNotice(false)) 64 | dispatch(setSearchLoading(false)) 65 | if (selectedCollection !== _selectedCollectionData) { 66 | zoomToCollectionExtent(selectedCollection) 67 | } 68 | } 69 | }, [collectionId]) 70 | 71 | function onCollectionChanged(e) { 72 | dispatch(sethasCollectionChanged(true)) 73 | setCollectionId(e.target.value) 74 | clearMapSelection() 75 | clearAllLayers() 76 | } 77 | 78 | function formatDate(dateString) { 79 | return dateString ? dateString.split('T')[0] : null 80 | } 81 | 82 | return ( 83 | 84 | 85 | 86 | 87 | onCollectionChanged(e)} 92 | > 93 | 96 | {_collectionsData && 97 | _collectionsData.map(({ id, title }) => ( 98 | 101 | ))} 102 | 103 | 104 | 105 | {_selectedCollectionData?.extent?.temporal && ( 106 |
107 | Extent:  108 | {formatDate(_selectedCollectionData.extent.temporal.interval[0][0]) || 109 | 'No Lower Limit'}{' '} 110 | to{' '} 111 | {formatDate(_selectedCollectionData.extent.temporal.interval[0][1]) || 112 | 'Present'} 113 |
114 | )} 115 |
116 | ) 117 | } 118 | 119 | export default Dropdown 120 | -------------------------------------------------------------------------------- /src/components/CollectionDropdown/CollectionDropdown.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { vi } from 'vitest' 3 | import { render, screen } from '@testing-library/react' 4 | import CollectionDropdown from './CollectionDropdown' 5 | import { Provider } from 'react-redux' 6 | import { store } from '../../redux/store' 7 | import { setCollectionsData, setappConfig } from '../../redux/slices/mainSlice' 8 | import { mockCollectionsData, mockAppConfig } from '../../testing/shared-mocks' 9 | import * as mapHelper from '../../utils/mapHelper' 10 | import userEvent from '@testing-library/user-event' 11 | 12 | describe('CollectionDropdown', () => { 13 | const setup = () => 14 | render( 15 | 16 | 17 | 18 | ) 19 | 20 | beforeEach(() => { 21 | vi.mock('../../utils/mapHelper') 22 | store.dispatch(setappConfig(mockAppConfig)) 23 | store.dispatch(setCollectionsData(mockCollectionsData)) 24 | }) 25 | afterEach(() => { 26 | vi.resetAllMocks() 27 | }) 28 | 29 | describe('on render', () => { 30 | it('should load collections options from collectionsData in redux state', () => { 31 | setup() 32 | expect(screen.getByText('Copernicus DEM GLO-30')).toBeInTheDocument() 33 | expect(screen.getByText('Sentinel-2 Level 2A')).toBeInTheDocument() 34 | }) 35 | }) 36 | describe('on collection changed', () => { 37 | it('should set hasCollectionChanged to true in redux state', async () => { 38 | setup() 39 | expect(store.getState().mainSlice.hasCollectionChanged).toBeFalsy() 40 | await userEvent.selectOptions( 41 | screen.getByRole('combobox', { 42 | name: /collection/i 43 | }), 44 | 'Copernicus DEM GLO-30' 45 | ) 46 | expect(store.getState().mainSlice.hasCollectionChanged).toBeTruthy() 47 | }) 48 | it('should dispatch and call functions to reset map', async () => { 49 | const spyZoomToCollectionExtent = vi.spyOn( 50 | mapHelper, 51 | 'zoomToCollectionExtent' 52 | ) 53 | const spyClearMapSelection = vi.spyOn(mapHelper, 'clearMapSelection') 54 | const spyClearAllLayers = vi.spyOn(mapHelper, 'clearAllLayers') 55 | setup() 56 | await userEvent.selectOptions( 57 | screen.getByRole('combobox', { 58 | name: /collection/i 59 | }), 60 | 'Copernicus DEM GLO-30' 61 | ) 62 | expect(store.getState().mainSlice.showZoomNotice).toBeFalsy() 63 | expect(store.getState().mainSlice.searchResults).toBeNull() 64 | expect(store.getState().mainSlice.searchLoading).toBeFalsy() 65 | expect(spyZoomToCollectionExtent).toHaveBeenCalled() 66 | expect(spyClearMapSelection).toHaveBeenCalled() 67 | expect(spyClearAllLayers).toHaveBeenCalled() 68 | }) 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /src/components/DateTimeRangeSelector/DateTimeRangeSelector.css: -------------------------------------------------------------------------------- 1 | label { 2 | position: relative; 3 | top: 2px; 4 | } 5 | 6 | .datePickerContainer { 7 | display: flex; 8 | flex-direction: row; 9 | width: 100%; 10 | margin-top: 15px; 11 | margin-bottom: 15px; 12 | } 13 | 14 | .reactDatePicker { 15 | width: 80px; 16 | } 17 | 18 | .datePicker { 19 | font-size: 14px; 20 | min-width: 150px; 21 | } 22 | 23 | .datePicker label { 24 | font-size: 16px; 25 | } 26 | 27 | .datePicker select { 28 | margin-top: 5px; 29 | } 30 | 31 | .datePicker label span svg { 32 | height: 16px; 33 | width: 16px; 34 | position: relative; 35 | top: 1px; 36 | left: 4px; 37 | margin-right: 10px; 38 | } 39 | 40 | .datePicker .react-tooltip { 41 | font-weight: normal; 42 | line-height: 1.6; 43 | } 44 | 45 | .dateToolTipIcon { 46 | cursor: pointer; 47 | } 48 | 49 | span.error-true { 50 | color: #bc3131; 51 | } 52 | 53 | .react-calendar { 54 | width: 270px; 55 | } 56 | 57 | #dateRange-tooltip { 58 | opacity: 1; 59 | z-index: 999; 60 | } 61 | 62 | .dateRangeSpanText { 63 | display: flex; 64 | justify-content: center; 65 | align-items: center; 66 | margin-left: 5px; 67 | margin-right: 5px; 68 | } 69 | 70 | /* !important used here to override a MUI icon style to work in non-MUI datepicker */ 71 | .datePicker-icon { 72 | color: #373d4d; 73 | height: 1rem !important; 74 | width: 1rem !important; 75 | cursor: pointer; 76 | } 77 | 78 | .react-datepicker__today-button:hover { 79 | background-color: #d3d3d3; 80 | } 81 | 82 | .datePickerLabelUTCText { 83 | font-weight: 100; 84 | font-size: 12px; 85 | } 86 | -------------------------------------------------------------------------------- /src/components/DateTimeRangeSelector/DateTimeRangeSelector.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import './DateTimeRangeSelector.css' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import 'react-tooltip/dist/react-tooltip.css' 5 | import { setSearchDateRangeValue } from '../../redux/slices/mainSlice' 6 | import CalendarTodayIcon from '@mui/icons-material/CalendarToday' 7 | import DatePicker from 'react-datepicker' 8 | import 'react-datepicker/dist/react-datepicker.css' 9 | import dayjs from 'dayjs' 10 | import utc from 'dayjs/plugin/utc' 11 | import { convertToUTC } from '../../utils/datetime' 12 | 13 | const DateTimeRangeSelector = () => { 14 | dayjs.extend(utc) 15 | const dispatch = useDispatch() 16 | const _selectedCollectionData = useSelector( 17 | (state) => state.mainSlice.selectedCollectionData 18 | ) 19 | const _hasCollectionChanged = useSelector( 20 | (state) => state.mainSlice.hasCollectionChanged 21 | ) 22 | const _SearchDateRangeValue = useSelector( 23 | (state) => state.mainSlice.searchDateRangeValue 24 | ) 25 | const _hasLeftPanelTabChanged = useSelector( 26 | (state) => state.mainSlice.hasLeftPanelTabChanged 27 | ) 28 | const [startDate, setstartDate] = useState(_SearchDateRangeValue[0]) 29 | const [endDate, setendDate] = useState(_SearchDateRangeValue[1]) 30 | 31 | useEffect(() => { 32 | if (!_selectedCollectionData) { 33 | return 34 | } 35 | 36 | const currentDateUTCString = dayjs(new Date()).utc().startOf('day').format() 37 | const collectionEndDateOrCurrentLocal = 38 | _selectedCollectionData.extent.temporal.interval[0][1] !== null 39 | ? convertToUTC(_selectedCollectionData.extent.temporal.interval[0][1]) 40 | : convertToUTC(currentDateUTCString) 41 | 42 | // if temporal range not in last two week on init, set to match collection 43 | if (!_hasCollectionChanged) { 44 | if ( 45 | (startDate < 46 | convertToUTC( 47 | _selectedCollectionData.extent.temporal.interval[0][0] 48 | ) && 49 | endDate < 50 | convertToUTC( 51 | _selectedCollectionData.extent.temporal.interval[0][0] 52 | )) || 53 | (startDate > collectionEndDateOrCurrentLocal && 54 | endDate > collectionEndDateOrCurrentLocal) 55 | ) { 56 | setstartDate( 57 | convertToUTC(_selectedCollectionData.extent.temporal.interval[0][0]) 58 | ) 59 | setendDate(collectionEndDateOrCurrentLocal) 60 | } 61 | } else { 62 | setstartDate(convertToUTC(_SearchDateRangeValue[0])) 63 | setendDate(convertToUTC(_SearchDateRangeValue[1])) 64 | } 65 | }, [_selectedCollectionData]) 66 | 67 | useEffect(() => { 68 | let correctStartSearchDate 69 | let correctEndSearchDate 70 | 71 | const StartDateAsDateObject = 72 | startDate instanceof Date ? startDate : new Date(startDate) 73 | const offsetInMilliseconds = 74 | StartDateAsDateObject.getTimezoneOffset() * 60 * 1000 75 | 76 | if (startDate instanceof Date) { 77 | correctStartSearchDate = new Date( 78 | startDate.getTime() - offsetInMilliseconds 79 | ).toISOString() 80 | } else { 81 | correctStartSearchDate = new Date(startDate).toISOString() 82 | } 83 | 84 | if (endDate instanceof Date) { 85 | correctEndSearchDate = new Date( 86 | endDate.getTime() - offsetInMilliseconds 87 | ).toISOString() 88 | } else { 89 | correctEndSearchDate = new Date(endDate).toISOString() 90 | } 91 | 92 | dispatch( 93 | setSearchDateRangeValue([correctStartSearchDate, correctEndSearchDate]) 94 | ) 95 | }, [startDate, endDate]) 96 | 97 | useEffect(() => { 98 | if (_hasLeftPanelTabChanged) { 99 | setstartDate(convertToUTC(_SearchDateRangeValue[0])) 100 | setendDate(convertToUTC(_SearchDateRangeValue[1])) 101 | } 102 | }, []) 103 | 104 | return ( 105 |
106 | 114 |
115 | 128 | } 129 | toggleCalendarOnIconClick 130 | closeOnScroll={true} 131 | peekNextMonth 132 | showMonthDropdown 133 | showYearDropdown 134 | dropdownMode="select" 135 | dateFormat="yyyy-MM-dd" 136 | popperPlacement="top-end" 137 | onChange={(date) => setstartDate(date)} 138 | > 139 | to 140 | 154 | } 155 | toggleCalendarOnIconClick 156 | closeOnScroll={true} 157 | showMonthDropdown 158 | showYearDropdown 159 | dropdownMode="select" 160 | dateFormat="yyyy-MM-dd" 161 | popperPlacement="top-end" 162 | onChange={(date) => setendDate(date)} 163 | > 164 |
165 |
166 | ) 167 | } 168 | 169 | export default DateTimeRangeSelector 170 | -------------------------------------------------------------------------------- /src/components/DateTimeRangeSelector/react-calendar-overrides.css: -------------------------------------------------------------------------------- 1 | .react-calendar { 2 | width: 350px; 3 | max-width: 100%; 4 | background: white; 5 | border: 1px solid #a0a096; 6 | font-family: Arial, Helvetica, sans-serif; 7 | line-height: 1.125em; 8 | } 9 | 10 | .react-calendar--doubleView { 11 | width: 700px; 12 | } 13 | 14 | .react-calendar--doubleView .react-calendar__viewContainer { 15 | display: flex; 16 | margin: -0.5em; 17 | } 18 | 19 | .react-calendar--doubleView .react-calendar__viewContainer > * { 20 | width: 50%; 21 | margin: 0.5em; 22 | } 23 | 24 | .react-calendar, 25 | .react-calendar *, 26 | .react-calendar *:before, 27 | .react-calendar *:after { 28 | -moz-box-sizing: border-box; 29 | -webkit-box-sizing: border-box; 30 | box-sizing: border-box; 31 | } 32 | 33 | .react-calendar button { 34 | margin: 0; 35 | border: 0; 36 | outline: none; 37 | } 38 | 39 | .react-calendar button:enabled:hover { 40 | cursor: pointer; 41 | } 42 | 43 | .react-calendar__navigation { 44 | display: flex; 45 | height: 44px; 46 | margin-bottom: 1em; 47 | } 48 | 49 | .react-calendar__navigation button { 50 | min-width: 44px; 51 | background: none; 52 | } 53 | 54 | .react-calendar__navigation button:disabled { 55 | background-color: #f0f0f0; 56 | } 57 | 58 | .react-calendar__navigation button:enabled:hover, 59 | .react-calendar__navigation button:enabled:focus { 60 | background-color: #e6e6e6; 61 | } 62 | 63 | .react-calendar__month-view__weekdays { 64 | text-align: center; 65 | text-transform: uppercase; 66 | font-weight: bold; 67 | font-size: 0.75em; 68 | } 69 | 70 | .react-calendar__month-view__weekdays__weekday { 71 | padding: 0.5em; 72 | } 73 | 74 | .react-calendar__month-view__weekNumbers .react-calendar__tile { 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | font-size: 0.75em; 79 | font-weight: bold; 80 | } 81 | 82 | .react-calendar__month-view__days__day--weekend { 83 | color: #757575; 84 | } 85 | 86 | .react-calendar__month-view__days__day--neighboringMonth { 87 | color: #757575; 88 | } 89 | 90 | .react-calendar__year-view .react-calendar__tile, 91 | .react-calendar__decade-view .react-calendar__tile, 92 | .react-calendar__century-view .react-calendar__tile { 93 | padding: 2em 0.5em; 94 | } 95 | 96 | .react-calendar__tile { 97 | max-width: 100%; 98 | padding: 10px 6.6667px; 99 | background: none; 100 | text-align: center; 101 | line-height: 16px; 102 | } 103 | 104 | .react-calendar__tile:disabled { 105 | background-color: #f0f0f0; 106 | } 107 | 108 | .react-calendar__tile:enabled:hover, 109 | .react-calendar__tile:enabled:focus { 110 | background-color: #e6e6e6; 111 | } 112 | 113 | .react-calendar__tile--now { 114 | background: #6cc24a; 115 | } 116 | 117 | .react-calendar__tile--now:enabled:hover, 118 | .react-calendar__tile--now:enabled:focus { 119 | background: #d3d3d3; 120 | } 121 | 122 | .react-calendar__tile--hasActive { 123 | background: #a9b0c1; 124 | } 125 | 126 | .react-calendar__tile--hasActive:enabled:hover, 127 | .react-calendar__tile--hasActive:enabled:focus { 128 | background: #a9b0c1; 129 | } 130 | 131 | .react-calendar__tile--active { 132 | background: #353d4f; 133 | color: white; 134 | } 135 | 136 | .react-calendar__tile--active:enabled:hover, 137 | .react-calendar__tile--active:enabled:focus { 138 | background: #353d4f; 139 | } 140 | 141 | .react-calendar--selectRange .react-calendar__tile--hover { 142 | background-color: #e6e6e6; 143 | } 144 | 145 | .react-calendar__month-view__weekdays { 146 | color: #333; 147 | } 148 | 149 | .react-datetimerange-picker__wrapper { 150 | border: 0 none; 151 | padding-top: 5px; 152 | } 153 | 154 | .react-datetimerange-picker__inputGroup { 155 | border-bottom: 1px solid #76829c; 156 | display: flex; 157 | flex-direction: row; 158 | align-items: baseline; 159 | padding-bottom: 2px; 160 | } 161 | 162 | .react-datetimerange-picker__inputGroup:hover { 163 | padding-bottom: 1px; 164 | border-bottom: 2px solid rgba(0, 0, 0, 0.87); 165 | } 166 | 167 | .react-datetimerange-picker__button { 168 | margin-bottom: 4px; 169 | } 170 | 171 | .react-datetimerange-picker__range-divider { 172 | position: relative; 173 | top: -1px; 174 | } 175 | 176 | .react-datetimerange-picker__button:enabled:hover 177 | .react-datetimerange-picker__button__icon, 178 | .react-datetimerange-picker__button:enabled:focus 179 | .react-datetimerange-picker__button__icon { 180 | stroke: #6cc24a; 181 | } 182 | 183 | .react-datetimerange-picker svg { 184 | stroke: #a9b0c1; 185 | } 186 | 187 | .datePicker input::placeholder { 188 | color: #ffffff; 189 | } 190 | 191 | .datePicker select { 192 | color: #353d4f; 193 | } 194 | 195 | .react-datetimerange-picker__inputGroup__input { 196 | color: white; 197 | } 198 | 199 | .react-datetimerange-picker__clock { 200 | display: none; 201 | } 202 | 203 | .react-datetimerange-picker__clock--open { 204 | display: none; 205 | } 206 | -------------------------------------------------------------------------------- /src/components/ExportButton/ExportButton.css: -------------------------------------------------------------------------------- 1 | .ExportButton { 2 | margin: 0px 5px 0px 10px; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | .downloadButton { 9 | background-color: #4f5768; 10 | color: white; 11 | cursor: pointer; 12 | border: 1px solid #a9b0c1; 13 | border-radius: 5px; 14 | padding: 5px 5px 3px 5px; 15 | height: 33px; 16 | } 17 | -------------------------------------------------------------------------------- /src/components/ExportButton/ExportButton.jsx: -------------------------------------------------------------------------------- 1 | import { React } from 'react' 2 | import './ExportButton.css' 3 | import { useSelector } from 'react-redux' 4 | import DownloadIcon from '@mui/icons-material/Download' 5 | import { showApplicationAlert } from '../../utils/alertHelper' 6 | 7 | const ExportButton = () => { 8 | const _searchResults = useSelector((state) => state.mainSlice.searchResults) 9 | const _selectedCollection = useSelector( 10 | (state) => state.mainSlice.selectedCollection 11 | ) 12 | const _SearchDateRangeValue = useSelector( 13 | (state) => state.mainSlice.searchDateRangeValue 14 | ) 15 | function onExportClick() { 16 | if (!_searchResults) { 17 | showApplicationAlert('warning', 'no search results', 5000) 18 | return 19 | } 20 | const startDate = _SearchDateRangeValue[0].split('T')[0] 21 | const endDate = _SearchDateRangeValue[1].split('T')[0] 22 | const uniqueFileName = `${_selectedCollection}_${startDate}_${endDate}.geojson` 23 | 24 | const blob = new Blob([JSON.stringify(_searchResults)], { 25 | type: 'application/json' 26 | }) 27 | const url = URL.createObjectURL(blob) 28 | const a = document.createElement('a') 29 | a.href = url 30 | a.download = uniqueFileName 31 | document.body.appendChild(a) 32 | a.click() 33 | document.body.removeChild(a) 34 | URL.revokeObjectURL(url) 35 | } 36 | 37 | return ( 38 |
39 | 42 |
43 | ) 44 | } 45 | 46 | export default ExportButton 47 | -------------------------------------------------------------------------------- /src/components/LayerList/LayerList.css: -------------------------------------------------------------------------------- 1 | .LayerList { 2 | position: absolute; 3 | top: 130px; 4 | left: 50px; 5 | z-index: 1; 6 | background-color: rgba(34, 34, 34, 0.8); 7 | border-radius: 2px; 8 | min-width: 200px; 9 | max-width: 400px; 10 | max-height: 300px; 11 | font-size: 16px; 12 | display: flex; 13 | flex-direction: column; 14 | } 15 | 16 | .LayerListTitle { 17 | border-bottom: solid 2px #373d4d; 18 | height: 35px; 19 | margin-bottom: 8px; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .LayerListTitleText { 25 | margin-left: 15px; 26 | } 27 | 28 | .LayerListLayers { 29 | overflow-y: auto; 30 | margin-left: 14px; 31 | margin-bottom: 5px; 32 | } 33 | 34 | .LayerListLayer { 35 | display: flex; 36 | flex-direction: row; 37 | } 38 | 39 | .LayerListLayerContainer { 40 | display: block; 41 | position: relative; 42 | padding-left: 25px; 43 | margin-bottom: 12px; 44 | margin-right: 15px; 45 | cursor: pointer; 46 | font-size: 14px; 47 | -webkit-user-select: none; 48 | -moz-user-select: none; 49 | -ms-user-select: none; 50 | user-select: none; 51 | } 52 | 53 | .LayerListLayerContainer input { 54 | position: absolute; 55 | opacity: 0; 56 | cursor: pointer; 57 | height: 0; 58 | width: 0; 59 | } 60 | 61 | .LayerListCheckmark { 62 | position: absolute; 63 | top: 0; 64 | left: 0; 65 | height: 16px; 66 | width: 16px; 67 | background-color: #eee; 68 | } 69 | 70 | .LayerListLayerContainer:hover input ~ .LayerListCheckmark { 71 | background-color: #ccc; 72 | } 73 | 74 | .LayerListLayerContainer input:checked ~ .LayerListCheckmark { 75 | background-color: #81c05b; 76 | } 77 | 78 | .LayerListCheckmark:after { 79 | content: ''; 80 | position: absolute; 81 | display: none; 82 | } 83 | 84 | .LayerListLayerContainer input:checked ~ .LayerListCheckmark:after { 85 | display: block; 86 | } 87 | 88 | .LayerListLayerContainer .LayerListCheckmark:after { 89 | left: 5px; 90 | top: 1px; 91 | width: 4px; 92 | height: 8px; 93 | border: solid #fff; 94 | border-width: 0 3px 3px 0; 95 | -webkit-transform: rotate(45deg); 96 | -ms-transform: rotate(45deg); 97 | transform: rotate(45deg); 98 | } 99 | -------------------------------------------------------------------------------- /src/components/LayerList/LayerList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './LayerList.css' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import { setreferenceLayers } from '../../redux/slices/mainSlice' 5 | import { toggleReferenceLayerVisibility } from '../../utils/mapHelper' 6 | 7 | const LayerList = () => { 8 | const dispatch = useDispatch() 9 | const _referenceLayers = useSelector( 10 | (state) => state.mainSlice.referenceLayers 11 | ) 12 | function onLayerClicked(combinedLayerName) { 13 | const updatedLayers = _referenceLayers.map((layer) => 14 | layer.combinedLayerName === combinedLayerName 15 | ? { ...layer, visibility: !layer.visibility } 16 | : layer 17 | ) 18 | dispatch(setreferenceLayers(updatedLayers)) 19 | toggleReferenceLayerVisibility(combinedLayerName) 20 | } 21 | 22 | return ( 23 |
24 |
25 | Reference Layers 26 |
27 |
28 | {_referenceLayers.map((layer) => ( 29 |
30 | 39 |
40 | ))} 41 |
42 |
43 | ) 44 | } 45 | 46 | export default LayerList 47 | -------------------------------------------------------------------------------- /src/components/Layout/Content/Content.css: -------------------------------------------------------------------------------- 1 | .Content { 2 | height: calc(100% - 64px); 3 | width: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | color: #d3d3d3; 7 | font-size: 20px; 8 | position: absolute; 9 | top: 64px; 10 | } 11 | 12 | @media screen and (max-width: 730px) { 13 | .Content { 14 | height: calc(100% - 64px); 15 | top: 64px; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Layout/Content/Content.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './Content.css' 3 | import RightContent from './RightContent/RightContent' 4 | import LeftContent from './LeftContent/LeftContent' 5 | 6 | const Content = () => { 7 | return ( 8 |
9 | 10 | 11 |
12 | ) 13 | } 14 | 15 | export default Content 16 | -------------------------------------------------------------------------------- /src/components/Layout/Content/LeftContent/LeftContent.css: -------------------------------------------------------------------------------- 1 | .LeftContent { 2 | height: 100%; 3 | width: 300px; 4 | background-color: #12171a; 5 | } 6 | 7 | .LeftContentTabs { 8 | height: 50px; 9 | min-height: 50px; 10 | width: 100%; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | } 15 | 16 | .LeftContentTab { 17 | width: 50%; 18 | height: 100%; 19 | display: flex; 20 | align-items: center; 21 | justify-content: center; 22 | color: #fff; 23 | font-size: 18px; 24 | text-align: center; 25 | text-decoration: none; 26 | background-color: #373d4d; 27 | cursor: pointer; 28 | border: none; 29 | border-radius: 0px; 30 | border-bottom: 4px solid #373d4d; 31 | border-top: 4px solid #373d4d; 32 | font-weight: bold; 33 | } 34 | 35 | .LeftContentTabSelected { 36 | border-bottom: 4px solid #6cc24a; 37 | } 38 | 39 | .LeftContentSelectedTab { 40 | margin-left: 15px; 41 | margin-right: 15px; 42 | display: flex; 43 | flex-direction: column; 44 | justify-content: space-between; 45 | height: calc(100% - 50px); 46 | } 47 | 48 | .LeftContentHolder { 49 | display: flex; 50 | flex-direction: column; 51 | justify-content: space-between; 52 | height: 100%; 53 | } 54 | 55 | .searchButton { 56 | width: 100%; 57 | } 58 | 59 | .ItemDetails { 60 | display: flex; 61 | flex-direction: column; 62 | align-items: center; 63 | justify-content: center; 64 | height: 100%; 65 | width: 100%; 66 | } 67 | 68 | .disableSearchOverlay { 69 | height: 100%; 70 | width: 300px; 71 | background-color: #12171a; 72 | opacity: 0.7; 73 | z-index: 10; 74 | position: absolute; 75 | left: 0px; 76 | } 77 | 78 | .popupResultsEmptyPrimaryText { 79 | font-weight: bold; 80 | margin-bottom: 20px; 81 | } 82 | 83 | .popupResultsEmptySecondaryText { 84 | font-size: 14px; 85 | max-width: 80%; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/Layout/Content/LeftContent/LeftContent.jsx: -------------------------------------------------------------------------------- 1 | import { React, useEffect } from 'react' 2 | import './LeftContent.css' 3 | import Search from '../../../Search/Search' 4 | import PopupResults from '../../../PopupResults/PopupResults' 5 | import { useSelector, useDispatch } from 'react-redux' 6 | import { debounceNewSearch } from '../../../../utils/searchHelper' 7 | import { 8 | settabSelected, 9 | sethasLeftPanelTabChanged 10 | } from '../../../../redux/slices/mainSlice' 11 | 12 | const LeftContent = () => { 13 | const dispatch = useDispatch() 14 | 15 | const _clickResults = useSelector((state) => state.mainSlice.clickResults) 16 | const _searchLoading = useSelector((state) => state.mainSlice.searchLoading) 17 | const _isDrawingEnabled = useSelector( 18 | (state) => state.mainSlice.isDrawingEnabled 19 | ) 20 | const _tabSelected = useSelector((state) => state.mainSlice.tabSelected) 21 | 22 | useEffect(() => { 23 | document.addEventListener('keydown', handleKeyPress) 24 | return () => { 25 | document.removeEventListener('keydown', handleKeyPress) 26 | } 27 | }, []) 28 | 29 | const handleKeyPress = (event) => { 30 | if (event.ctrlKey && event.key === ' ') { 31 | debounceNewSearch() 32 | } 33 | } 34 | 35 | function setFiltersTab() { 36 | dispatch(settabSelected('filters')) 37 | } 38 | function setDetailsTab() { 39 | dispatch(settabSelected('details')) 40 | dispatch(sethasLeftPanelTabChanged(true)) 41 | } 42 | 43 | return ( 44 |
45 |
46 | {_isDrawingEnabled || _searchLoading ? ( 47 |
51 | ) : null} 52 |
53 | 63 | 73 |
74 |
75 | {_tabSelected === 'filters' ? ( 76 | 77 | ) : ( 78 |
79 | 80 |
81 | )} 82 |
83 |
84 |
85 | ) 86 | } 87 | 88 | export default LeftContent 89 | -------------------------------------------------------------------------------- /src/components/Layout/Content/LeftContent/LeftContent.test.jsx: -------------------------------------------------------------------------------- 1 | import { describe, it, vi } from 'vitest' 2 | import React from 'react' 3 | import { render, screen } from '@testing-library/react' 4 | import LeftContent from './LeftContent' 5 | import { Provider } from 'react-redux' 6 | import { store } from '../../../../redux/store' 7 | import { 8 | setappConfig, 9 | setSearchLoading, 10 | settabSelected 11 | } from '../../../../redux/slices/mainSlice' 12 | import { mockAppConfig } from '../../../../testing/shared-mocks' 13 | import userEvent from '@testing-library/user-event' 14 | 15 | describe('LeftContent', () => { 16 | const user = userEvent.setup() 17 | const setup = () => 18 | render( 19 | 20 | 21 | 22 | ) 23 | 24 | beforeEach(() => { 25 | store.dispatch(setappConfig(mockAppConfig)) 26 | vi.mock('../../../../utils/mapHelper') 27 | }) 28 | afterEach(() => { 29 | vi.resetAllMocks() 30 | }) 31 | 32 | describe('on render', () => { 33 | it('should render Search', () => { 34 | setup() 35 | expect(screen.queryByTestId('Search')).toBeInTheDocument() 36 | }) 37 | }) 38 | 39 | describe('when search loading', () => { 40 | it('should render disabled search bar overlay div', async () => { 41 | store.dispatch(setSearchLoading(true)) 42 | store.dispatch(setappConfig(mockAppConfig)) 43 | setup() 44 | expect( 45 | screen.queryByTestId('test_disableSearchOverlay') 46 | ).toBeInTheDocument() 47 | }) 48 | }) 49 | 50 | describe('on user actions', () => { 51 | describe('on Item Details tab clicked', () => { 52 | it('should not render search results', async () => { 53 | setup() 54 | expect(screen.queryByTestId('Search')).toBeInTheDocument() 55 | const itemDetailsButton = screen.getByRole('button', { 56 | name: /item details/i 57 | }) 58 | await user.click(itemDetailsButton) 59 | expect(screen.queryByTestId('Search')).not.toBeInTheDocument() 60 | expect(screen.queryByTestId('testPopupResults')).toBeInTheDocument() 61 | }) 62 | }) 63 | describe('on filters tab clicked', () => { 64 | it('should render search results', async () => { 65 | store.dispatch(settabSelected('item details')) 66 | setup() 67 | expect(screen.queryByTestId('Search')).not.toBeInTheDocument() 68 | expect(screen.queryByTestId('testPopupResults')).toBeInTheDocument() 69 | const filtersButton = screen.getByRole('button', { 70 | name: /filters/i 71 | }) 72 | await user.click(filtersButton) 73 | expect(screen.queryByTestId('Search')).toBeInTheDocument() 74 | expect(screen.queryByTestId('testPopupResults')).not.toBeInTheDocument() 75 | }) 76 | }) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/components/Layout/PageHeader/PageHeader.css: -------------------------------------------------------------------------------- 1 | .PageHeader { 2 | width: 100%; 3 | height: 64px; 4 | background-color: #12171a; 5 | color: #a9b0c1; 6 | display: flex; 7 | align-items: stretch; 8 | justify-content: space-between; 9 | position: absolute; 10 | -webkit-touch-callout: none; /* iOS Safari */ 11 | -webkit-user-select: none; /* Safari */ 12 | -khtml-user-select: none; /* Konqueror HTML */ 13 | -moz-user-select: none; /* Old versions of Firefox */ 14 | -ms-user-select: none; /* Internet Explorer/Edge */ 15 | user-select: none; /* Non-prefixed version, currently 16 | supported by Chrome, Edge, Opera and Firefox */ 17 | } 18 | 19 | .pageHeaderRightButtons { 20 | display: flex; 21 | flex-direction: row; 22 | } 23 | 24 | .pageHeaderLeft { 25 | padding: 15px; 26 | display: flex; 27 | align-items: center; 28 | width: 50%; 29 | } 30 | 31 | .dashboardLink { 32 | margin-right: 10px; 33 | font-size: 16px; 34 | height: 100%; 35 | display: flex; 36 | align-items: center; 37 | justify-content: center; 38 | } 39 | 40 | .pageHeaderRight { 41 | padding: 15px; 42 | display: flex; 43 | align-items: center; 44 | } 45 | 46 | .headerLogoImage { 47 | max-width: 100%; 48 | max-height: 30px; 49 | min-width: 150px; 50 | } 51 | 52 | .filmDrop { 53 | padding: 6px; 54 | min-width: 90px; 55 | } 56 | 57 | .pageHeaderLink { 58 | margin-left: 0px; 59 | margin-right: 10px; 60 | border: 1px #76829c solid; 61 | padding: 5px 20px; 62 | font-weight: bold; 63 | display: flex; 64 | align-items: center; 65 | border-radius: 6px; 66 | background: transparent; 67 | transition: all 0.33s ease-in-out; 68 | } 69 | 70 | .OpenIcon { 71 | padding-left: 10px; 72 | height: 100%; 73 | transition: all 0.33s ease-in-out; 74 | } 75 | 76 | .pageHeaderLink:hover { 77 | cursor: pointer; 78 | border: 1px #6cc24a solid; 79 | background: #6cc24a; 80 | color: #12171a; 81 | } 82 | 83 | .cartButtonHeaderBar { 84 | margin-left: 10px; 85 | } 86 | 87 | .logoutButton { 88 | background-color: #4f5768; 89 | color: white; 90 | padding: 10px; 91 | font-size: 16px; 92 | cursor: pointer; 93 | border: 1px solid #a9b0c1; 94 | border-radius: 5px; 95 | } 96 | 97 | .logoutButton:hover { 98 | background-color: #353d4f; 99 | } 100 | 101 | @media screen and (max-width: 950px) { 102 | .buttonLink { 103 | display: none; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/components/Layout/PageHeader/PageHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './PageHeader.css' 3 | import { OpenInNew } from '@mui/icons-material' 4 | import logoFilmDrop from '../../../assets/logo-filmdrop-e84.png' 5 | import { useSelector } from 'react-redux' 6 | import { Stack } from '@mui/material' 7 | import CartButton from '../../Cart/CartButton/CartButton' 8 | import { logoutUser } from '../../../utils/authHelper' 9 | 10 | const PageHeader = () => { 11 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 12 | 13 | function onDashboardClick() { 14 | window.open(_appConfig.DASHBOARD_BTN_URL, '_blank') 15 | } 16 | 17 | function onAnalyzeClick() { 18 | window.open(_appConfig.ANALYZE_BTN_URL, '_blank') 19 | } 20 | 21 | function onLogoutClick() { 22 | logoutUser() 23 | } 24 | 25 | return ( 26 |
27 |
28 | {_appConfig.LOGO_URL ? ( 29 | {_appConfig.LOGO_ALT} 34 | ) : ( 35 | FilmDrop default app logo 44 | )} 45 |
46 |
47 |
48 | {_appConfig.ANALYZE_BTN_URL && ( 49 | onAnalyzeClick()} 53 | > 54 | 55 | Analyze 56 | 57 | 58 | 59 | )} 60 | {_appConfig.DASHBOARD_BTN_URL && ( 61 | onDashboardClick()} 65 | > 66 | 67 | Dashboard 68 | 69 | 70 | 71 | )} 72 |
73 | {!('SHOW_BRAND_LOGO' in _appConfig) || _appConfig.SHOW_BRAND_LOGO ? ( 74 | 78 | FilmDrop by Element 84 83 | 84 | ) : null} 85 | {_appConfig.CART_ENABLED ? ( 86 | 87 | 88 | 89 | ) : null} 90 | {_appConfig.APP_TOKEN_AUTH_ENABLED ? ( 91 | 92 | 95 | 96 | ) : null} 97 |
98 |
99 | ) 100 | } 101 | 102 | export default PageHeader 103 | -------------------------------------------------------------------------------- /src/components/LeafMap/LeafMap.css: -------------------------------------------------------------------------------- 1 | @import 'leaflet/dist/leaflet.css'; 2 | @import 'leaflet-draw/dist/leaflet.draw.css'; 3 | 4 | .LeafMap { 5 | color: #222; 6 | width: 100%; 7 | background-color: #12171a; 8 | position: relative; 9 | } 10 | 11 | .mainMap { 12 | height: 100%; 13 | width: 100%; 14 | z-index: 1; 15 | } 16 | 17 | .LeafMap { 18 | color: #222; 19 | width: 100%; 20 | background-color: lightblue; 21 | } 22 | 23 | .mainMap { 24 | height: 100%; 25 | width: 100%; 26 | } 27 | 28 | .map-tiles { 29 | filter: brightness(1.125) invert(1) contrast(0.9) hue-rotate(0deg) saturate(0); 30 | } 31 | 32 | path.leaflet-interactive:focus { 33 | outline: none; 34 | } 35 | 36 | .leaflet-container:focus { 37 | outline: none; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/LeafMap/LeafMap.jsx: -------------------------------------------------------------------------------- 1 | import { React, useEffect, useState, useRef } from 'react' 2 | import './LeafMap.css' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { 5 | setMap, 6 | setmapDrawPolygonHandler, 7 | setshowMapAttribution 8 | } from '../../redux/slices/mainSlice' 9 | import * as L from 'leaflet' 10 | import 'leaflet-draw' 11 | import { MapContainer } from 'react-leaflet/MapContainer' 12 | import { TileLayer } from 'react-leaflet/TileLayer' 13 | import { SearchControl, OpenStreetMapProvider } from 'leaflet-geosearch' 14 | import 'leaflet-geosearch/dist/geosearch.css' 15 | import { 16 | mapClickHandler, 17 | setMosaicZoomMessage, 18 | addReferenceLayersToMap 19 | } from '../../utils/mapHelper' 20 | import { setScenesForCartLayer } from '../../utils/dataHelper' 21 | import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../defaults' 22 | 23 | const LeafMap = () => { 24 | const dispatch = useDispatch() 25 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 26 | const _cartItems = useSelector((state) => state.mainSlice.cartItems) 27 | // set map ref to itself with useRef 28 | const mapRef = useRef() 29 | 30 | const [map, setLocalMap] = useState({}) 31 | const [mapTouched, setmapTouched] = useState(false) 32 | 33 | const mapMarkerIcon = L.icon({ 34 | iconSize: [25, 41], 35 | iconAnchor: [10, 41], 36 | popupAnchor: [2, -40], 37 | iconUrl: '/marker-icon.png', 38 | shadowUrl: '/marker-shadow.png' 39 | }) 40 | 41 | const searchControl = new SearchControl({ 42 | style: 'button', 43 | notFoundMessage: 'Sorry, that address could not be found.', 44 | provider: new OpenStreetMapProvider(), 45 | marker: { 46 | icon: mapMarkerIcon 47 | } 48 | }) 49 | 50 | useEffect(() => { 51 | if (mapRef) { 52 | setLocalMap(mapRef.current) 53 | } 54 | }, [mapRef.current]) 55 | 56 | useEffect(() => { 57 | setScenesForCartLayer() 58 | }, [_cartItems]) 59 | 60 | useEffect(() => { 61 | if (map && Object.keys(map).length) { 62 | // override position of zoom controls 63 | L.control 64 | .zoom({ 65 | position: 'topleft' 66 | }) 67 | .addTo(map) 68 | // add geosearch/geocoder to map 69 | map.addControl(searchControl) 70 | 71 | // setup custom panes for results 72 | map.createPane('searchResults') 73 | map.getPane('searchResults').style.zIndex = 600 74 | 75 | map.createPane('imagery') 76 | map.getPane('imagery').style.zIndex = 650 77 | map.getPane('imagery').style.pointerEvents = 'none' 78 | 79 | map.createPane('drawPane') 80 | map.getPane('drawPane').style.zIndex = 700 81 | 82 | // override existing panes for draw controls 83 | map.getPane('overlayPane').style.zIndex = 700 84 | map.getPane('markerPane').style.zIndex = 700 85 | 86 | // setup max map bounds 87 | const southWest = L.latLng(-90, -180) 88 | const northEast = L.latLng(90, 180) 89 | const bounds = L.latLngBounds(southWest, northEast) 90 | map.setMaxBounds(bounds) 91 | 92 | map.on('drag', function () { 93 | map.panInsideBounds(bounds, { animate: false }) 94 | }) 95 | 96 | // set up map layers 97 | const referenceLayerGroup = L.layerGroup().addTo(map) 98 | referenceLayerGroup.layer_name = 'referenceLayerGroup' 99 | 100 | const resultFootprintsInit = new L.FeatureGroup() 101 | resultFootprintsInit.addTo(map) 102 | resultFootprintsInit.layer_name = 'searchResultsLayer' 103 | 104 | const cartFootprintsInit = new L.FeatureGroup() 105 | cartFootprintsInit.addTo(map) 106 | cartFootprintsInit.layer_name = 'cartFootprintsLayer' 107 | cartFootprintsInit.eachLayer(function (layer) { 108 | layer.on('mouseover', function (e) { 109 | map.getContainer().style.cursor = 'default' 110 | }) 111 | layer.on('mouseout', function (e) { 112 | map.getContainer().style.cursor = '' 113 | }) 114 | }) 115 | 116 | const clickedFootprintsHighlightInit = new L.FeatureGroup() 117 | clickedFootprintsHighlightInit.addTo(map) 118 | clickedFootprintsHighlightInit.layer_name = 'clickedSceneHighlightLayer' 119 | 120 | const clickedFootprintImageLayerInit = new L.FeatureGroup() 121 | clickedFootprintImageLayerInit.addTo(map) 122 | clickedFootprintImageLayerInit.layer_name = 'clickedSceneImageLayer' 123 | 124 | const mosaicImageLayerInit = new L.FeatureGroup() 125 | mosaicImageLayerInit.addTo(map) 126 | mosaicImageLayerInit.layer_name = 'mosaicImageLayer' 127 | 128 | const drawBounds = new L.FeatureGroup() 129 | drawBounds.pane = 'drawPane' 130 | drawBounds.addTo(map) 131 | drawBounds.layer_name = 'drawBoundsLayer' 132 | 133 | // eslint-disable-next-line no-new 134 | new L.Control.Draw({ 135 | edit: { 136 | featureGroup: drawBounds 137 | } 138 | }) 139 | 140 | const drawPolygonHandler = new L.Draw.Polygon(map, { 141 | shapeOptions: { color: '#00C07B' } 142 | }) 143 | 144 | dispatch(setmapDrawPolygonHandler(drawPolygonHandler)) 145 | 146 | // set up map events 147 | map.on('zoomend', function () { 148 | setMosaicZoomMessage() 149 | if (!mapTouched) { 150 | setmapTouched(true) 151 | dispatch(setshowMapAttribution(false)) 152 | } 153 | }) 154 | 155 | map.on('click', mapClickHandler) 156 | 157 | map.on('mousedown', function () { 158 | if (!mapTouched) { 159 | setmapTouched(true) 160 | dispatch(setshowMapAttribution(false)) 161 | } 162 | }) 163 | 164 | // push map into redux state 165 | dispatch(setMap(map)) 166 | 167 | addReferenceLayersToMap() 168 | } 169 | }, [map]) 170 | 171 | return ( 172 |
173 | {/* this sets up the base of the map component and a few default params */} 174 | 185 | {/* set basemap layers here: */} 186 | 193 | 194 |
195 | ) 196 | } 197 | 198 | export default LeafMap 199 | -------------------------------------------------------------------------------- /src/components/Legend/HeatMapSymbology/HeatMapSymbology.css: -------------------------------------------------------------------------------- 1 | .HeatMapSymbology { 2 | width: 150px; 3 | margin-top: 10px; 4 | } 5 | 6 | .HeatMapSymbology .gradient { 7 | width: 100%; 8 | height: 20px; 9 | border-bottom: 1px solid rgba(255, 255, 255, 0.5); 10 | opacity: 0.7; 11 | } 12 | 13 | .HeatMapSymbology .values { 14 | width: 100%; 15 | display: flex; 16 | flex-direction: row; 17 | justify-content: space-between; 18 | font-size: 0.6em; 19 | letter-spacing: 0.025em; 20 | color: white; 21 | position: relative; 22 | background: rgba(18, 23, 26, 0.5); 23 | } 24 | 25 | .HeatMapSymbology .values .min { 26 | border-left: 1px solid rgba(255, 255, 255, 0.5); 27 | padding-left: 5px; 28 | padding-top: 5px; 29 | } 30 | 31 | .HeatMapSymbology .values .max { 32 | border-right: 1px solid rgba(255, 255, 255, 0.5); 33 | padding-right: 5px; 34 | padding-top: 5px; 35 | } 36 | 37 | .HeatMapSymbology .values .mid { 38 | border-right: 1px solid rgba(255, 255, 255, 0.5); 39 | position: absolute; 40 | top: 0; 41 | left: 50%; 42 | height: 13px; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Legend/HeatMapSymbology/HeatMapSymbology.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import './HeatMapSymbology.css' 4 | import { colorMap } from '../../../utils' 5 | 6 | const HeatMapSymbology = ({ results }) => { 7 | const colors = colorMap(results.properties.largestRatio) 8 | 9 | return ( 10 |
11 |
21 |
22 |
0
23 |
24 |
{results.properties.largestFrequency}
25 |
26 |
27 | ) 28 | } 29 | 30 | HeatMapSymbology.propTypes = { 31 | results: PropTypes.object 32 | } 33 | 34 | export default HeatMapSymbology 35 | -------------------------------------------------------------------------------- /src/components/Legend/LayerLegend/LayerLegend.css: -------------------------------------------------------------------------------- 1 | .LayerLegend { 2 | position: absolute; 3 | z-index: 5; 4 | bottom: 20px; 5 | left: 20px; 6 | padding: 10px; 7 | background-color: #13171a; 8 | border-radius: 5px; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | font-size: 14px; 13 | opacity: 0.9; 14 | } 15 | 16 | .hexLegendContainer { 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | 21 | .hexLegendLabel { 22 | font-weight: bold; 23 | } 24 | 25 | .legendRow { 26 | margin-top: 4px; 27 | margin-bottom: 8px; 28 | display: flex; 29 | flex-direction: row; 30 | } 31 | 32 | .legendSymbol { 33 | width: 14px; 34 | height: fill; 35 | border-width: 2px; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | margin-right: 8px; 40 | } 41 | 42 | .searchAreaLegendSymbol { 43 | border-color: #00c07b; 44 | border-style: dashed; 45 | } 46 | 47 | .searchAreaLegendIcon { 48 | height: fill; 49 | width: 10px; 50 | margin-right: 8px; 51 | } 52 | 53 | .sceneLegendSymbol { 54 | border-color: #3183f5; 55 | background-color: #3183f52e; 56 | border-style: solid; 57 | } 58 | 59 | .sceneInCartLegendSymbol { 60 | border-color: #ad5c11; 61 | background-color: #ad5c1129; 62 | border-style: solid; 63 | } 64 | 65 | .sceneLegend { 66 | display: flex; 67 | flex-direction: column; 68 | } 69 | -------------------------------------------------------------------------------- /src/components/Legend/LayerLegend/LayerLegend.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './LayerLegend.css' 3 | import { useSelector } from 'react-redux' 4 | import HeatMapSymbology from '../HeatMapSymbology/HeatMapSymbology' 5 | 6 | const LayerLegend = () => { 7 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 8 | const _searchType = useSelector((state) => state.mainSlice.searchType) 9 | const _searchGeojsonBoundary = useSelector( 10 | (state) => state.mainSlice.searchGeojsonBoundary 11 | ) 12 | const _searchResults = useSelector((state) => state.mainSlice.searchResults) 13 | const _cartItems = useSelector((state) => state.mainSlice.cartItems) 14 | return ( 15 |
16 | {_appConfig.CART_ENABLED && _cartItems.length > 0 ? ( 17 |
18 |
19 | Scenes in cart 20 |
21 | ) : null} 22 | {_searchType === 'scene' && ( 23 |
24 |
25 |
26 | Available scene 27 |
28 |
29 | )} 30 | {_searchType === 'grid-code' && _searchResults && ( 31 |
32 |
33 | Scene aggregation 34 |
35 | )} 36 | {_appConfig.SEARCH_BY_GEOM_ENABLED && _searchGeojsonBoundary && ( 37 |
38 |
39 | Search area point 44 | Search area 45 |
46 | )} 47 | {_searchType === 'hex' && 48 | _searchResults?.searchType === 'AggregatedResults' && ( 49 |
50 | Aggregate Scenes 51 | 52 |
53 | )} 54 |
55 | ) 56 | } 57 | 58 | export default LayerLegend 59 | -------------------------------------------------------------------------------- /src/components/Legend/LayerLegend/LayerLegend.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import LayerLegend from './LayerLegend' 4 | import { Provider } from 'react-redux' 5 | import { store } from '../../../redux/store' 6 | import { 7 | setSearchResults, 8 | setappConfig, 9 | setSearchType, 10 | setcartItems 11 | } from '../../../redux/slices/mainSlice' 12 | import { 13 | mockAppConfig, 14 | mockGridAggregateSearchResult, 15 | mockHexAggregateSearchResult, 16 | mockSceneSearchResult 17 | } from '../../../testing/shared-mocks' 18 | 19 | describe('LayerLegend', () => { 20 | const setup = () => 21 | render( 22 | 23 | 24 | 25 | ) 26 | 27 | beforeEach(() => { 28 | store.dispatch(setappConfig(mockAppConfig)) 29 | }) 30 | 31 | describe('on conditional render', () => { 32 | describe('confirm conditional cart render', () => { 33 | it('should render scenes in cart legend item if cart enabled in config and cart has items', () => { 34 | const mockAppConfigSearchEnabled = { 35 | ...mockAppConfig, 36 | CART_ENABLED: 'true' 37 | } 38 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 39 | store.dispatch(setcartItems([mockSceneSearchResult])) 40 | setup() 41 | expect(screen.queryByText(/scenes in cart/i)).toBeInTheDocument() 42 | }) 43 | it('should not render scenes in cart legend item if cart enabled in config but cart has no items', () => { 44 | const mockAppConfigSearchEnabled = { 45 | ...mockAppConfig, 46 | CART_ENABLED: 'true' 47 | } 48 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 49 | setup() 50 | expect(screen.queryByText(/scenes in cart/i)).not.toBeInTheDocument() 51 | }) 52 | it('should not render scenes in cart legend item if cart not enabled in config', () => { 53 | setup() 54 | expect(screen.queryByText(/scenes in cart/i)).not.toBeInTheDocument() 55 | }) 56 | }) 57 | describe('confirm conditional scene render', () => { 58 | it('should render available scene legend item if searchType set to scene in redux', () => { 59 | store.dispatch(setSearchType('scene')) 60 | setup() 61 | expect(screen.queryByText(/available scene/i)).toBeInTheDocument() 62 | }) 63 | it('should not render available scene legend item if searchType not set to scene in redux', () => { 64 | store.dispatch(setSearchType('grid-code')) 65 | setup() 66 | expect(screen.queryByText(/available scene/i)).not.toBeInTheDocument() 67 | }) 68 | }) 69 | describe('confirm conditional gird-code render', () => { 70 | it('should render grid-code legend item if searchType set to grid-code and searchResults set in redux', () => { 71 | store.dispatch(setSearchType('grid-code')) 72 | store.dispatch(setSearchResults(mockGridAggregateSearchResult)) 73 | setup() 74 | expect(screen.queryByText(/scene aggregation/i)).toBeInTheDocument() 75 | }) 76 | it('should not render grid-code legend item if searchType not set to grid-code', () => { 77 | store.dispatch(setSearchType('scene')) 78 | store.dispatch(setSearchResults({ type: 'Point', coordinates: [0, 0] })) 79 | setup() 80 | expect(screen.queryByText(/scene aggregation/i)).not.toBeInTheDocument() 81 | }) 82 | it('should not render grid-code legend item if searchResults not set in redux', () => { 83 | store.dispatch(setSearchType('grid-code')) 84 | setup() 85 | expect(screen.queryByText(/scene aggregation/i)).not.toBeInTheDocument() 86 | }) 87 | }) 88 | describe('confirm conditional search area render', () => { 89 | it('should not render search area legend item if searchGeojsonBoundary set in redux', () => { 90 | const mockAppConfigSearchEnabled = { 91 | ...mockAppConfig, 92 | SEARCH_BY_GEOM_ENABLED: 'true' 93 | } 94 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 95 | setup() 96 | expect(screen.queryByText(/search area/i)).not.toBeInTheDocument() 97 | }) 98 | }) 99 | describe('confirm conditional hex render', () => { 100 | it('should render hex legend item if searchType and searchResult set in redux and search result contains aggregated results', () => { 101 | store.dispatch(setSearchType('hex')) 102 | store.dispatch(setSearchResults(mockHexAggregateSearchResult)) 103 | setup() 104 | expect(screen.queryByText(/aggregate scenes/i)).toBeInTheDocument() 105 | }) 106 | it('should not render hex legend item if searchType not set to hex in redux', () => { 107 | store.dispatch(setSearchType('grid-code')) 108 | store.dispatch(setSearchResults(mockHexAggregateSearchResult)) 109 | setup() 110 | expect(screen.queryByText(/aggregate scenes/i)).not.toBeInTheDocument() 111 | }) 112 | it('should not render hex legend item if searchResult not set in redux', () => { 113 | store.dispatch(setSearchType('hex')) 114 | setup() 115 | expect(screen.queryByText(/aggregate scenes/i)).not.toBeInTheDocument() 116 | }) 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/components/LoadingAnimation/LoadingAnimation.css: -------------------------------------------------------------------------------- 1 | .animated-loader { 2 | display: inline-block; 3 | position: relative; 4 | width: 60px; 5 | height: 100px; 6 | text-align: center; 7 | } 8 | 9 | .animated-loader svg { 10 | width: 60px; 11 | height: 100px; 12 | display: block; 13 | transform: rotate(-45deg); 14 | } 15 | 16 | .animated-loader svg path { 17 | stroke: #ccc; 18 | stroke-width: 5; 19 | fill: transparent; 20 | stroke-dasharray: 250; 21 | animation: strokePath 2s linear infinite; 22 | } 23 | 24 | @keyframes strokePath { 25 | 0% { 26 | stroke-dashoffset: 500; 27 | } 28 | 29 | 100% { 30 | stroke-dashoffset: 0; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/LoadingAnimation/LoadingAnimation.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './LoadingAnimation.css' 3 | 4 | const LoadingAnimation = () => { 5 | return ( 6 |
7 | 15 | 19 | 20 | 21 |
22 | ) 23 | } 24 | 25 | export default LoadingAnimation 26 | -------------------------------------------------------------------------------- /src/components/Login/Login.css: -------------------------------------------------------------------------------- 1 | .Login { 2 | height: 100%; 3 | width: 100%; 4 | display: flex; 5 | color: #fff; 6 | align-items: center; 7 | justify-content: center; 8 | flex-direction: column; 9 | background-color: #12171a; 10 | color: #fff; 11 | } 12 | 13 | .loginFormContainer { 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | min-width: 400px; 19 | min-height: 350px; 20 | background-color: #373d4d; 21 | border-radius: 0px 0px 10px 10px; 22 | } 23 | 24 | .loginFormLogo { 25 | border: 3px #373d4d solid; 26 | min-width: 394px; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | height: 100px; 31 | border-radius: 10px 10px 0px 0px; 32 | } 33 | 34 | .loginLogoImage { 35 | max-width: 300px; 36 | max-height: 40px; 37 | } 38 | 39 | .loginFormContainer h1 { 40 | margin-top: 0px; 41 | } 42 | 43 | .submitForm { 44 | width: 250px; 45 | display: flex; 46 | flex-direction: column; 47 | align-items: center; 48 | justify-content: center; 49 | } 50 | 51 | .submitForm label { 52 | width: 100%; 53 | margin-bottom: 10px; 54 | font-size: 18px; 55 | } 56 | 57 | .submitForm input { 58 | width: 100%; 59 | font-size: 18px; 60 | margin-bottom: 5px; 61 | } 62 | 63 | .submitForm button { 64 | margin-top: 20px; 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Login/Login.jsx: -------------------------------------------------------------------------------- 1 | import { React, useState, useEffect } from 'react' 2 | import './Login.css' 3 | import { AuthService } from '../../services/post-auth-service' 4 | import { useSelector } from 'react-redux' 5 | 6 | const Login = () => { 7 | const [username, setUsername] = useState('') 8 | const [password, setPassword] = useState('') 9 | const [isSubmitting, setIsSubmitting] = useState(false) 10 | 11 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 12 | 13 | const handleUsernameChange = (e) => { 14 | setUsername(e.target.value) 15 | } 16 | 17 | const handlePasswordChange = (e) => { 18 | setPassword(e.target.value) 19 | } 20 | 21 | const submitLogin = async (e) => { 22 | e.preventDefault() 23 | setIsSubmitting(true) 24 | try { 25 | await AuthService(username, password) 26 | } catch (error) { 27 | console.error('Login failed', error) 28 | } finally { 29 | setIsSubmitting(false) 30 | } 31 | } 32 | 33 | useEffect(() => { 34 | document.title = 'Login' 35 | }, []) 36 | 37 | return ( 38 |
39 |
40 | {_appConfig.LOGO_URL ? ( 41 | {_appConfig.LOGO_ALT} 46 | ) : ( 47 | FilmDrop default app logo 56 | )} 57 |
58 |
59 |

Login

60 |
61 | 62 | 68 | 69 | 75 | 86 |
87 |
88 |
89 | ) 90 | } 91 | 92 | export default Login 93 | -------------------------------------------------------------------------------- /src/components/PopupResult/PopupResult.css: -------------------------------------------------------------------------------- 1 | .popupResult { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .popupResultThumbnailContainer { 7 | display: flex; 8 | flex-direction: row; 9 | align-items: center; 10 | justify-content: center; 11 | margin-top: 15px; 12 | margin-bottom: 10px; 13 | min-height: 230px; 14 | } 15 | 16 | .popupResultThumbnail { 17 | width: 225px; 18 | } 19 | 20 | .popupResultDetails { 21 | font-size: 16px; 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: center; 25 | align-items: center; 26 | margin-bottom: 10px; 27 | } 28 | 29 | .detailRow { 30 | width: 240px; 31 | display: flex; 32 | flex-direction: column; 33 | line-height: 1.6; 34 | } 35 | 36 | .popupResultDetailsRowKey { 37 | font-weight: bold; 38 | color: #fff; 39 | } 40 | 41 | .popupResultDetailsRowValue { 42 | font-size: 14px; 43 | word-wrap: break-word; 44 | } 45 | 46 | .popupResultDetailsHrefLink { 47 | color: #d3d3d3; 48 | text-decoration: underline; 49 | width: 100%; 50 | font-size: 14px; 51 | margin-bottom: 5px; 52 | } 53 | 54 | .popupResultDetailsHrefLink:hover { 55 | color: #fff; 56 | } 57 | 58 | /* width */ 59 | ::-webkit-scrollbar { 60 | width: 10px; 61 | height: 10px; 62 | } 63 | 64 | /* Track */ 65 | ::-webkit-scrollbar-track { 66 | background: #dedede; 67 | } 68 | 69 | /* Handle */ 70 | ::-webkit-scrollbar-thumb { 71 | background: #888; 72 | } 73 | 74 | /* Handle on hover */ 75 | ::-webkit-scrollbar-thumb:hover { 76 | background: #555; 77 | } 78 | -------------------------------------------------------------------------------- /src/components/PopupResult/PopupResult.jsx: -------------------------------------------------------------------------------- 1 | import { React, useEffect, useState } from 'react' 2 | import PropTypes from 'prop-types' 3 | import './PopupResult.css' 4 | import { useSelector } from 'react-redux' 5 | import { processDisplayFieldValues } from '../../utils/dataHelper' 6 | import { debounceTitilerOverlay, zoomToItemExtent } from '../../utils/mapHelper' 7 | 8 | const PopupResult = (props) => { 9 | const _appConfig = useSelector((state) => state.mainSlice.appConfig) 10 | const _selectedCollectionData = useSelector( 11 | (state) => state.mainSlice.selectedCollectionData 12 | ) 13 | const _autoCenterOnItemChanged = useSelector( 14 | (state) => state.mainSlice.autoCenterOnItemChanged 15 | ) 16 | const [thumbnailURL, setthumbnailURL] = useState(null) 17 | 18 | useEffect(() => { 19 | if (props.result) { 20 | if (_autoCenterOnItemChanged) { 21 | zoomToItemExtent(props.result) 22 | } 23 | debounceTitilerOverlay(props.result) 24 | const thumbnailURLForSelection = props.result?.links?.find( 25 | ({ rel }) => rel === 'thumbnail' 26 | )?.href 27 | 28 | const image = new Image() 29 | image.onload = function () { 30 | if (this.width > 0) { 31 | setthumbnailURL(thumbnailURLForSelection) 32 | } 33 | } 34 | image.onerror = function () { 35 | setthumbnailURL('/ThumbnailNotAvailable.png') 36 | } 37 | image.src = thumbnailURLForSelection 38 | } 39 | // eslint-disable-next-line 40 | }, [props.result]) 41 | 42 | return ( 43 |
51 | {props.result ? ( 52 |
53 |
54 | {thumbnailURL ? ( 55 | 56 | thumbnail { 61 | currentTarget.onerror = null // prevents looping 62 | currentTarget.parentElement.remove() 63 | }} 64 | > 65 | 66 | ) : null} 67 |
68 | 69 |
70 | {_appConfig.STAC_LINK_ENABLED && ( 71 | 84 | )} 85 |
86 | 90 | Title: 91 | 92 | 96 | {props.result.id} 97 | 98 |
99 | {_appConfig.POPUP_DISPLAY_FIELDS && 100 | _selectedCollectionData.id in _appConfig.POPUP_DISPLAY_FIELDS && 101 | _appConfig.POPUP_DISPLAY_FIELDS[_selectedCollectionData.id].map( 102 | (field) => ( 103 |
104 | 108 | {field.charAt(0).toUpperCase() + field.slice(1) + ':'} 109 | 110 | 114 | {field === 'eo:cloud_cover' 115 | ? Math.round(props.result?.properties[field] * 100) / 116 | 100 + 117 | ' %' 118 | : processDisplayFieldValues( 119 | props.result?.properties[field] 120 | )} 121 | 122 |
123 | ) 124 | )} 125 |
126 |
127 | ) : null} 128 |
129 | ) 130 | } 131 | 132 | PopupResult.propTypes = { 133 | result: PropTypes.object 134 | } 135 | 136 | export default PopupResult 137 | -------------------------------------------------------------------------------- /src/components/PopupResult/PopupResult.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render, screen } from '@testing-library/react' 3 | import PopupResult from './PopupResult' 4 | import { Provider } from 'react-redux' 5 | import { store } from '../../redux/store' 6 | import { 7 | setSelectedCollectionData, 8 | setappConfig 9 | } from '../../redux/slices/mainSlice' 10 | import { 11 | mockAppConfig, 12 | mockClickResults, 13 | mockCollectionsData 14 | } from '../../testing/shared-mocks' 15 | import { describe } from 'vitest' 16 | 17 | describe('PopupResult', () => { 18 | const setup = () => 19 | render( 20 | 21 | 22 | 23 | ) 24 | 25 | beforeEach(() => { 26 | store.dispatch(setappConfig(mockAppConfig)) 27 | }) 28 | 29 | describe('on conditional render', () => { 30 | it('should render title field and no others if POPUP_DISPLAY_FIELDS not set in config', () => { 31 | const mockAppConfigSearchEnabled = { 32 | ...mockAppConfig 33 | } 34 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 35 | setup() 36 | expect(screen.queryByText(/title:/i)).toBeInTheDocument() 37 | }) 38 | it('should render other properties only if POPUP_DISPLAY_FIELDS set in config and collection exists in app', () => { 39 | const mockAppConfigSearchEnabled = { 40 | ...mockAppConfig, 41 | POPUP_DISPLAY_FIELDS: { 'cop-dem-glo-30': ['datetime'] } 42 | } 43 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 44 | store.dispatch(setSelectedCollectionData(mockCollectionsData[0])) 45 | setup() 46 | expect(screen.queryByText(/title:/i)).toBeInTheDocument() 47 | expect(screen.queryByText(/datetime:/i)).toBeInTheDocument() 48 | }) 49 | it('should not render other properties if POPUP_DISPLAY_FIELDS set in config but collection does not exists in app', () => { 50 | const mockAppConfigSearchEnabled = { 51 | ...mockAppConfig, 52 | POPUP_DISPLAY_FIELDS: { 'sentinel-2-l2a': ['datetime'] } 53 | } 54 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 55 | store.dispatch(setSelectedCollectionData(mockCollectionsData[0])) 56 | setup() 57 | expect(screen.queryByText(/title:/i)).toBeInTheDocument() 58 | expect(screen.queryByText(/datetime:/i)).not.toBeInTheDocument() 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/components/PopupResults/PopupResults.css: -------------------------------------------------------------------------------- 1 | .popupResultsContainer { 2 | height: 100%; 3 | width: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | .popupResults { 9 | height: 100%; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | 14 | .popupHeader { 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | justify-content: center; 19 | min-height: 50px; 20 | border-bottom: #353d4f 3px solid; 21 | } 22 | 23 | .popupHeaderTop { 24 | width: 100%; 25 | display: flex; 26 | flex-direction: row; 27 | } 28 | 29 | .popupHeaderBottom { 30 | display: flex; 31 | flex-direction: row; 32 | justify-content: center; 33 | height: 32px; 34 | width: 100%; 35 | margin-top: 5px; 36 | margin-bottom: 10px; 37 | } 38 | 39 | .popupHeaderBottomButton { 40 | background-color: transparent; 41 | color: #a9b0c1; 42 | border: 1px solid #a9b0c1; 43 | border-radius: 5px; 44 | height: 32px; 45 | font-size: 16px; 46 | width: 80%; 47 | display: flex; 48 | align-items: center; 49 | justify-content: center; 50 | cursor: pointer; 51 | transition: color 0.33s ease-in-out; 52 | -webkit-touch-callout: none; 53 | -webkit-user-select: none; 54 | user-select: none; 55 | } 56 | .popupHeaderBottomButton:hover { 57 | background-color: #252f36; 58 | color: #fff; 59 | } 60 | .popupHeaderBottomButtonDisabled { 61 | color: #646566; 62 | border: 1px solid #646566; 63 | cursor: default; 64 | } 65 | .popupHeaderBottomButtonDisabled:hover { 66 | background-color: transparent; 67 | color: #646566; 68 | } 69 | 70 | .popupResultsBottom { 71 | display: flex; 72 | flex-direction: row; 73 | justify-content: center; 74 | height: 32px; 75 | width: 100%; 76 | margin-top: 20px; 77 | margin-bottom: 20px; 78 | } 79 | 80 | .popupResultsBottomButton { 81 | background-color: transparent; 82 | color: #a9b0c1; 83 | border: 1px solid #a9b0c1; 84 | border-radius: 5px; 85 | height: 32px; 86 | font-size: 16px; 87 | width: 80%; 88 | display: flex; 89 | align-items: center; 90 | justify-content: center; 91 | cursor: pointer; 92 | transition: color 0.33s ease-in-out; 93 | -webkit-touch-callout: none; 94 | -webkit-user-select: none; 95 | user-select: none; 96 | } 97 | .popupResultsBottomButton:hover { 98 | background-color: #252f36; 99 | color: #fff; 100 | } 101 | 102 | .popupResultsContent { 103 | height: 100%; 104 | overflow-y: auto; 105 | } 106 | 107 | .popupResultsContentCartEnabled { 108 | height: calc(100% - 138px); 109 | } 110 | 111 | .popupResultContentText { 112 | height: 40px; 113 | font-size: 16px; 114 | display: flex; 115 | align-items: center; 116 | } 117 | 118 | .popupFooter { 119 | font-size: 18px; 120 | display: flex; 121 | flex-direction: row; 122 | align-items: center; 123 | justify-content: center; 124 | min-height: 50px; 125 | border-top: #353d4f 3px solid; 126 | } 127 | 128 | .popupFooterControls { 129 | width: 100%; 130 | display: flex; 131 | align-items: center; 132 | justify-content: space-between; 133 | } 134 | 135 | .popupFooterButtonsGroup { 136 | display: flex; 137 | flex-direction: row; 138 | } 139 | 140 | .popupFooterButton { 141 | background-color: transparent; 142 | color: #a9b0c1; 143 | border: 0px; 144 | font-weight: bold; 145 | height: 30px; 146 | width: 30px; 147 | border: #dedede solid 1px; 148 | display: flex; 149 | align-items: center; 150 | justify-content: center; 151 | cursor: pointer; 152 | transition: color 0.33s ease-in-out; 153 | -webkit-touch-callout: none; 154 | -webkit-user-select: none; 155 | user-select: none; 156 | } 157 | 158 | .popupFooterButton:hover { 159 | background-color: #252f36; 160 | color: #fff; 161 | } 162 | 163 | .popupFooterButtonRight { 164 | margin-left: -1px; 165 | border-top-right-radius: 5px; 166 | border-bottom-right-radius: 5px; 167 | } 168 | 169 | .popupFooterButtonLeft { 170 | margin-left: -1px; 171 | border-top-left-radius: 5px; 172 | border-bottom-left-radius: 5px; 173 | } 174 | 175 | .popupResultsEmpty { 176 | display: flex; 177 | align-items: center; 178 | justify-content: center; 179 | flex-direction: column; 180 | width: 100%; 181 | height: 100%; 182 | font-size: 16px; 183 | text-align: center; 184 | } 185 | -------------------------------------------------------------------------------- /src/components/Search/Search.css: -------------------------------------------------------------------------------- 1 | .Search { 2 | height: 100%; 3 | width: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | .searchFilters { 8 | height: calc(100% - 60px); 9 | margin-top: 15px; 10 | overflow-y: auto; 11 | overflow-x: hidden; 12 | } 13 | 14 | .searchButtonContainer { 15 | height: 60px; 16 | border-top: solid #4f5768 2px; 17 | display: flex; 18 | align-items: end; 19 | margin-bottom: 15px; 20 | } 21 | 22 | .searchContainer { 23 | color: #ffffff; 24 | font-size: 16px; 25 | margin-right: 5px; 26 | margin-bottom: 15px; 27 | } 28 | 29 | .searchContainer label { 30 | font-weight: bold; 31 | color: #fff; 32 | } 33 | 34 | .searchContainer.datePickerComponent label { 35 | position: relative; 36 | top: 2px; 37 | } 38 | 39 | .viewSelectorComponent { 40 | width: 100%; 41 | } 42 | 43 | .searchContainer.searchButton { 44 | flex-direction: column; 45 | align-items: center; 46 | justify-content: center; 47 | } 48 | .searchContainer.searchButton .linkButton { 49 | height: 45px; 50 | } 51 | 52 | .searchFilterContainer { 53 | width: 100%; 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | 58 | .searchByGeomOptionsButtons { 59 | width: 100%; 60 | display: flex; 61 | flex-direction: row; 62 | align-items: center; 63 | margin-top: 10px; 64 | } 65 | 66 | .searchByGeomOptionsButton { 67 | height: 32px; 68 | max-width: 100px; 69 | margin-right: 10px; 70 | background-color: #4f5768; 71 | color: white; 72 | padding: 15px 15px; 73 | text-align: center; 74 | text-decoration: none; 75 | font-size: 14px; 76 | cursor: pointer; 77 | border: 1px solid #a9b0c1; 78 | display: flex; 79 | align-items: center; 80 | justify-content: center; 81 | border-radius: 5px; 82 | } 83 | .searchByGeomOptionsButton:hover { 84 | background-color: #353d4f; 85 | } 86 | 87 | .searchByGeomOptionsButtonDisabled { 88 | opacity: 0.35; 89 | cursor: default; 90 | } 91 | .searchByGeomOptionsButtonDisabled:hover { 92 | background-color: #4f5768; 93 | } 94 | 95 | .searchByGeomOptions { 96 | margin-top: 8px; 97 | font-size: 14px; 98 | color: #a9b0c1; 99 | font-weight: 600; 100 | display: flex; 101 | align-items: center; 102 | justify-content: center; 103 | cursor: pointer; 104 | user-select: none; 105 | } 106 | 107 | /* Date Range Picker */ 108 | 109 | .datePickerComponent { 110 | z-index: 4; 111 | font-size: 16px; 112 | position: relative; 113 | min-height: 50px; 114 | margin-top: 15px; 115 | } 116 | 117 | .datePickerComponent label a svg { 118 | height: 16px; 119 | width: 16px; 120 | position: relative; 121 | top: 1px; 122 | left: 4px; 123 | margin-right: 10px; 124 | } 125 | 126 | .datePickerComponent .react-tooltip { 127 | font-weight: normal; 128 | line-height: 1.6; 129 | } 130 | 131 | /* Cloud Slider */ 132 | 133 | .searchContainer.cloudSlider { 134 | margin-bottom: 5px; 135 | } 136 | 137 | .searchContainer .MuiGrid-container { 138 | position: relative; 139 | top: 4px; 140 | } 141 | 142 | input[class$='-MuiInputBase-root-MuiInput-root']::before { 143 | border-bottom-color: #76829c !important; 144 | } 145 | 146 | input[class$='-MuiInputBase-input-MuiInput-input'] { 147 | text-align: center; 148 | font-family: 'Inter', Arial, Helvetica, sans-serif !important; 149 | color: white !important; 150 | } 151 | 152 | /* Leaflet Search Icon Overrides */ 153 | 154 | .leaflet-control-geosearch a.leaflet-bar-part:before { 155 | top: 17px !important; 156 | left: 15px !important; 157 | width: 7px !important; 158 | } 159 | 160 | .leaflet-control-geosearch a.leaflet-bar-part:after { 161 | height: 10px !important; 162 | width: 10px !important; 163 | } 164 | 165 | .leaflet-control-geosearch.leaflet-geosearch-bar { 166 | margin-left: 50px; 167 | max-width: 30%; 168 | } 169 | 170 | @media screen and (max-width: 975px) { 171 | .leaflet-control-geosearch.leaflet-geosearch-bar { 172 | max-width: 80%; 173 | } 174 | } 175 | 176 | /* Hover layer labels for grid cell search results */ 177 | 178 | .tooltip_style { 179 | font-weight: 600; 180 | background-color: #6cc24a; 181 | border: 0 none; 182 | text-align: center; 183 | line-height: 15px; 184 | font-size: 1.25em; 185 | pointer-events: none; 186 | display: flex; 187 | flex-direction: column; 188 | align-items: center; 189 | } 190 | 191 | .tooltip_style:before { 192 | border-top-color: #6cc24a; 193 | } 194 | 195 | .tooltip_style:after { 196 | border-bottom-color: #6cc24a; 197 | } 198 | 199 | .tooltip_style span { 200 | font-size: 0.675em; 201 | letter-spacing: 0.05em; 202 | } 203 | 204 | /* Layer styles for geo hex search results */ 205 | 206 | .label_style { 207 | font-weight: 400; 208 | border: 0 none; 209 | text-align: center; 210 | line-height: 15px; 211 | font-size: 1.25em; 212 | pointer-events: none; 213 | display: flex; 214 | flex-direction: column; 215 | align-items: center; 216 | background: transparent; 217 | color: rgba(255, 255, 255, 0.7); 218 | box-shadow: none; 219 | text-shadow: 0px 1px 3px rgba(0, 0, 0, 0.8); 220 | } 221 | 222 | .label_style span { 223 | font-size: 0.675em; 224 | letter-spacing: 0.05em; 225 | } 226 | 227 | svg path.leaflet-interactive { 228 | transition: fill-opacity 0.33s ease-in-out; 229 | } 230 | -------------------------------------------------------------------------------- /src/components/Search/Search.test.jsx: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import React from 'react' 3 | import { render, screen } from '@testing-library/react' 4 | import Search from './Search' 5 | import { Provider } from 'react-redux' 6 | import { store } from '../../redux/store' 7 | import { 8 | setappConfig, 9 | setCloudCover, 10 | setsearchGeojsonBoundary, 11 | setshowSearchByGeom 12 | } from '../../redux/slices/mainSlice' 13 | import { mockAppConfig } from '../../testing/shared-mocks' 14 | import userEvent from '@testing-library/user-event' 15 | import * as mapHelper from '../../utils/mapHelper' 16 | 17 | describe('Search', () => { 18 | const user = userEvent.setup() 19 | const setup = () => 20 | render( 21 | 22 | 23 | 24 | ) 25 | 26 | afterEach(() => { 27 | vi.resetAllMocks() 28 | }) 29 | 30 | describe('search button', () => { 31 | describe('if SEARCH_BY_GEOM_ENABLED is true', () => { 32 | beforeEach(() => { 33 | vi.mock('../../utils/mapHelper') 34 | vi.mock('../../utils/searchHelper') 35 | const mockAppConfigSearchEnabled = { 36 | ...mockAppConfig, 37 | SEARCH_BY_GEOM_ENABLED: true 38 | } 39 | store.dispatch(setappConfig(mockAppConfigSearchEnabled)) 40 | }) 41 | describe('on render', () => { 42 | it('should not render disabled search bar overlay div', async () => { 43 | setup() 44 | expect( 45 | screen.queryByTestId('test_disableSearchOverlay') 46 | ).not.toBeInTheDocument() 47 | }) 48 | }) 49 | describe('when search options changed', () => { 50 | it('should set showSearchByGeom to false in redux', () => { 51 | store.dispatch(setshowSearchByGeom(true)) 52 | setup() 53 | store.dispatch(setCloudCover(5)) 54 | expect(store.getState().mainSlice.showSearchByGeom).toBeFalsy() 55 | }) 56 | }) 57 | describe('when search button clicked', () => { 58 | it('should set showSearchByGeom to false in redux', async () => { 59 | store.dispatch(setshowSearchByGeom(true)) 60 | setup() 61 | const searchButton = screen.getByRole('button', { 62 | name: /search/i 63 | }) 64 | await user.click(searchButton) 65 | expect(store.getState().mainSlice.showSearchByGeom).toBeFalsy() 66 | }) 67 | }) 68 | describe('when drawing mode enabled', () => { 69 | it('should render disabled search bar overlay div', async () => { 70 | setup() 71 | const drawBoundaryButton = screen.getByRole('button', { 72 | name: /draw/i 73 | }) 74 | await user.click(drawBoundaryButton) 75 | expect(store.getState().mainSlice.isDrawingEnabled).toBeTruthy() 76 | }) 77 | }) 78 | describe('when draw boundary button clicked', () => { 79 | it('should not call functions if geom already exists', async () => { 80 | const spyEnableMapPolyDrawing = vi.spyOn( 81 | mapHelper, 82 | 'enableMapPolyDrawing' 83 | ) 84 | store.dispatch( 85 | setsearchGeojsonBoundary({ 86 | type: 'Polygon', 87 | coordinates: [[]] 88 | }) 89 | ) 90 | setup() 91 | const drawBoundaryButton = screen.getByRole('button', { 92 | name: /draw/i 93 | }) 94 | await user.click(drawBoundaryButton) 95 | expect(spyEnableMapPolyDrawing).not.toHaveBeenCalled() 96 | }) 97 | it('should enter drawing state if geom does not exists', async () => { 98 | const spyEnableMapPolyDrawing = vi.spyOn( 99 | mapHelper, 100 | 'enableMapPolyDrawing' 101 | ) 102 | store.dispatch(setshowSearchByGeom(true)) 103 | setup() 104 | const drawBoundaryButton = screen.getByRole('button', { 105 | name: /draw/i 106 | }) 107 | await user.click(drawBoundaryButton) 108 | expect(spyEnableMapPolyDrawing).toHaveBeenCalled() 109 | expect(store.getState().mainSlice.isDrawingEnabled).toBeTruthy() 110 | }) 111 | }) 112 | describe('when clear button clicked', () => { 113 | it('should not call functions if geom does not exists', async () => { 114 | const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') 115 | setup() 116 | const clearButton = screen.getByRole('button', { 117 | name: /clear/i 118 | }) 119 | await user.click(clearButton) 120 | expect(spyClearLayer).not.toHaveBeenCalled() 121 | }) 122 | it('should clear layer and close options if geom exists', async () => { 123 | const spyClearLayer = vi.spyOn(mapHelper, 'clearLayer') 124 | store.dispatch( 125 | setsearchGeojsonBoundary({ 126 | type: 'Polygon', 127 | coordinates: [[]] 128 | }) 129 | ) 130 | setup() 131 | const clearButton = screen.getByRole('button', { 132 | name: /clear/i 133 | }) 134 | await user.click(clearButton) 135 | expect(spyClearLayer).toHaveBeenCalled() 136 | expect(store.getState().mainSlice.searchGeojsonBoundary).toBeNull() 137 | expect(store.getState().mainSlice.showSearchByGeom).toBeFalsy() 138 | }) 139 | }) 140 | describe('when upload geojson button clicked', () => { 141 | it('should not call dispatch functions if geom already exists', async () => { 142 | store.dispatch( 143 | setsearchGeojsonBoundary({ 144 | type: 'Polygon', 145 | coordinates: [[]] 146 | }) 147 | ) 148 | setup() 149 | const uploadGeojsonButton = screen.getByRole('button', { 150 | name: /upload/i 151 | }) 152 | await user.click(uploadGeojsonButton) 153 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy() 154 | }) 155 | it('should call dispatch functions if geom does not exists', async () => { 156 | store.dispatch(setshowSearchByGeom(true)) 157 | setup() 158 | const uploadGeojsonButton = screen.getByRole('button', { 159 | name: /upload/i 160 | }) 161 | await user.click(uploadGeojsonButton) 162 | expect(store.getState().mainSlice.showSearchByGeom).toBeFalsy() 163 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy() 164 | }) 165 | }) 166 | }) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/components/SystemMessage/SystemMessage.css: -------------------------------------------------------------------------------- 1 | .SystemMessage { 2 | position: absolute; 3 | bottom: 40px; 4 | right: 10px; 5 | z-index: 500; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/SystemMessage/SystemMessage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Alert from '@mui/material/Alert' 3 | import './SystemMessage.css' 4 | 5 | import { useSelector } from 'react-redux' 6 | import { store } from '../../redux/store' 7 | import { setshowApplicationAlert } from '../../redux/slices/mainSlice' 8 | import { logoutUser } from '../../utils/authHelper' 9 | 10 | const SystemMessage = () => { 11 | const _applicationAlertMessage = useSelector( 12 | (state) => state.mainSlice.applicationAlertMessage 13 | ) 14 | const _applicationAlertSeverity = useSelector( 15 | (state) => state.mainSlice.applicationAlertSeverity 16 | ) 17 | 18 | return ( 19 |
20 | { 22 | store.dispatch(setshowApplicationAlert(false)) 23 | if (_applicationAlertSeverity === 'error') { 24 | logoutUser() 25 | } 26 | }} 27 | severity={_applicationAlertSeverity} 28 | sx={{ 29 | '& .MuiAlert-message': { 30 | fontSize: 14 31 | } 32 | }} 33 | > 34 | {_applicationAlertMessage} 35 | 36 |
37 | ) 38 | } 39 | 40 | export default SystemMessage 41 | -------------------------------------------------------------------------------- /src/components/SystemMessage/SystemMessage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { describe, it, expect } from 'vitest' 3 | import { render, screen } from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import SystemMessage from './SystemMessage' 6 | import { Provider } from 'react-redux' 7 | import { store } from '../../redux/store' 8 | import { 9 | setapplicationAlertMessage, 10 | setapplicationAlertSeverity, 11 | setshowApplicationAlert 12 | } from '../../redux/slices/mainSlice' 13 | 14 | describe('SystemMessage', () => { 15 | const user = userEvent.setup() 16 | 17 | const setup = () => 18 | render( 19 | 20 | 21 | 22 | ) 23 | 24 | describe('when user clicks close button', () => { 25 | it('should set ShowApplicationAlert in state to false', async () => { 26 | store.dispatch(setshowApplicationAlert(true)) 27 | setup() 28 | await user.click( 29 | screen.getByRole('button', { 30 | name: /close/i 31 | }) 32 | ) 33 | expect(store.getState().mainSlice.showApplicationAlert).toBeFalsy() 34 | }) 35 | }) 36 | 37 | describe('when alert renders', () => { 38 | it('should set severity to reflect redux state', async () => { 39 | store.dispatch(setshowApplicationAlert(true)) 40 | store.dispatch(setapplicationAlertSeverity('success')) 41 | setup() 42 | expect(screen.getByRole('alert')).toHaveClass('MuiAlert-standardSuccess') 43 | }) 44 | it('should set message to reflect redux state', async () => { 45 | store.dispatch(setshowApplicationAlert(true)) 46 | store.dispatch(setapplicationAlertMessage('user updated')) 47 | setup() 48 | expect(screen.getByText(/user updated/i)).toBeInTheDocument() 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /src/components/UploadGeojsonModal/UploadGeojsonModal.css: -------------------------------------------------------------------------------- 1 | .uploadGeojsonModal { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | bottom: 0; 7 | z-index: 100; 8 | background: rgba(0, 0, 0, 0.7); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | } 13 | 14 | .uploadGeojsonModalContainer { 15 | width: 600px; 16 | max-width: 80%; 17 | min-height: 280px; 18 | background: #353d4f; 19 | color: #dedede; 20 | padding: 20px; 21 | position: relative; 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | 28 | .uploadGeojsonModalTitle { 29 | font-size: 24px; 30 | font-weight: bold; 31 | position: absolute; 32 | top: 20px; 33 | left: 20px; 34 | } 35 | 36 | .fileToUploadText { 37 | position: absolute; 38 | bottom: 25px; 39 | left: 20px; 40 | right: 140px; 41 | width: calc(100% - 220px); 42 | white-space: pre-wrap; 43 | white-space: -moz-pre-wrap; 44 | white-space: -pre-wrap; 45 | white-space: -o-pre-wrap; 46 | word-wrap: break-word; 47 | } 48 | 49 | .uploadGeojsonModalActionButtons { 50 | position: absolute; 51 | right: 20px; 52 | display: flex; 53 | flex-direction: row; 54 | bottom: 10px; 55 | } 56 | 57 | .uploadGeojsonModalActionButton { 58 | height: 32px; 59 | width: 50px; 60 | background-color: #4f5768; 61 | border: none; 62 | color: white; 63 | padding: 15px 32px; 64 | text-align: center; 65 | text-decoration: none; 66 | font-size: 16px; 67 | cursor: pointer; 68 | border: 1px solid #a9b0c1; 69 | margin-bottom: 10px; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | border-radius: 5px; 74 | margin-left: 10px; 75 | } 76 | 77 | .uploadGeojsonModalActionButton:hover { 78 | background: #353d4f; 79 | } 80 | 81 | @media (max-width: 650px) { 82 | .fileToUploadText { 83 | font-size: 14px; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/components/UploadGeojsonModal/UploadGeojsonModal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useState } from 'react' 2 | import './UploadGeojsonModal.css' 3 | import { useDispatch } from 'react-redux' 4 | import { setshowUploadGeojsonModal } from '../../redux/slices/mainSlice' 5 | import { useDropzone } from 'react-dropzone' 6 | import { addUploadedGeojsonToMap, parseGeomUpload } from '../../utils/mapHelper' 7 | import { showApplicationAlert } from '../../utils/alertHelper' 8 | 9 | const UploadGeojsonModal = () => { 10 | const [fileData, setFileData] = useState(null) 11 | const dispatch = useDispatch() 12 | 13 | const baseStyle = { 14 | padding: '20px', 15 | borderWidth: 2, 16 | borderRadius: 2, 17 | borderColor: '#6cc24a', 18 | borderStyle: 'dashed', 19 | backgroundColor: '#4f5768', 20 | color: '#fff', 21 | outline: 'none', 22 | transition: 'border .24s ease-in-out', 23 | height: '80px', 24 | display: 'flex', 25 | alignItems: 'center', 26 | cursor: 'pointer' 27 | } 28 | 29 | const focusedStyle = { 30 | borderColor: '#6cc24a' 31 | } 32 | 33 | const acceptStyle = { 34 | borderColor: '#00e676' 35 | } 36 | 37 | const rejectStyle = { 38 | borderColor: '#ff1744' 39 | } 40 | 41 | const handleFileDrop = (acceptedFiles) => { 42 | const file = acceptedFiles[0] 43 | if (file.size >= 100000) { 44 | setFileData(null) 45 | showApplicationAlert('warning', 'File size exceeded (100KB max)', 5000) 46 | return 47 | } 48 | if (file.name.endsWith('.geojson') || file.name.endsWith('.json')) { 49 | const reader = new FileReader() 50 | reader.onload = (e) => { 51 | setFileData(e.target.result) 52 | } 53 | reader.readAsText(file) 54 | } else { 55 | setFileData(null) 56 | showApplicationAlert( 57 | 'warning', 58 | 'ERROR: Only .json or .geojson supported', 59 | 5000 60 | ) 61 | } 62 | } 63 | 64 | const { 65 | getRootProps, 66 | getInputProps, 67 | isFocused, 68 | isDragAccept, 69 | isDragReject, 70 | acceptedFiles 71 | } = useDropzone({ 72 | onDrop: handleFileDrop, 73 | multiple: false 74 | }) 75 | 76 | const acceptedFileItems = acceptedFiles.map((file) => ( 77 | {file.path} 78 | )) 79 | 80 | const style = useMemo( 81 | () => ({ 82 | ...baseStyle, 83 | ...(isFocused ? focusedStyle : {}), 84 | ...(isDragAccept ? acceptStyle : {}), 85 | ...(isDragReject ? rejectStyle : {}) 86 | }), 87 | [isFocused, isDragAccept, isDragReject] 88 | ) 89 | 90 | function onUploadGeojsonCancelClicked() { 91 | dispatch(setshowUploadGeojsonModal(false)) 92 | } 93 | 94 | async function onUploadGeojsonAddClicked() { 95 | let geoJsonData 96 | try { 97 | geoJsonData = JSON.parse(fileData) 98 | } catch (e) { 99 | showApplicationAlert('warning', 'ERROR: JSON format invalid', 5000) 100 | return 101 | } 102 | if (fileData) { 103 | await parseGeomUpload(geoJsonData).then( 104 | (response) => { 105 | addUploadedGeojsonToMap(response) 106 | dispatch(setshowUploadGeojsonModal(false)) 107 | }, 108 | // eslint-disable-next-line n/handle-callback-err 109 | (error) => { 110 | showApplicationAlert( 111 | 'warning', 112 | 'ERROR: ' + error.message.toString(), 113 | 5000 114 | ) 115 | } 116 | ) 117 | } else { 118 | showApplicationAlert('warning', 'No file selected', 5000) 119 | } 120 | } 121 | 122 | return ( 123 |
124 |
125 |
126 | Upload Geojson File 127 |
132 |

133 | Drag and drop a GeoJSON file here or click to{' '} 134 | 138 | browse 139 |

140 |
141 | {fileData ? ( 142 |
{acceptedFileItems}
143 | ) : null} 144 |
145 | 151 | 157 |
158 |
159 |
160 | ) 161 | } 162 | 163 | export default UploadGeojsonModal 164 | -------------------------------------------------------------------------------- /src/components/UploadGeojsonModal/UploadGeojsonModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { describe, it, expect, vi } from 'vitest' 3 | import { render, screen, waitFor, fireEvent } from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import UploadGeojsonModal from './UploadGeojsonModal' 6 | import { Provider } from 'react-redux' 7 | import { store } from '../../redux/store' 8 | import { setshowUploadGeojsonModal } from '../../redux/slices/mainSlice' 9 | import * as alertHelper from '../../utils/alertHelper' 10 | import * as mapHelper from '../../utils/mapHelper' 11 | 12 | describe('UploadGeojsonModal', () => { 13 | const user = userEvent.setup() 14 | 15 | const setup = () => 16 | render( 17 | 18 | 19 | 20 | ) 21 | 22 | describe('when cancel button is clicked', () => { 23 | it('should set showUploadGeojsonModal to be false in state', async () => { 24 | store.dispatch(setshowUploadGeojsonModal(true)) 25 | setup() 26 | const cancelButton = screen.getByRole('button', { name: /cancel/i }) 27 | await user.click(cancelButton) 28 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy() 29 | }) 30 | }) 31 | describe('when upload button is clicked', () => { 32 | beforeEach(() => { 33 | vi.mock('../../utils/alertHelper') 34 | vi.mock('../../utils/mapHelper') 35 | }) 36 | afterEach(() => { 37 | vi.resetAllMocks() 38 | }) 39 | it('should show warning if json is not valid and not close modal', async () => { 40 | const spyshowApplicationAlert = vi.spyOn( 41 | alertHelper, 42 | 'showApplicationAlert' 43 | ) 44 | store.dispatch(setshowUploadGeojsonModal(true)) 45 | setup() 46 | const json = '{ "invalid json"' 47 | const file = new File([json], 'test.geojson', { 48 | type: 'application/json' 49 | }) 50 | const input = screen.getByTestId('testGeojsonFileUploadInput') 51 | await waitFor(async () => 52 | fireEvent.change(input, { target: { files: [file] } }) 53 | ) 54 | const cancelButton = screen.getByRole('button', { name: /add/i }) 55 | await user.click(cancelButton) 56 | expect(spyshowApplicationAlert).toHaveBeenCalledWith( 57 | 'warning', 58 | 'ERROR: JSON format invalid', 59 | 5000 60 | ) 61 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy() 62 | }) 63 | it('should show warning and not close modal if parseGeomUpload throws an warning', async () => { 64 | const spyshowApplicationAlert = vi.spyOn( 65 | alertHelper, 66 | 'showApplicationAlert' 67 | ) 68 | const spyaddUploadedGeojsonToMap = vi.spyOn( 69 | mapHelper, 70 | 'addUploadedGeojsonToMap' 71 | ) 72 | mapHelper.parseGeomUpload.mockRejectedValueOnce(new Error('Parse error')) 73 | 74 | store.dispatch(setshowUploadGeojsonModal(true)) 75 | setup() 76 | const geojson = JSON.stringify({ 77 | type: 'Feature', 78 | geometry: { 79 | type: 'Point', 80 | coordinates: [0, 0] 81 | }, 82 | properties: { 83 | name: 'My Point' 84 | } 85 | }) 86 | const file = new File([geojson], 'test.geojson', { 87 | type: 'application/json' 88 | }) 89 | const input = screen.getByTestId('testGeojsonFileUploadInput') 90 | await waitFor(async () => 91 | fireEvent.change(input, { target: { files: [file] } }) 92 | ) 93 | const cancelButton = screen.getByRole('button', { name: /add/i }) 94 | await user.click(cancelButton) 95 | expect(spyshowApplicationAlert).toHaveBeenCalledWith( 96 | 'warning', 97 | 'ERROR: Parse error', 98 | 5000 99 | ) 100 | expect(spyaddUploadedGeojsonToMap).not.toHaveBeenCalled() 101 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeTruthy() 102 | }) 103 | it('should call addUploadedGeojsonToMap and close modal if parseGeomUpload does not error', async () => { 104 | const spyshowApplicationAlert = vi.spyOn( 105 | alertHelper, 106 | 'showApplicationAlert' 107 | ) 108 | const spyaddUploadedGeojsonToMap = vi.spyOn( 109 | mapHelper, 110 | 'addUploadedGeojsonToMap' 111 | ) 112 | mapHelper.parseGeomUpload.mockResolvedValueOnce('parsed geojson') 113 | 114 | store.dispatch(setshowUploadGeojsonModal(true)) 115 | setup() 116 | const geojson = JSON.stringify({ 117 | type: 'Feature', 118 | geometry: { 119 | type: 'Point', 120 | coordinates: [0, 0] 121 | }, 122 | properties: { 123 | name: 'My Point' 124 | } 125 | }) 126 | const file = new File([geojson], 'test.geojson', { 127 | type: 'application/json' 128 | }) 129 | const input = screen.getByTestId('testGeojsonFileUploadInput') 130 | await waitFor(async () => 131 | fireEvent.change(input, { target: { files: [file] } }) 132 | ) 133 | const cancelButton = screen.getByRole('button', { name: /add/i }) 134 | await user.click(cancelButton) 135 | expect(spyshowApplicationAlert).not.toHaveBeenCalled() 136 | expect(spyaddUploadedGeojsonToMap).toHaveBeenCalledWith('parsed geojson') 137 | expect(store.getState().mainSlice.showUploadGeojsonModal).toBeFalsy() 138 | }) 139 | }) 140 | }) 141 | -------------------------------------------------------------------------------- /src/components/ViewSelector/ViewSelector.css: -------------------------------------------------------------------------------- 1 | .viewSelector button:nth-child(1) { 2 | border-top-left-radius: 6px; 3 | border-bottom-left-radius: 6px; 4 | } 5 | 6 | .viewSelector button:nth-child(2) { 7 | border-top-right-radius: 6px; 8 | border-bottom-right-radius: 6px; 9 | position: relative; 10 | left: -1px; 11 | } 12 | 13 | .viewSelector button { 14 | border: 1px solid #76829c !important; 15 | font-size: 12px; 16 | line-height: 15px; 17 | margin-top: 7px; 18 | letter-spacing: 0.05em; 19 | } 20 | 21 | .viewSelector div[role='group'] { 22 | box-shadow: none; 23 | } 24 | -------------------------------------------------------------------------------- /src/components/ViewSelector/ViewSelector.jsx: -------------------------------------------------------------------------------- 1 | import { React, useState, useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { setViewMode } from '../../redux/slices/mainSlice' 4 | import './ViewSelector.css' 5 | import { createTheme, ThemeProvider } from '@mui/material/styles' 6 | import { Stack } from '@mui/material' 7 | import Grid from '@mui/material/Grid' 8 | import Button from '@mui/material/Button' 9 | import ButtonGroup from '@mui/material/ButtonGroup' 10 | 11 | const ViewSelector = () => { 12 | const _viewMode = useSelector((state) => state.mainSlice.viewMode) 13 | 14 | const dispatch = useDispatch() 15 | const [selectedBtn, setSelectedBtn] = useState(_viewMode) 16 | 17 | const theme = createTheme({ 18 | palette: { 19 | primary: { 20 | main: '#DEDEDE' 21 | }, 22 | secondary: { 23 | main: '#4f5768' 24 | } 25 | } 26 | }) 27 | 28 | useEffect(() => { 29 | dispatch(setViewMode(selectedBtn)) 30 | }, [selectedBtn]) 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 42 | 48 | 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | } 61 | 62 | export default ViewSelector 63 | -------------------------------------------------------------------------------- /src/components/defaults.js: -------------------------------------------------------------------------------- 1 | export const DEFAULT_MOSAIC_MIN_ZOOM = 7 2 | export const DEFAULT_MOSAIC_MAX_ITEMS = 100 3 | export const DEFAULT_API_MAX_ITEMS = 200 4 | export const DEFAULT_MED_ZOOM = 4 5 | export const DEFAULT_HIGH_ZOOM = 7 6 | export const DEFAULT_COLORMAP = 'viridis' 7 | export const DEFAULT_APP_NAME = 'FilmDrop Console' 8 | export const DEFAULT_MAX_SCENES_RENDERED = 1000 9 | export const DEFAULT_MAP_CENTER = [30, 0] 10 | export const DEFAULT_MAP_ZOOM = 3 11 | // sets default date range (current minus 24hrs * 60min * 60sec * 1000ms per day * 14 days) 12 | const twoWeeksAgo = new Date(Date.now() - 24 * 60 * 60 * 1000 * 14) 13 | export const DEFAULT_DATE_RANGE = [twoWeeksAgo, new Date()] 14 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;700;900&display=swap'); 2 | 3 | html, 4 | body { 5 | margin: 0px; 6 | padding: 0px; 7 | height: 100%; 8 | width: 100%; 9 | font-family: 'Inter', Arial, Helvetica, sans-serif; 10 | } 11 | 12 | #root { 13 | height: 100%; 14 | width: 100%; 15 | } 16 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import App from './App' 5 | 6 | // import redux stuff 7 | import { store } from './redux/store' 8 | import { Provider } from 'react-redux' 9 | 10 | const root = ReactDOM.createRoot(document.getElementById('root')) 11 | root.render( 12 | 13 | 14 | 15 | ) 16 | -------------------------------------------------------------------------------- /src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import mainSlice from './slices/mainSlice' 3 | 4 | // This sets up the store, 5 | // you shouldn't need to edit this unless you want to 6 | // do more advanced redux stuff... 7 | 8 | export const store = configureStore({ 9 | reducer: { 10 | mainSlice 11 | }, 12 | // since we are adding a map from leaflet js (which returns a complex object, which is not serializable) need to disable check 13 | middleware: (getDefaultMiddleware) => 14 | getDefaultMiddleware({ 15 | serializableCheck: false 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/services/get-aggregate-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setSearchLoading, setSearchResults } from '../redux/slices/mainSlice' 3 | import { 4 | addDataToLayer, 5 | buildHexGridLayerOptions, 6 | gridCodeLayerStyle 7 | } from '../utils/mapHelper' 8 | import { mapHexGridFromJson, mapGridCodeFromJson } from '../utils/searchHelper' 9 | 10 | export async function AggregateSearchService(searchParams, gridType) { 11 | const requestHeaders = new Headers() 12 | const JWT = localStorage.getItem('APP_AUTH_TOKEN') 13 | const isSTACTokenAuthEnabled = 14 | store.getState().mainSlice.appConfig.APP_TOKEN_AUTH_ENABLED ?? false 15 | if (JWT && isSTACTokenAuthEnabled) { 16 | requestHeaders.append('Authorization', `Bearer ${JWT}`) 17 | } 18 | await fetch( 19 | `${ 20 | store.getState().mainSlice.appConfig.STAC_API_URL 21 | }/aggregate?${searchParams}`, 22 | { 23 | credentials: 24 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin', 25 | headers: requestHeaders 26 | } 27 | ) 28 | .then((response) => { 29 | if (response.ok) { 30 | return response.json() 31 | } 32 | throw new Error() 33 | }) 34 | .then((json) => { 35 | let gridFromJson 36 | let options 37 | if (gridType === 'hex') { 38 | gridFromJson = mapHexGridFromJson(json) 39 | store.dispatch(setSearchResults(gridFromJson)) 40 | options = buildHexGridLayerOptions(gridFromJson.properties.largestRatio) 41 | } else { 42 | gridFromJson = mapGridCodeFromJson(json) 43 | store.dispatch(setSearchResults(gridFromJson)) 44 | options = { 45 | style: gridCodeLayerStyle, 46 | onEachFeature: function (feature, layer) { 47 | const scenes = feature.properties.frequency > 1 ? 'scenes' : 'scene' 48 | layer.bindTooltip( 49 | `${feature.properties.frequency.toString()} ${scenes}`, 50 | { 51 | permanent: false, 52 | direction: 'top', 53 | className: 'tooltip_style', 54 | interactive: false 55 | } 56 | ) 57 | layer.on('mouseout', function (e) { 58 | const map = store.getState().mainSlice.map 59 | map.eachLayer(function (layer) { 60 | if (layer.getTooltip()) { 61 | layer.closeTooltip() 62 | } 63 | }) 64 | }) 65 | } 66 | } 67 | } 68 | 69 | store.dispatch(setSearchLoading(false)) 70 | addDataToLayer(gridFromJson, 'searchResultsLayer', options, true) 71 | }) 72 | .catch((error) => { 73 | store.dispatch(setSearchLoading(false)) 74 | const message = 'Error Fetching Aggregate Search Results' 75 | // log full error for diagnosing client side errors if needed 76 | console.error(message, error) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /src/services/get-aggregations-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | 3 | export async function GetCollectionAggregationsService(collectionId) { 4 | const requestHeaders = new Headers() 5 | const JWT = localStorage.getItem('APP_AUTH_TOKEN') 6 | const isSTACTokenAuthEnabled = 7 | store.getState().mainSlice.appConfig.APP_TOKEN_AUTH_ENABLED ?? false 8 | if (JWT && isSTACTokenAuthEnabled) { 9 | requestHeaders.append('Authorization', `Bearer ${JWT}`) 10 | } 11 | return fetch( 12 | `${ 13 | store.getState().mainSlice.appConfig.STAC_API_URL 14 | }/collections/${collectionId}/aggregations`, 15 | { 16 | credentials: 17 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin', 18 | headers: requestHeaders 19 | } 20 | ) 21 | .then((response) => { 22 | if (response.ok) { 23 | return response.json() 24 | } 25 | throw new Error() 26 | }) 27 | .then((json) => { 28 | return json.aggregations 29 | }) 30 | .catch((error) => { 31 | const message = 'Error Fetching Aggregations for: ' + collectionId 32 | // log full error for diagnosing client side errors if needed 33 | console.error(message, error) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/services/get-all-scenes-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setmappedScenes } from '../redux/slices/mainSlice' 3 | import { 4 | addDataToLayer, 5 | footprintLayerStyle, 6 | clearLayer 7 | } from '../utils/mapHelper' 8 | import { DEFAULT_MAX_SCENES_RENDERED } from '../components/defaults' 9 | 10 | async function fetchFeatures(url, abortSignal) { 11 | const response = await fetch(url, { 12 | signal: abortSignal, 13 | credentials: 14 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin' 15 | }) 16 | const data = await response.json() 17 | clearLayer('clickedSceneImageLayer') 18 | 19 | const features = data.features || [] 20 | 21 | const options = { 22 | style: footprintLayerStyle 23 | } 24 | addDataToLayer(features, 'searchResultsLayer', options, false) 25 | 26 | store.dispatch( 27 | setmappedScenes(store.getState().mainSlice.mappedScenes.concat(features)) 28 | ) 29 | 30 | const nextPageLink = data.links.find((link) => link.rel === 'next') 31 | if (nextPageLink) { 32 | if (!abortSignal.aborted) { 33 | if ( 34 | store.getState().mainSlice.mappedScenes.length >= 35 | DEFAULT_MAX_SCENES_RENDERED 36 | ) { 37 | // change this number to increase max number of scenes returned, set to 1000 currently 38 | return 39 | } 40 | const nextFeatures = await fetchFeatures(nextPageLink.href, abortSignal) 41 | return features.concat(nextFeatures) 42 | } 43 | } 44 | } 45 | 46 | export async function fetchAllFeatures(url, abortSignal) { 47 | return await fetchFeatures(url, abortSignal) 48 | } 49 | -------------------------------------------------------------------------------- /src/services/get-collections-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { 3 | setCollectionsData, 4 | setShowAppLoading, 5 | setapplicationAlertMessage, 6 | setshowApplicationAlert 7 | } from '../redux/slices/mainSlice' 8 | import { buildCollectionsData, loadLocalGridData } from '../utils/dataHelper' 9 | import { logoutUser } from '../utils/authHelper' 10 | 11 | export async function GetCollectionsService(searchParams) { 12 | const requestHeaders = new Headers() 13 | const JWT = localStorage.getItem('APP_AUTH_TOKEN') 14 | const isSTACTokenAuthEnabled = 15 | store.getState().mainSlice.appConfig.APP_TOKEN_AUTH_ENABLED ?? false 16 | if (JWT && isSTACTokenAuthEnabled) { 17 | requestHeaders.append('Authorization', `Bearer ${JWT}`) 18 | } 19 | await fetch( 20 | `${store.getState().mainSlice.appConfig.STAC_API_URL}/collections`, 21 | { 22 | credentials: 23 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin', 24 | headers: requestHeaders 25 | } 26 | ) 27 | .then((response) => { 28 | if (response.ok) { 29 | return response.json() 30 | } 31 | const contentType = response.headers.get('content-type') 32 | const error = new Error('Server responded with an error') 33 | error.status = response.status 34 | error.statusText = response.statusText 35 | if (contentType && contentType.includes('application/json')) { 36 | return response.json().then((err) => { 37 | error.response = err 38 | throw error 39 | }) 40 | } else { 41 | throw error 42 | } 43 | }) 44 | .then((json) => { 45 | if (!store.getState().mainSlice.appConfig.COLLECTIONS) { 46 | const builtCollectionData = buildCollectionsData(json) 47 | return builtCollectionData 48 | } 49 | if (json && json.collections && Array.isArray(json.collections)) { 50 | json.collections = json.collections.filter((collection) => { 51 | return store 52 | .getState() 53 | .mainSlice.appConfig.COLLECTIONS.includes(collection.id) 54 | }) 55 | const builtCollectionData = buildCollectionsData(json) 56 | return builtCollectionData 57 | } 58 | }) 59 | .then((formattedData) => { 60 | if (Object.values(formattedData).length === 0) { 61 | store.dispatch( 62 | setapplicationAlertMessage('Error: No Collections Found') 63 | ) 64 | store.dispatch(setshowApplicationAlert(true)) 65 | } 66 | store.dispatch(setCollectionsData(formattedData)) 67 | store.dispatch(setShowAppLoading(false)) 68 | loadLocalGridData() 69 | }) 70 | .catch((error) => { 71 | if (error.status === 403) { 72 | store.dispatch( 73 | setapplicationAlertMessage( 74 | 'STAC API returned 403. Bad Token OR needs STAC Auth Enabled in config.', 75 | 'error' 76 | ) 77 | ) 78 | store.dispatch(setshowApplicationAlert(true)) 79 | logoutUser() 80 | } 81 | const message = 'Error Fetching Collections' 82 | // log full error for diagnosing client side errors if needed 83 | console.error(message, error) 84 | }) 85 | } 86 | -------------------------------------------------------------------------------- /src/services/get-config-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setappConfig } from '../redux/slices/mainSlice' 3 | import { showApplicationAlert } from '../utils/alertHelper' 4 | 5 | export async function LoadConfigIntoStateService() { 6 | const cacheBuster = Date.now() 7 | const configUrl = `${ 8 | import.meta.env.BASE_URL 9 | }config/config.json?_cb=${cacheBuster}` 10 | 11 | await fetch(configUrl, { 12 | cache: 'no-store' 13 | }) 14 | .then((response) => { 15 | if (response.ok) { 16 | return response.json() 17 | } 18 | throw new Error() 19 | }) 20 | .then((json) => { 21 | store.dispatch(setappConfig(json)) 22 | }) 23 | .catch((error) => { 24 | const message = 'Error Fetching Config File' 25 | // log full error for diagnosing client side errors if needed 26 | console.error(message, error) 27 | showApplicationAlert('error', message, null) 28 | }) 29 | } 30 | 31 | export async function DoesFaviconExistService() { 32 | const cacheBuster = Date.now() 33 | 34 | try { 35 | const response = await fetch( 36 | `/config/${ 37 | store.getState().mainSlice.appConfig.APP_FAVICON 38 | }?_cb=${cacheBuster}`, 39 | { 40 | method: 'HEAD', 41 | cache: 'no-store' 42 | } 43 | ) 44 | 45 | return response.ok 46 | } catch (error) { 47 | console.error('Error Fetching Favicon File', error) 48 | return false 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/services/get-local-grid-data-json-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setLocalGridData } from '../redux/slices/mainSlice' 3 | 4 | export async function LoadLocalGridDataService(fileName) { 5 | const cacheBuster = Date.now() 6 | const configUrl = `${ 7 | import.meta.env.BASE_URL 8 | }data/${fileName}.json?_cb=${cacheBuster}` 9 | await fetch(configUrl, { 10 | cache: 'no-store' 11 | }) 12 | .then((response) => { 13 | if (response.ok) { 14 | return response.json() 15 | } 16 | throw new Error() 17 | }) 18 | .then((json) => { 19 | const getLocalGridData = store.getState().mainSlice.localGridData 20 | const newObject = { [fileName.toUpperCase()]: json } 21 | if (!Object.prototype.hasOwnProperty.call(getLocalGridData, fileName)) { 22 | store.dispatch(setLocalGridData({ ...getLocalGridData, ...newObject })) 23 | } 24 | }) 25 | .catch((error) => { 26 | const message = 'Error Fetching Local Grid Data' 27 | // log full error for diagnosing client side errors if needed 28 | console.error(message, error) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/services/get-mosaic-bounds.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | 3 | export function GetMosaicBoundsService(mosaicURL) { 4 | return new Promise(function (resolve, reject) { 5 | fetch(mosaicURL, { 6 | credentials: 7 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin' 8 | }) 9 | .then((response) => { 10 | if (response.ok) { 11 | return response.json() 12 | } 13 | throw new Error() 14 | }) 15 | .then((json) => { 16 | resolve(json.bounds) 17 | }) 18 | .catch((error) => { 19 | const message = 'Error Fetching Mosaicjson Tile Results' 20 | // log full error for diagnosing client side errors if needed 21 | console.error(message, error) 22 | reject(error) 23 | }) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/services/get-queryables-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | 3 | export function GetCollectionQueryablesService(collectionId) { 4 | const requestHeaders = new Headers() 5 | const JWT = localStorage.getItem('APP_AUTH_TOKEN') 6 | const isSTACTokenAuthEnabled = 7 | store.getState().mainSlice.appConfig.APP_TOKEN_AUTH_ENABLED ?? false 8 | if (JWT && isSTACTokenAuthEnabled) { 9 | requestHeaders.append('Authorization', `Bearer ${JWT}`) 10 | } 11 | return fetch( 12 | `${ 13 | store.getState().mainSlice.appConfig.STAC_API_URL 14 | }/collections/${collectionId}/queryables`, 15 | { 16 | credentials: 17 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin', 18 | headers: requestHeaders 19 | } 20 | ) 21 | .then((response) => { 22 | if (response.ok) { 23 | return response.json() 24 | } 25 | throw new Error() 26 | }) 27 | .then((json) => { 28 | return json.properties 29 | }) 30 | .catch((error) => { 31 | const message = 'Error Fetching Aggregations for: ' + collectionId 32 | // log full error for diagnosing client side errors if needed 33 | console.error(message, error) 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/services/get-search-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { 3 | setClickResults, 4 | setSearchLoading, 5 | setSearchResults, 6 | setmappedScenes, 7 | settabSelected, 8 | sethasLeftPanelTabChanged 9 | } from '../redux/slices/mainSlice' 10 | import { addDataToLayer, footprintLayerStyle } from '../utils/mapHelper' 11 | 12 | export async function SearchService(searchParams, typeOfSearch) { 13 | const requestHeaders = new Headers() 14 | const JWT = localStorage.getItem('APP_AUTH_TOKEN') 15 | const isSTACTokenAuthEnabled = 16 | store.getState().mainSlice.appConfig.APP_TOKEN_AUTH_ENABLED ?? false 17 | if (JWT && isSTACTokenAuthEnabled) { 18 | requestHeaders.append('Authorization', `Bearer ${JWT}`) 19 | } 20 | await fetch( 21 | `${ 22 | store.getState().mainSlice.appConfig.STAC_API_URL 23 | }/search?${searchParams}`, 24 | { 25 | credentials: 26 | store.getState().mainSlice.appConfig.FETCH_CREDENTIALS || 'same-origin', 27 | headers: requestHeaders 28 | } 29 | ) 30 | .then((response) => { 31 | if (response.ok) { 32 | return response.json() 33 | } 34 | throw new Error() 35 | }) 36 | .then((json) => { 37 | if (typeOfSearch === 'scene') { 38 | store.dispatch(setSearchResults(json)) 39 | store.dispatch(setmappedScenes(json.features)) 40 | const options = { 41 | style: footprintLayerStyle 42 | } 43 | store.dispatch(setSearchLoading(false)) 44 | addDataToLayer(json, 'searchResultsLayer', options, true) 45 | } else { 46 | store.dispatch(setSearchLoading(false)) 47 | store.dispatch(setClickResults(json.features)) 48 | store.dispatch(settabSelected('details')) 49 | store.dispatch(sethasLeftPanelTabChanged(true)) 50 | } 51 | }) 52 | .catch((error) => { 53 | store.dispatch(setSearchLoading(false)) 54 | const message = 'Error Fetching Search Results' 55 | // log full error for diagnosing client side errors if needed 56 | console.error(message, error) 57 | }) 58 | } 59 | -------------------------------------------------------------------------------- /src/services/post-auth-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setauthTokenExists } from '../redux/slices/mainSlice' 3 | import { showApplicationAlert } from '../utils/alertHelper' 4 | 5 | export async function AuthService(username, password) { 6 | const AuthServiceURL = store.getState().mainSlice.appConfig.AUTH_URL 7 | 8 | const myHeaders = new Headers() 9 | myHeaders.append('Content-Type', 'application/x-www-form-urlencoded') 10 | 11 | const urlencoded = new URLSearchParams() 12 | urlencoded.append('grant_type', 'password') 13 | urlencoded.append('username', username) 14 | urlencoded.append('password', password) 15 | 16 | const reqParams = { 17 | method: 'POST', 18 | headers: myHeaders, 19 | body: urlencoded 20 | } 21 | 22 | await fetch(`${AuthServiceURL}`, reqParams) 23 | .then((response) => { 24 | if (response.ok) { 25 | return response.json() 26 | } 27 | throw new Error() 28 | }) 29 | .then((json) => { 30 | if (!json.access_token) { 31 | throw new Error('No Auth Token Found') 32 | } 33 | localStorage.setItem('APP_AUTH_TOKEN', json.access_token) 34 | store.dispatch(setauthTokenExists(true)) 35 | }) 36 | .catch((error) => { 37 | store.dispatch(setauthTokenExists(false)) 38 | const message = 'Authentication Error' 39 | showApplicationAlert('warning', 'Login Failed', 5000) 40 | // log full error for diagnosing client side errors if needed 41 | console.error(message, error) 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/services/post-mosaic-service.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setSearchLoading } from '../redux/slices/mainSlice' 3 | import { addMosaicLayer } from '../utils/mapHelper' 4 | 5 | export async function AddMosaicService(reqParams) { 6 | const mosaicTilerURL = store.getState().mainSlice.appConfig.MOSAIC_TILER_URL 7 | await fetch(`${mosaicTilerURL}/mosaicjson/mosaics`, reqParams) 8 | .then((response) => { 9 | if (response.ok) { 10 | return response.json() 11 | } 12 | throw new Error() 13 | }) 14 | .then((json) => { 15 | addMosaicLayer(json) 16 | }) 17 | .catch((error) => { 18 | store.dispatch(setSearchLoading(false)) 19 | const message = 'Error Fetching Mosaic' 20 | // log full error for diagnosing client side errors if needed 21 | console.error(message, error) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { expect, afterEach, beforeEach, vi } from 'vitest' 2 | import { cleanup } from '@testing-library/react' 3 | import * as matchers from '@testing-library/jest-dom/matchers' 4 | import { mainSliceReset } from './redux/slices/mainSlice' 5 | import { store } from './redux/store' 6 | import 'resize-observer-polyfill' 7 | 8 | window.HTMLCanvasElement.prototype.getContext = () => {} 9 | global.ResizeObserver = require('resize-observer-polyfill') 10 | 11 | expect.extend(matchers) 12 | 13 | beforeEach(() => { 14 | store.dispatch(mainSliceReset()) 15 | vi.mock('./services/get-collections-service.js') 16 | vi.mock('./services/get-config-service.js') 17 | vi.mock('./services/get-local-grid-data-json-service.js') 18 | }) 19 | 20 | afterEach(() => { 21 | cleanup() 22 | }) 23 | -------------------------------------------------------------------------------- /src/utils/alertHelper.js: -------------------------------------------------------------------------------- 1 | import { 2 | setapplicationAlertMessage, 3 | setapplicationAlertSeverity, 4 | setshowApplicationAlert 5 | } from '../redux/slices/mainSlice' 6 | import { store } from '../redux/store' 7 | 8 | export function showApplicationAlert( 9 | severity, 10 | message = null, 11 | duration = null 12 | ) { 13 | message 14 | ? store.dispatch(setapplicationAlertMessage(message)) 15 | : store.dispatch(setapplicationAlertMessage('System Error')) 16 | 17 | store.dispatch(setapplicationAlertSeverity(severity)) 18 | store.dispatch(setshowApplicationAlert(true)) 19 | 20 | duration && 21 | setTimeout(() => { 22 | store.dispatch(setshowApplicationAlert(false)) 23 | }, duration) 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/alertHelper.test.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { setapplicationAlertMessage } from '../redux/slices/mainSlice' 3 | import { store } from '../redux/store' 4 | import { showApplicationAlert } from './alertHelper' 5 | 6 | describe('AlertHelper', () => { 7 | describe('showApplicationAlert', () => { 8 | it('should set default Alert message if no message passed', () => { 9 | store.dispatch(setapplicationAlertMessage('test error')) 10 | expect(store.getState().mainSlice.applicationAlertMessage).toBe( 11 | 'test error' 12 | ) 13 | showApplicationAlert('error', null) 14 | expect(store.getState().mainSlice.applicationAlertMessage).toBe( 15 | 'System Error' 16 | ) 17 | }) 18 | it('should set Alert message in state if message param passed', () => { 19 | showApplicationAlert('warning', 'this is a user message') 20 | expect(store.getState().mainSlice.applicationAlertMessage).toBe( 21 | 'this is a user message' 22 | ) 23 | }) 24 | it('should set ShowApplicationAlert in state to false if duration param passed and specified duration has finished', async () => { 25 | vi.useFakeTimers() 26 | const durationParam = 5000 27 | showApplicationAlert('warning', null, durationParam) 28 | expect(store.getState().mainSlice.showApplicationAlert).toBeTruthy() 29 | vi.runAllTimers() 30 | expect(store.getState().mainSlice.showApplicationAlert).toBeFalsy() 31 | }) 32 | it('should set ApplicationAlertSeverity in state to match input param', () => { 33 | showApplicationAlert('success', 'this is a user message') 34 | expect(store.getState().mainSlice.applicationAlertSeverity).toBe( 35 | 'success' 36 | ) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/utils/authHelper.js: -------------------------------------------------------------------------------- 1 | import { store } from '../redux/store' 2 | import { setauthTokenExists } from '../redux/slices/mainSlice' 3 | 4 | export function logoutUser() { 5 | localStorage.removeItem('APP_AUTH_TOKEN') 6 | store.dispatch(setauthTokenExists(false)) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/colorMap.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_COLORMAP } from '../components/defaults' 2 | import colormap from 'colormap' 3 | import { store } from '../redux/store' 4 | 5 | export const colorMap = (largestRatio) => { 6 | return colormap({ 7 | colormap: 8 | store.getState().mainSlice.appConfig.CONFIG_COLORMAP || DEFAULT_COLORMAP, 9 | nshades: Math.round(Math.max(9, largestRatio)), 10 | format: 'hex' 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/configHelper.js: -------------------------------------------------------------------------------- 1 | import { DEFAULT_APP_NAME } from '../components/defaults' 2 | import { store } from '../redux/store' 3 | import { DoesFaviconExistService } from '../services/get-config-service' 4 | import { setappName, setreferenceLayers } from '../redux/slices/mainSlice' 5 | import { showApplicationAlert } from './alertHelper' 6 | 7 | function loadAppTitle() { 8 | if (!store.getState().mainSlice.appConfig.APP_NAME) { 9 | document.title = DEFAULT_APP_NAME 10 | store.dispatch(setappName(DEFAULT_APP_NAME)) 11 | return 12 | } 13 | document.title = store.getState().mainSlice.appConfig.APP_NAME 14 | store.dispatch(setappName(store.getState().mainSlice.appConfig.APP_NAME)) 15 | } 16 | 17 | async function loadAppFavicon() { 18 | if (!store.getState().mainSlice.appConfig.APP_FAVICON) { 19 | return 20 | } 21 | const doesFaviconFileExist = await DoesFaviconExistService() 22 | if (!doesFaviconFileExist) { 23 | return 24 | } 25 | if ( 26 | store 27 | .getState() 28 | .mainSlice.appConfig.APP_FAVICON.toLowerCase() 29 | .endsWith('.png') || 30 | store 31 | .getState() 32 | .mainSlice.appConfig.APP_FAVICON.toLowerCase() 33 | .endsWith('.ico') 34 | ) { 35 | const faviconFromConfig = 36 | '/config/' + 37 | store.getState().mainSlice.appConfig.APP_FAVICON + 38 | `?_cb=${Date.now()}` 39 | const newFaviconLink = document.querySelector("link[rel~='icon']") 40 | newFaviconLink.href = faviconFromConfig 41 | } 42 | } 43 | 44 | async function parseLayerListConfig(config) { 45 | try { 46 | if ( 47 | !store.getState().mainSlice.appConfig || 48 | !store.getState().mainSlice.appConfig.LAYER_LIST_SERVICES 49 | ) { 50 | throw new Error( 51 | 'Invalid configuration format: LAYER_LIST_SERVICES is missing.' 52 | ) 53 | } 54 | return store 55 | .getState() 56 | .mainSlice.appConfig.LAYER_LIST_SERVICES.flatMap((service) => { 57 | if (!service.layers || !Array.isArray(service.layers)) { 58 | throw new Error( 59 | `Invalid configuration format for service '${service.name}': 'layers' is missing or not an array.` 60 | ) 61 | } 62 | 63 | return service.layers 64 | .map((layer) => { 65 | if (!layer.name) { 66 | throw new Error( 67 | `Invalid configuration format for layer in service '${service.name}': 'name' is missing.` 68 | ) 69 | } 70 | 71 | const validCRS = ['EPSG:4326', 'EPSG:3857'] 72 | const shouldAddLayer = !layer.crs || validCRS.includes(layer.crs) 73 | 74 | if (shouldAddLayer) { 75 | return { 76 | combinedLayerName: `${service.name.replace( 77 | / /g, 78 | '_' 79 | )}_${layer.name.replace(/ /g, '_')}`, 80 | layerName: layer.name, 81 | layerAlias: layer.alias || layer.name, 82 | visibility: layer.default_visibility || false, 83 | crs: layer.crs || 'EPSG:3857', 84 | url: service.url, 85 | type: service.type 86 | } 87 | } 88 | 89 | console.error( 90 | 'Error adding layer: ' + 91 | `${service.name.replace(/ /g, '_')}_${layer.name.replace( 92 | / /g, 93 | '_' 94 | )}` + 95 | ': unsupported crs' 96 | ) 97 | return null // Skip adding the layer if error 98 | }) 99 | .filter((layer) => layer !== null) // Filter out null layers 100 | }) 101 | } catch (error) { 102 | console.error('Error loading reference layers', error.message) 103 | showApplicationAlert('warning', 'Error loading reference layers', 5000) 104 | return [] 105 | } 106 | } 107 | 108 | async function loadReferenceLayers() { 109 | if ( 110 | !store.getState().mainSlice.appConfig.LAYER_LIST_SERVICES || 111 | !store.getState().mainSlice.appConfig.LAYER_LIST_ENABLED 112 | ) { 113 | return 114 | } 115 | const LayerListFromConfig = await parseLayerListConfig() 116 | if (LayerListFromConfig.length === 0) { 117 | return 118 | } 119 | 120 | store.dispatch(setreferenceLayers(LayerListFromConfig)) 121 | } 122 | 123 | export function InitializeAppFromConfig() { 124 | loadAppTitle() 125 | loadAppFavicon() 126 | loadReferenceLayers() 127 | } 128 | 129 | // exports for testing purposes 130 | export { loadAppTitle, loadAppFavicon } 131 | -------------------------------------------------------------------------------- /src/utils/configHelper.test.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { setappConfig } from '../redux/slices/mainSlice' 3 | import { store } from '../redux/store' 4 | import { mockAppConfig } from '../testing/shared-mocks' 5 | import { loadAppTitle, loadAppFavicon } from './configHelper' 6 | import { DoesFaviconExistService } from '../services/get-config-service' 7 | 8 | describe('ConfigHelper', () => { 9 | afterEach(() => { 10 | vi.resetAllMocks() 11 | }) 12 | describe('loadAppTitle', () => { 13 | it('sets document title and appName from config if present', () => { 14 | const mockAppConfigAppTitle = { 15 | ...mockAppConfig, 16 | APP_NAME: 'Demo App' 17 | } 18 | store.dispatch(setappConfig(mockAppConfigAppTitle)) 19 | loadAppTitle() 20 | expect(global.window.document.title).toBe('Demo App') 21 | expect(store.getState().mainSlice.appName).toBe('Demo App') 22 | }) 23 | it('sets document title and appName from default if App_Name not present in config', () => { 24 | store.dispatch(setappConfig(mockAppConfig)) 25 | loadAppTitle() 26 | expect(global.window.document.title).toBe('FilmDrop Console') 27 | expect(store.getState().mainSlice.appName).toBe('FilmDrop Console') 28 | }) 29 | }) 30 | describe('loadAppFavicon', () => { 31 | const originalQuerySelector = document.querySelector 32 | beforeEach(() => { 33 | vi.clearAllMocks() 34 | vi.mock('../services/get-config-service', () => ({ 35 | DoesFaviconExistService: vi.fn() 36 | })) 37 | }) 38 | afterEach(() => { 39 | document.querySelector = originalQuerySelector 40 | }) 41 | 42 | it('should do nothing when APP_FAVICON is not provided', async () => { 43 | store.dispatch(setappConfig(mockAppConfig)) 44 | await loadAppFavicon() 45 | expect(DoesFaviconExistService).not.toHaveBeenCalled() 46 | }) 47 | it('should do nothing when DoesFaviconExistService returns false', async () => { 48 | DoesFaviconExistService.mockResolvedValueOnce(false) 49 | const mockAppConfigFavicon = { 50 | ...mockAppConfig, 51 | APP_FAVICON: 'favicon.ico' 52 | } 53 | store.dispatch(setappConfig(mockAppConfigFavicon)) 54 | await loadAppFavicon() 55 | expect(DoesFaviconExistService).toHaveBeenCalled() 56 | }) 57 | it('should call DoesFaviconExistService but not query for link if Favicon set in config but file does not exist', async () => { 58 | DoesFaviconExistService.mockResolvedValueOnce(false) 59 | const mockAppConfigFavicon = { 60 | ...mockAppConfig, 61 | APP_FAVICON: 'favicon.ico' 62 | } 63 | store.dispatch(setappConfig(mockAppConfigFavicon)) 64 | const mockLink = document.createElement('link') 65 | mockLink.rel = 'icon' 66 | mockLink.href = '/favicon.ico' 67 | document.querySelector = vi.fn(() => mockLink) 68 | await loadAppFavicon() 69 | expect(DoesFaviconExistService).toHaveBeenCalled() 70 | expect(document.querySelector).not.toHaveBeenCalledWith( 71 | "link[rel~='icon']" 72 | ) 73 | }) 74 | it('should call DoesFaviconExistService and query for link if favicon set in config and file exists', async () => { 75 | DoesFaviconExistService.mockResolvedValueOnce(true) 76 | const mockAppConfigFavicon = { 77 | ...mockAppConfig, 78 | APP_FAVICON: 'favicon.ico' 79 | } 80 | store.dispatch(setappConfig(mockAppConfigFavicon)) 81 | const mockLink = document.createElement('link') 82 | mockLink.rel = 'icon' 83 | mockLink.href = '/config/favicon.ico?_cb=123' 84 | document.querySelector = vi.fn(() => mockLink) 85 | await loadAppFavicon() 86 | expect(DoesFaviconExistService).toHaveBeenCalled() 87 | expect(document.querySelector).toHaveBeenCalledWith("link[rel~='icon']") 88 | expect(mockLink.href).toContain( 89 | 'http://localhost:3000/config/favicon.ico?_cb=' 90 | ) 91 | expect(mockLink.href).toMatch(/_cb=\d+/) 92 | }) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /src/utils/dataHelper.js: -------------------------------------------------------------------------------- 1 | import { GetCollectionQueryablesService } from '../services/get-queryables-service' 2 | import { GetCollectionAggregationsService } from '../services/get-aggregations-service' 3 | import { LoadLocalGridDataService } from '../services/get-local-grid-data-json-service' 4 | import { store } from '../redux/store' 5 | import { 6 | cartFootprintLayerStyle, 7 | addDataToLayer, 8 | clearLayer 9 | } from './mapHelper' 10 | 11 | export async function buildCollectionsData(collections) { 12 | for (const collection of collections.collections) { 13 | let queryables 14 | let aggregations 15 | const hasAggregations = 16 | store.getState().mainSlice.appConfig.SUPPORTS_AGGREGATIONS ?? true 17 | if (hasAggregations) { 18 | ;[queryables, aggregations] = await Promise.all([ 19 | GetCollectionQueryablesService(collection.id), 20 | GetCollectionAggregationsService(collection.id) 21 | ]) 22 | } else { 23 | queryables = await GetCollectionQueryablesService(collection.id) 24 | } 25 | 26 | collection.queryables = queryables 27 | if (hasAggregations) { 28 | collection.aggregations = aggregations 29 | } 30 | } 31 | 32 | collections.collections = collections.collections.sort((a, b) => 33 | a.id > b.id ? 1 : b.id > a.id ? -1 : 0 34 | ) 35 | 36 | return collections.collections 37 | } 38 | 39 | export async function loadLocalGridData() { 40 | const dataFiles = ['cdem', 'doqq', 'mgrs', 'wrs2'] 41 | dataFiles.map(async function (d) { 42 | await LoadLocalGridDataService(d) 43 | }) 44 | } 45 | 46 | export function isSceneInCart(sceneObject) { 47 | const cartItems = store.getState().mainSlice.cartItems 48 | return cartItems.some((cartItem) => cartItem.id === sceneObject.id) 49 | } 50 | 51 | export function numberOfSelectedInCart(results) { 52 | const cartItems = store.getState().mainSlice.cartItems 53 | return results.reduce((count, result) => { 54 | if (cartItems.some((cartItem) => cartItem.id === result.id)) { 55 | return count + 1 56 | } 57 | return count 58 | }, 0) 59 | } 60 | 61 | export function areAllScenesSelectedInCart(results) { 62 | const cartItems = store.getState().mainSlice.cartItems 63 | return results.every((result) => 64 | cartItems.some((cartItem) => cartItem.id === result.id) 65 | ) 66 | } 67 | 68 | export function setScenesForCartLayer() { 69 | if (store.getState().mainSlice.cartItems.length === 0) { 70 | clearLayer('cartFootprintsLayer') 71 | return 72 | } 73 | const cartGeojson = { 74 | type: 'FeatureCollection', 75 | features: store.getState().mainSlice.cartItems 76 | } 77 | const options = { 78 | style: cartFootprintLayerStyle, 79 | interactive: false 80 | } 81 | addDataToLayer(cartGeojson, 'cartFootprintsLayer', options, true) 82 | } 83 | 84 | export function processDisplayFieldValues(value) { 85 | switch (true) { 86 | case value === null: 87 | case value === undefined: 88 | return 'No Data' 89 | case typeof value === 'boolean': 90 | case typeof value === 'number': 91 | case typeof value === 'string': 92 | return value.toString() 93 | case Array.isArray(value): 94 | return value.map(processDisplayFieldValues).join(', ') 95 | case typeof value === 'object': 96 | return formatNestedObjectDisplayFieldValues(value) 97 | default: 98 | return 'Unsupported Type' 99 | } 100 | } 101 | 102 | function formatNestedObjectDisplayFieldValues(inputObject) { 103 | function formatValue(value) { 104 | if (Array.isArray(value)) { 105 | return `[${value.map(formatValue).join(', ')}]` 106 | } else if (typeof value === 'object') { 107 | return `{${formatObject(value)}}` 108 | } else { 109 | return value 110 | } 111 | } 112 | function formatObject(obj) { 113 | return Object.entries(obj) 114 | .map(([key, value]) => `${key}: ${formatValue(value)}`) 115 | .join(', ') 116 | } 117 | return `${formatObject(inputObject)}` 118 | } 119 | -------------------------------------------------------------------------------- /src/utils/dataHelper.test.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { store } from '../redux/store' 3 | import { 4 | loadLocalGridData, 5 | isSceneInCart, 6 | numberOfSelectedInCart, 7 | areAllScenesSelectedInCart, 8 | setScenesForCartLayer, 9 | processDisplayFieldValues 10 | } from './dataHelper' 11 | import * as getLocalGridDataService from '../services/get-local-grid-data-json-service' 12 | import * as MapHelper from './mapHelper' 13 | 14 | describe('dataHelper', () => { 15 | describe('loadLocalGridData', () => { 16 | it('calls service to load grid data', async () => { 17 | const spyLoadLocalGridDataService = vi.spyOn( 18 | getLocalGridDataService, 19 | 'LoadLocalGridDataService' 20 | ) 21 | await loadLocalGridData() 22 | expect(spyLoadLocalGridDataService).toHaveBeenCalledTimes(4) 23 | }) 24 | }) 25 | 26 | describe('isSceneInCart', () => { 27 | it('returns true if scene is in cart', () => { 28 | const mockCart = [{ id: '1' }, { id: '2' }] 29 | vi.spyOn(store, 'getState').mockReturnValue({ 30 | mainSlice: { 31 | cartItems: mockCart 32 | } 33 | }) 34 | const scene = { id: '1' } 35 | const result = isSceneInCart(scene) 36 | expect(result).toBe(true) 37 | }) 38 | it('returns false if scene is not in cart', () => { 39 | const mockCart = [{ id: '1' }, { id: '2' }] 40 | vi.spyOn(store, 'getState').mockReturnValue({ 41 | mainSlice: { 42 | cartItems: mockCart 43 | } 44 | }) 45 | const scene = { id: '3' } 46 | const result = isSceneInCart(scene) 47 | expect(result).toBe(false) 48 | }) 49 | }) 50 | 51 | describe('numberOfSelectedInCart', () => { 52 | it('returns number of selected scenes in cart', () => { 53 | const mockCart = [{ id: '1' }, { id: '2' }] 54 | vi.spyOn(store, 'getState').mockReturnValue({ 55 | mainSlice: { 56 | cartItems: mockCart 57 | } 58 | }) 59 | const mockResults = [{ id: '1' }, { id: '3' }] 60 | const count = numberOfSelectedInCart(mockResults) 61 | expect(count).toBe(1) 62 | }) 63 | }) 64 | 65 | describe('areAllScenesSelectedInCart', () => { 66 | it('returns true if all scenes are in cart', () => { 67 | const mockCart = [{ id: '1' }, { id: '2' }] 68 | vi.spyOn(store, 'getState').mockReturnValue({ 69 | mainSlice: { 70 | cartItems: mockCart 71 | } 72 | }) 73 | const mockResults = [{ id: '1' }, { id: '2' }] 74 | const allInCart = areAllScenesSelectedInCart(mockResults) 75 | expect(allInCart).toBe(true) 76 | }) 77 | it('returns false if some scenes not in cart', () => { 78 | const mockCart = [{ id: '1' }, { id: '2' }] 79 | vi.spyOn(store, 'getState').mockReturnValue({ 80 | mainSlice: { 81 | cartItems: mockCart 82 | } 83 | }) 84 | const mockResults = [{ id: '1' }, { id: '3' }] 85 | const allInCart = areAllScenesSelectedInCart(mockResults) 86 | expect(allInCart).toBe(false) 87 | }) 88 | }) 89 | 90 | describe('setScenesForCartLayer', () => { 91 | it('clears layer if no cart items', () => { 92 | const mockEmptyCart = [] 93 | vi.spyOn(store, 'getState').mockReturnValue({ 94 | mainSlice: { 95 | cartItems: mockEmptyCart 96 | } 97 | }) 98 | const spyClearLayer = vi.spyOn(MapHelper, 'clearLayer') 99 | setScenesForCartLayer() 100 | expect(spyClearLayer).toHaveBeenCalledWith('cartFootprintsLayer') 101 | }) 102 | it('sets geojson and options for cart layer', () => { 103 | const mockCartItems = [{ id: '1' }, { id: '2' }] 104 | vi.spyOn(store, 'getState').mockReturnValue({ 105 | mainSlice: { 106 | cartItems: mockCartItems 107 | } 108 | }) 109 | const spyAddDataToLayer = vi.spyOn(MapHelper, 'addDataToLayer') 110 | setScenesForCartLayer() 111 | expect(spyAddDataToLayer).toHaveBeenCalledWith( 112 | expect.objectContaining({ 113 | type: 'FeatureCollection', 114 | features: mockCartItems 115 | }), 116 | 'cartFootprintsLayer', 117 | expect.any(Object), 118 | true 119 | ) 120 | }) 121 | }) 122 | 123 | describe('processDisplayFieldValues', () => { 124 | it('returns boolean value as string', () => { 125 | const input = true 126 | const expected = 'true' 127 | const actual = processDisplayFieldValues(input) 128 | expect(actual).toEqual(expected) 129 | }) 130 | 131 | it('returns array values joined as comma separated string', () => { 132 | const input = [1, 2, 3] 133 | const expected = '1, 2, 3' 134 | const actual = processDisplayFieldValues(input) 135 | expect(actual).toEqual(expected) 136 | }) 137 | 138 | it('returns number value as string', () => { 139 | const input = 123 140 | const expected = '123' 141 | const actual = processDisplayFieldValues(input) 142 | expect(actual).toEqual(expected) 143 | }) 144 | 145 | it('returns string value unchanged', () => { 146 | const input = 'hello' 147 | const expected = 'hello' 148 | const actual = processDisplayFieldValues(input) 149 | expect(actual).toEqual(expected) 150 | }) 151 | 152 | it('should process nested object values into string', () => { 153 | const input = { 154 | nestedObj: { 155 | nestedBool: false, 156 | nestedArr: [4, 5, 6], 157 | platform: { 158 | eq: 'landsat-8' 159 | }, 160 | collections: ['landsat-c2-l2'] 161 | } 162 | } 163 | 164 | const result = processDisplayFieldValues(input) 165 | expect(result).toEqual( 166 | 'nestedObj: {nestedBool: false, nestedArr: [4, 5, 6], platform: {eq: landsat-8}, collections: [landsat-c2-l2]}' 167 | ) 168 | }) 169 | 170 | it('returns Unsupported Type for other types', () => { 171 | const input = Symbol('test') 172 | const expected = 'Unsupported Type' 173 | const actual = processDisplayFieldValues(input) 174 | expect(actual).toEqual(expected) 175 | }) 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /src/utils/datetime.js: -------------------------------------------------------------------------------- 1 | // date used for STAC API request for mosaic view 2 | export const convertDate = (dateTimeRef) => { 3 | return `${dateTimeRef[0]}/${dateTimeRef[1]}` 4 | } 5 | 6 | // date used for STAC API request for scene view 7 | export const convertDateForURL = (dateTimeRef) => 8 | encodeURIComponent(convertDate(dateTimeRef)) 9 | 10 | export function convertToUTC(dateString) { 11 | const [datePart, timePart] = dateString.split('T') 12 | const [year, month, day] = datePart.split('-').map(Number) 13 | const [hours, minutes, seconds] = timePart.slice(0, -1).split(':').map(Number) 14 | const utcDate = new Date( 15 | Date.UTC(year, month - 1, day, hours, minutes, seconds) 16 | ) 17 | 18 | // Get the timezone offset in minutes 19 | const offsetInMinutes = utcDate.getTimezoneOffset() 20 | 21 | // Add the offset to the UTC date to get the correct date for UI presentation in datepicker 22 | const correctDate = new Date(utcDate.getTime() + offsetInMinutes * 60 * 1000) 23 | 24 | return correctDate 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | // throttle function to prevent map from rendering too quickly 2 | export default function debounce(func, wait, immediate) { 3 | let timeout 4 | 5 | return function executedFunction() { 6 | const context = this 7 | const args = arguments 8 | 9 | const later = function () { 10 | timeout = null 11 | if (!immediate) func.apply(context, args) 12 | } 13 | 14 | const callNow = immediate && !timeout 15 | 16 | clearTimeout(timeout) 17 | 18 | timeout = setTimeout(later, wait) 19 | 20 | if (callNow) func.apply(context, args) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/geojsonValidation.js: -------------------------------------------------------------------------------- 1 | const GeoJSONValidation = { 2 | validate: function (geojson) { 3 | if ( 4 | typeof geojson === 'object' && 5 | geojson !== null && 6 | 'type' in geojson && 7 | [ 8 | 'Point', 9 | 'MultiPoint', 10 | 'LineString', 11 | 'MultiLineString', 12 | 'Polygon', 13 | 'MultiPolygon', 14 | 'GeometryCollection', 15 | 'Feature', 16 | 'FeatureCollection' 17 | ].includes(geojson.type) 18 | ) { 19 | return true 20 | } 21 | return false 22 | }, 23 | 24 | isValidGeoJSON: function (geojson) { 25 | return this.validate(geojson) 26 | }, 27 | 28 | isValidFeatureCollection: function (featureCollection) { 29 | return ( 30 | featureCollection.type === 'FeatureCollection' && 31 | Array.isArray(featureCollection.features) && 32 | featureCollection.features.every( 33 | (feature) => 34 | feature.type === 'Feature' && 35 | typeof feature.properties === 'object' && 36 | feature.properties !== null && 37 | typeof feature.geometry === 'object' && 38 | feature.geometry !== null && 39 | this.isValidGeoJSON(feature.geometry) 40 | ) 41 | ) 42 | }, 43 | 44 | isValidFeature: function (feature) { 45 | return ( 46 | feature.type === 'Feature' && 47 | typeof feature.properties === 'object' && 48 | feature.properties !== null && 49 | typeof feature.geometry === 'object' && 50 | feature.geometry !== null && 51 | this.isValidGeoJSON(feature.geometry) 52 | ) 53 | }, 54 | 55 | isValidGeometry: function (geometry) { 56 | const validTypes = [ 57 | 'Point', 58 | 'MultiPoint', 59 | 'LineString', 60 | 'MultiLineString', 61 | 'Polygon', 62 | 'MultiPolygon', 63 | 'GeometryCollection' 64 | ] 65 | 66 | if (!validTypes.includes(geometry.type)) { 67 | return false 68 | } 69 | 70 | if (geometry.type === 'GeometryCollection') { 71 | return ( 72 | Array.isArray(geometry.geometries) && 73 | geometry.geometries.every(this.isValidGeometry) 74 | ) 75 | } 76 | 77 | return ( 78 | typeof geometry.coordinates !== 'undefined' && 79 | Array.isArray(geometry.coordinates) 80 | ) 81 | }, 82 | 83 | isValidGeometryCollection: function (geometryCollection) { 84 | return ( 85 | this.isValidGeoJSON(geometryCollection) && 86 | geometryCollection.type === 'GeometryCollection' && 87 | Array.isArray(geometryCollection.geometries) && 88 | geometryCollection.geometries.every(this.isValidGeometry) 89 | ) 90 | } 91 | } 92 | 93 | export default GeoJSONValidation 94 | -------------------------------------------------------------------------------- /src/utils/geojsonValidation.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import GeoJSONValidation from './geojsonValidation' 3 | 4 | describe('GeoJSONValidation', () => { 5 | describe('validate', () => { 6 | it('should return true for valid GeoJSON', () => { 7 | const geojson = { type: 'Point', coordinates: [0, 0] } 8 | const result = GeoJSONValidation.validate(geojson) 9 | expect(result === true) 10 | }) 11 | 12 | it('should return false for invalid GeoJSON', () => { 13 | const geojson = { type: 'InvalidType', coordinates: [0, 0] } 14 | const result = GeoJSONValidation.validate(geojson) 15 | expect(result === false) 16 | }) 17 | }) 18 | 19 | describe('isValidGeoJSON', () => { 20 | it('should return true for valid GeoJSON', () => { 21 | const geojson = { 22 | type: 'Polygon', 23 | coordinates: [ 24 | [ 25 | [0, 0], 26 | [1, 1], 27 | [2, 2], 28 | [0, 0] 29 | ] 30 | ] 31 | } 32 | const result = GeoJSONValidation.isValidGeoJSON(geojson) 33 | expect(result === true) 34 | }) 35 | 36 | it('should return false for invalid GeoJSON', () => { 37 | const geojson = { type: 'InvalidType', coordinates: [0, 0] } 38 | const result = GeoJSONValidation.isValidGeoJSON(geojson) 39 | expect(result === false) 40 | }) 41 | }) 42 | 43 | describe('isValidFeatureCollection', () => { 44 | it('should return true for valid FeatureCollection', () => { 45 | const featureCollection = { 46 | type: 'FeatureCollection', 47 | features: [ 48 | { 49 | type: 'Feature', 50 | properties: { name: 'Feature 1' }, 51 | geometry: { type: 'Point', coordinates: [0, 0] } 52 | }, 53 | { 54 | type: 'Feature', 55 | properties: { name: 'Feature 2' }, 56 | geometry: { 57 | type: 'LineString', 58 | coordinates: [ 59 | [0, 0], 60 | [1, 1] 61 | ] 62 | } 63 | } 64 | ] 65 | } 66 | const result = 67 | GeoJSONValidation.isValidFeatureCollection(featureCollection) 68 | expect(result === true) 69 | }) 70 | 71 | it('should return false for invalid FeatureCollection', () => { 72 | const featureCollection = { 73 | type: 'FeatureCollection', 74 | features: [ 75 | { 76 | type: 'InvalidType', 77 | properties: { name: 'Feature 1' }, 78 | geometry: { type: 'Point', coordinates: [0, 0] } 79 | } 80 | ] 81 | } 82 | const result = 83 | GeoJSONValidation.isValidFeatureCollection(featureCollection) 84 | expect(result === false) 85 | }) 86 | }) 87 | 88 | describe('isValidFeature', () => { 89 | it('should return true for valid Feature', () => { 90 | const feature = { 91 | type: 'Feature', 92 | properties: { name: 'Feature 1' }, 93 | geometry: { type: 'Point', coordinates: [0, 0] } 94 | } 95 | const result = GeoJSONValidation.isValidFeature(feature) 96 | expect(result === true) 97 | }) 98 | 99 | it('should return false for invalid Feature', () => { 100 | const feature = { 101 | type: 'InvalidType', 102 | properties: { name: 'Feature 1' }, 103 | geometry: { type: 'Point', coordinates: [0, 0] } 104 | } 105 | const result = GeoJSONValidation.isValidFeature(feature) 106 | expect(result === false) 107 | }) 108 | }) 109 | 110 | describe('isValidGeometry', () => { 111 | it('should return true for valid Geometry', () => { 112 | const geometry = { type: 'Point', coordinates: [0, 0] } 113 | const result = GeoJSONValidation.isValidGeometry(geometry) 114 | expect(result === true) 115 | }) 116 | 117 | it('should return false for invalid Geometry', () => { 118 | const geometry = { type: 'InvalidType', coordinates: [0, 0] } 119 | const result = GeoJSONValidation.isValidGeometry(geometry) 120 | expect(result === false) 121 | }) 122 | }) 123 | 124 | describe('isValidGeometryCollection', () => { 125 | it('should return true for valid GeometryCollection', () => { 126 | const geometryCollection = { 127 | type: 'GeometryCollection', 128 | geometries: [ 129 | { type: 'Point', coordinates: [0, 0] }, 130 | { 131 | type: 'LineString', 132 | coordinates: [ 133 | [0, 0], 134 | [1, 1] 135 | ] 136 | } 137 | ] 138 | } 139 | const result = 140 | GeoJSONValidation.isValidGeometryCollection(geometryCollection) 141 | expect(result === true) 142 | }) 143 | 144 | it('should return false for invalid GeometryCollection', () => { 145 | const geometryCollection = { 146 | type: 'GeometryCollection', 147 | geometries: [{ type: 'InvalidType', coordinates: [0, 0] }] 148 | } 149 | const result = 150 | GeoJSONValidation.isValidGeometryCollection(geometryCollection) 151 | expect(result === false) 152 | }) 153 | }) 154 | }) 155 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as debounce } from './debounce' 2 | export { convertDate } from './datetime' 3 | export { convertDateForURL } from './datetime' 4 | export { colorMap } from './colorMap' 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node18/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "module": "Node16", 7 | "allowJs": true, 8 | "checkJs": false, 9 | "noEmit": true, 10 | "alwaysStrict": true, 11 | "noImplicitAny": false, 12 | "noImplicitOverride": true, 13 | "noImplicitReturns": true, 14 | "allowUnusedLabels": false, 15 | "allowUnreachableCode": false, 16 | "exactOptionalPropertyTypes": true, 17 | "strictNullChecks": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "noPropertyAccessFromIndexSignature": true, 20 | "noUncheckedIndexedAccess": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "typeRoots": [ 24 | "./@types", 25 | "node_modules/@types", 26 | "node_modules/@testing-library" 27 | ] 28 | }, 29 | "include": ["./src"], 30 | "references": [{ "path": "./tsconfig.node.json" }], 31 | "exclude": ["node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.mts"] 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.mts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | import react from '@vitejs/plugin-react' 6 | import viteTsconfigPaths from 'vite-tsconfig-paths' 7 | import { defineConfig, configDefaults } from 'vitest/config' 8 | import svgrPlugin from 'vite-plugin-svgr' 9 | 10 | export default defineConfig({ 11 | base: './', 12 | define: { 13 | 'process.env.REACT_APP_VERSION': JSON.stringify( 14 | require('./package.json').version 15 | ) 16 | }, 17 | plugins: [react(), viteTsconfigPaths(), svgrPlugin()], 18 | build: { 19 | outDir: 'build' 20 | }, 21 | server: { 22 | open: true, 23 | hmr: { 24 | overlay: false 25 | } 26 | }, 27 | test: { 28 | globals: true, 29 | environment: 'jsdom', 30 | setupFiles: ['./src/setupTests.js'], 31 | pool: 'threads', 32 | coverage: { 33 | provider: 'v8', 34 | reporter: ['text'], 35 | exclude: [...configDefaults.coverage.exclude, 'src/redux/*'] // ignore the redux boilerplate for coverage report 36 | } 37 | } 38 | }) 39 | --------------------------------------------------------------------------------