├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── checks.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example ├── .env.example ├── README.md ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.js │ ├── App.test.js │ ├── components │ │ ├── buttons │ │ │ └── index.jsx │ │ ├── form │ │ │ ├── Checkbox.jsx │ │ │ ├── Legend.jsx │ │ │ └── index.js │ │ └── headers │ │ │ └── index.jsx │ ├── config.js │ ├── index.css │ ├── index.js │ ├── layout │ │ ├── Header │ │ │ └── index.jsx │ │ ├── Panel │ │ │ └── index.jsx │ │ └── Section │ │ │ └── index.jsx │ ├── pages │ │ └── Main │ │ │ ├── ItemList.jsx │ │ │ ├── Map.jsx │ │ │ ├── QueryBuilder.jsx │ │ │ ├── index.jsx │ │ │ └── proptypes.js │ ├── reportWebVitals.js │ └── setupTests.js ├── tailwind.config.js └── yarn.lock ├── jest.config.js ├── jest.setup.ts ├── package.json ├── rollup.config.mjs ├── src ├── context │ └── index.tsx ├── hooks │ ├── useCollection.test.ts │ ├── useCollection.ts │ ├── useCollections.test.ts │ ├── useCollections.ts │ ├── useItem.test.ts │ ├── useItem.ts │ ├── useStacApi.test.ts │ ├── useStacApi.ts │ ├── useStacSearch.test.ts │ ├── useStacSearch.ts │ └── wrapper.tsx ├── index.ts ├── stac-api │ └── index.ts ├── types │ ├── index.d.ts │ └── stac.d.ts └── utils │ └── debounce.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | rollup.config.mjs 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": ["react", "react-hooks", "@typescript-eslint"], 10 | "env": { "browser": true }, 11 | "parserOptions": { 12 | "ecmaVersion": 5, 13 | "sourceType": "module", 14 | "project": "./tsconfig.json" 15 | }, 16 | "rules": { 17 | "@typescript-eslint/indent": ["error", 2], 18 | "quotes": ["error", "single"], 19 | "jsx-quotes": ["error", "prefer-double"], 20 | "semi": [2, "always"], 21 | "eol-last": ["error", "always"], 22 | "no-console": 2, 23 | "no-extra-semi": 2, 24 | "semi-spacing": [2, { "before": false, "after": true }], 25 | "no-dupe-else-if": 0, 26 | "no-setter-return": 0, 27 | "prefer-promise-reject-errors": 0, 28 | "react/button-has-type": 2, 29 | "react/default-props-match-prop-types": 2, 30 | "react/jsx-closing-bracket-location": 2, 31 | "react/jsx-closing-tag-location": 2, 32 | "react/jsx-curly-spacing": 2, 33 | "react/jsx-curly-newline": 2, 34 | "react/jsx-equals-spacing": 2, 35 | "react/jsx-max-props-per-line": [2, { "maximum": 1, "when": "multiline" }], 36 | "react/jsx-first-prop-new-line": 2, 37 | "react/jsx-curly-brace-presence": [ 38 | 2, 39 | { "props": "never", "children": "never" } 40 | ], 41 | "react/jsx-pascal-case": 2, 42 | "react/jsx-props-no-multi-spaces": 2, 43 | "react/jsx-tag-spacing": [2, { "beforeClosing": "never" }], 44 | "react/jsx-wrap-multilines": 2, 45 | "react/no-array-index-key": 2, 46 | "react/no-typos": 2, 47 | "react/no-unsafe": 2, 48 | "react/no-unused-prop-types": 2, 49 | "react/no-unused-state": 2, 50 | "react/self-closing-comp": 2, 51 | "react/sort-comp": 2, 52 | "react/style-prop-object": 2, 53 | "react/void-dom-elements-no-children": 2, 54 | "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks 55 | "react-hooks/exhaustive-deps": "warn" 56 | }, 57 | "settings": { 58 | "react": { 59 | "version": "detect" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Checks 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | env: 11 | NODE: 16 12 | 13 | jobs: 14 | prep: 15 | if: github.event.pull_request.draft == false 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Cancel Previous Runs 20 | uses: styfle/cancel-workflow-action@0.8.0 21 | with: 22 | access_token: ${{ github.token }} 23 | 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | 27 | - name: Use Node.js ${{ env.NODE }} 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: ${{ env.NODE }} 31 | 32 | - name: Cache node_modules 33 | uses: actions/cache@v2 34 | id: cache-node-modules 35 | with: 36 | path: node_modules 37 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} 38 | 39 | - name: Install 40 | run: yarn install 41 | 42 | lint: 43 | needs: prep 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - name: Checkout 48 | uses: actions/checkout@v2 49 | 50 | - name: Use Node.js ${{ env.NODE }} 51 | uses: actions/setup-node@v1 52 | with: 53 | node-version: ${{ env.NODE }} 54 | 55 | - name: Cache node_modules 56 | uses: actions/cache@v2 57 | id: cache-node-modules 58 | with: 59 | path: node_modules 60 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} 61 | 62 | - name: Install 63 | run: yarn install 64 | 65 | - name: Lint 66 | run: yarn lint 67 | 68 | test: 69 | needs: prep 70 | runs-on: ubuntu-latest 71 | 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v2 75 | 76 | - name: Use Node.js ${{ env.NODE }} 77 | uses: actions/setup-node@v1 78 | with: 79 | node-version: ${{ env.NODE }} 80 | 81 | - name: Cache node_modules 82 | uses: actions/cache@v2 83 | id: cache-node-modules 84 | with: 85 | path: node_modules 86 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} 87 | 88 | - name: Install 89 | run: yarn install 90 | 91 | - name: Test 92 | run: yarn test 93 | 94 | build: 95 | needs: test 96 | runs-on: ubuntu-latest 97 | 98 | steps: 99 | - name: Checkout 100 | uses: actions/checkout@v2 101 | 102 | - name: Use Node.js ${{ env.NODE }} 103 | uses: actions/setup-node@v1 104 | with: 105 | node-version: ${{ env.NODE }} 106 | 107 | - name: Cache node_modules 108 | uses: actions/cache@v2 109 | id: cache-node-modules 110 | with: 111 | path: node_modules 112 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package.json') }} 113 | 114 | - name: Install 115 | run: yarn install 116 | 117 | - name: Test 118 | run: yarn build 119 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /example/node_modules 3 | dist 4 | /example/build 5 | .env 6 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | node_modules 3 | .github 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Development Seed 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stac-react 2 | 3 | React hooks to build front-end applications for STAC APIs. 4 | 5 | > **Note:** 6 | > stac-react is in early development, the API will likely break in future versions. 7 | 8 | ## Installation 9 | 10 | With NPM: 11 | 12 | ```sh 13 | npm i @developmentseed/stac-react 14 | ``` 15 | 16 | With Yarn: 17 | 18 | ```sh 19 | yarn add @developmentseed/stac-react 20 | ``` 21 | 22 | ## Getting started 23 | 24 | Stac-react's hooks must be used inside children of a React context that provides access to the stac-react's core functionality. 25 | 26 | To get started, initialize `StacApiProvider` with the base URL of the STAC catalog. 27 | 28 | ```jsx 29 | import { StacApiProvider } from "stac-react"; 30 | 31 | function StacApp() { 32 | return ( 33 | 34 | // Other components 35 | 36 | ); 37 | } 38 | ``` 39 | 40 | Now you can start using stac-react hooks in child components of `StacApiProvider` 41 | 42 | ```jsx 43 | import { StacApiProvider, useCollections } from "stac-react"; 44 | 45 | function Collections() { 46 | const { collections } = useCollections(); 47 | 48 | return ( 49 | 54 | 55 | ) 56 | } 57 | 58 | function StacApp() { 59 | return ( 60 | 61 | 62 | 63 | ); 64 | } 65 | ``` 66 | 67 | ## API 68 | 69 | ### StacApiProvider 70 | 71 | Provides the React context required for stac-react hooks. 72 | 73 | #### Initialization 74 | 75 | ```jsx 76 | import { StacApiProvider } from "stac-react"; 77 | 78 | function StacApp() { 79 | return ( 80 | 81 | // Other components 82 | 83 | ); 84 | } 85 | ``` 86 | 87 | ##### Component Properties 88 | 89 | Option | Type | Description 90 | --------------- | --------- | ------------- 91 | `apiUrl`. | `string` | The base url of the STAC catalog. 92 | 93 | ### useCollections 94 | 95 | Retrieves collections from a STAC catalog. 96 | 97 | #### Initialization 98 | 99 | ```js 100 | import { useCollections } from "stac-react"; 101 | const { collections } = useCollections(); 102 | ``` 103 | 104 | #### Return values 105 | 106 | Option | Type | Description 107 | --------------- | --------- | ------------- 108 | `collections` | `array` | A list of collections available from the STAC catalog. Is `null` if collections have not been retrieved. 109 | `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. 110 | `reload` | `function`| Callback function to trigger a reload of collections. 111 | `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. 112 | 113 | #### Example 114 | 115 | ```js 116 | import { useCollections } from "stac-react"; 117 | 118 | function CollectionList() { 119 | const { collections, state } = useCollections(); 120 | 121 | if (state === "LOADING") { 122 | return

Loading collections...

123 | } 124 | 125 | return ( 126 | <> 127 | {collections ? ( 128 |
    129 | {collections.collections.map(({ id, title }) => ( 130 |
  • {title}
  • 131 | ))} 132 |
133 | 134 | ): ( 135 |

No collections

136 | )} 137 | 138 | ); 139 | } 140 | ``` 141 | 142 | ### useCollection 143 | 144 | Retrieves a single collection from the STAC catalog. 145 | 146 | #### Initialization 147 | 148 | ```js 149 | import { useCollection } from "stac-react"; 150 | const { collection } = useCollection(id); 151 | ``` 152 | 153 | #### Parameters 154 | 155 | Option | Type | Description 156 | --------------- | --------- | ------------- 157 | `id` | `string` | The collection ID. 158 | 159 | #### Return values 160 | 161 | Option | Type | Description 162 | --------------- | --------- | ------------- 163 | `collection` | `object` | The collection matching the provided ID. Is `null` if collection has not been retrieved. 164 | `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. 165 | `reload` | `function`| Callback function to trigger a reload of the collection. 166 | `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. 167 | 168 | #### Example 169 | 170 | ```js 171 | import { useCollection } from "stac-react"; 172 | 173 | function Collection() { 174 | const { collection, state } = useCollection("collection_id"); 175 | 176 | if (state === "LOADING") { 177 | return

Loading collection...

178 | } 179 | 180 | return ( 181 | <> 182 | {collection ? ( 183 | <> 184 |

{collection.id}

185 |

{collection.description}

186 | 187 | ) : ( 188 |

Not found

189 | )} 190 | 191 | ); 192 | } 193 | ``` 194 | 195 | ### useItem 196 | 197 | Retrieves an item from the STAC catalog. To retrieve an item, provide its full url to the `useItem` hook. 198 | 199 | #### Initialization 200 | 201 | ```js 202 | import { useItem } from "stac-react"; 203 | const { item } = useItem(url); 204 | ``` 205 | 206 | #### Parameters 207 | 208 | Option | Type | Description 209 | --------------- | --------- | ------------- 210 | `url` | `string` | The URL of the item you want to retrieve. 211 | 212 | #### Return values 213 | 214 | Option | Type | Description 215 | --------------- | --------- | ------------- 216 | `item` | `object` | The item matching the provided URL. 217 | `state` | `str` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. 218 | `reload` | `function`| Callback function to trigger a reload of the item. 219 | `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. 220 | 221 | #### Examples 222 | 223 | ```js 224 | import { useItem } from "stac-react"; 225 | 226 | function Item() { 227 | const { item, state } = useItem("https://stac-catalog.com/items/abc123"); 228 | 229 | if (state === "LOADING") { 230 | return

Loading item...

231 | } 232 | 233 | return ( 234 | <> 235 | {item ? ( 236 | <> 237 |

{item.id}

238 |

{items.description}

239 | 240 | ) : ( 241 |

Not found

242 | )} 243 | 244 | ); 245 | } 246 | ``` 247 | 248 | ### useStacSearch 249 | 250 | Executes a search against a STAC API using the provided search parameters. 251 | 252 | #### Initialization 253 | 254 | ```js 255 | import { useStacSearch } from "stac-react"; 256 | const { results } = useStacSearch(); 257 | ``` 258 | 259 | #### Return values 260 | 261 | Option | Type | Description 262 | ------------------ | --------- | ------------- 263 | `submit` | `function` | Callback to submit the search using the current filter parameters. Excecutes an API call to the specified STAC API. 264 | `ids` | `array` | List of item IDs to match in the search, `undefined` if unset. 265 | `setIds(itemIds)` | `function` | Callback to set `ids`. `itemIds` must be an `array` of `string` with the IDs of the selected items, or `undefined` to reset. 266 | `bbox` | `array` | Array of coordinates `[northWestLon, northWestLat, southEastLon, southEastLat]`, `undefined` if unset. 267 | `setBbox(bbox)` | `function` | Callback to set `bbox`. `bbox` must be an array of coordinates `[northWestLon, northWestLat, southEastLon, southEastLat]`, or `undefined` to reset. 268 | `collections` | `array` | List of select collection IDs included in the search query. `undefined` if unset. 269 | `setCollections(collectionIDs)` | `function` | Callback to set `collections`. `collectionIDs` must be an `array` of `string` with the IDs of the selected collections, or `undefined` to reset. 270 | `dateRangeFrom` | `string` | The from-date of the search query. `undefined` if unset. 271 | `setDateRangeFrom(fromDate)` | `function` | Callback to set `dateRangeFrom`. `fromDate` must be ISO representation of a date, ie. `2022-05-18`, or `undefined` to reset. 272 | `dateRangeTo` | `string` | The to-date of the search query. `undefined` if unset. 273 | `setDateRangeTo(toDate)` | `function` | Callback to set `dateRangeto`. `toDate` must be ISO representation of a date, ie. `2022-05-18`, or `undefined` to reset. 274 | `sortby` | `array` | Specifies the order of results. Array of `{ field: string, direction: 'asc' | 'desc' }` 275 | `setSortby(sort)` | `function` | Callback to set `sortby`. `sort` must be an array of `{ field: string, direction: 'asc' | 'desc' }`, or `undefined` to reset. 276 | `limit` | `number` | The number of results returned per result page. 277 | `setLimit(limit)` | `function` | Callback to set `limit`. `limit` must be a `number`, or `undefined` to reset. 278 | `results` | `object` | The result of the last search query; a [GeoJSON `FeatureCollection` with additional members](https://github.com/radiantearth/stac-api-spec/blob/v1.0.0-rc.2/fragments/itemcollection/README.md). `undefined` if the search request has not been submitted, or if there was an error. 279 | `state` | `string` | The status of the request. `"IDLE"` before and after the request is sent or received. `"LOADING"` when the request is in progress. 280 | `error` | [`Error`](#error) | Error information if the last request was unsuccessful. `undefined` if the last request was successful. 281 | `nextPage` | `function` | Callback function to load the next page of results. Is `undefined` if the last page is the currently loaded. 282 | `previousPage` | `function` | Callback function to load the previous page of results. Is `undefined` if the first page is the currently loaded. 283 | 284 | #### Examples 285 | 286 | ##### Render results 287 | 288 | ```jsx 289 | import { useStacSearch } from "stac-react"; 290 | 291 | function StacComponent() { 292 | const { result } = useStacSearch(); 293 | 294 | return ( 295 | <> 296 |
297 | {results && ( 298 |
    299 | {results.features.map(({ id }) => ( 300 |
  • { id }
  • 301 | ))} 302 |
303 | )} 304 |
305 | 306 | ) 307 | } 308 | ``` 309 | 310 | ##### Handle errors 311 | 312 | ```jsx 313 | import { useCallback } from "react"; 314 | import { useStacSearch } from "stac-react"; 315 | 316 | import Map from "./map"; 317 | 318 | function StacComponent() { 319 | const { error, result } = useStacSearch(); 320 | 321 | return ( 322 | <> 323 |
324 | {error &&

{ error.detail }

} 325 | {results && ( 326 |
    327 | {results.features.map(({ id }) => ( 328 |
  • { id }
  • 329 | ))} 330 |
331 | )} 332 |
333 | 334 | ) 335 | } 336 | ``` 337 | 338 | ##### Pagination 339 | 340 | ```jsx 341 | import { useStacSearch } from "stac-react"; 342 | 343 | function StacComponent() { 344 | const { 345 | nextPage, 346 | previousPage, 347 | result 348 | } = useStacSearch(); 349 | 350 | return ( 351 | <> 352 |
353 | {results && ( 354 | // render results 355 | )} 356 |
357 | 361 | 362 | ) 363 | } 364 | ``` 365 | 366 | ##### Set collections 367 | 368 | ```jsx 369 | import { useStacSearch } from "stac-react"; 370 | 371 | function StacComponent() { 372 | const { collections } = useCollections(); 373 | const { collections: selectedCollections, setCollections, results, submit } = useStacSearch(); 374 | 375 | const handleChange = useCallback((event) => { 376 | const { value } = event.target; 377 | 378 | const nextValues = selectedCollections.includes(value) 379 | ? selectedCollections.filter((v) => v !== value) 380 | : [ ...selectedCollections, value ]; 381 | 382 | setCollections(nextValues); 383 | }, [selectedCollections, setCollections]); 384 | 385 | return ( 386 | <> 387 |
388 |
389 |
390 | Select collections 391 | {collections.map(({ id, title }) => ( 392 | 400 | 401 | ))} 402 |
403 | 404 | 405 |
406 | 407 | ) 408 | } 409 | ``` 410 | 411 | ##### Set bounding box 412 | 413 | ```jsx 414 | import { useCallback } from "react"; 415 | import { useStacSearch } from "stac-react"; 416 | 417 | function StacComponent() { 418 | const { bbox, setBbox, submit } = useStacSearch(); 419 | 420 | const handleDrawComplete = useCallback((feature) => { 421 | setIsBboxDrawEnabled(false); 422 | 423 | const { coordinates } = feature.geometry; 424 | const bbox = [...coordinates[0][0], ...coordinates[0][2]]; 425 | setBbox(bbox); 426 | }, [setBbox]); 427 | 428 | 429 | } 430 | ``` 431 | 432 | This example assumes that a `Map` component handles drawing and calls `handleDrawComplete` to set the `bbox` for the search. `handleDrawComplete` is called with a GeoJSON feature representing the bounding box drawn on the map. 433 | 434 | ##### Set date range 435 | 436 | ```jsx 437 | import { useStacSearch } from "stac-react"; 438 | 439 | function StacComponent() { 440 | const { 441 | dateRangeFrom, 442 | setDateRangeFrom, 443 | dateRangeTo, 444 | setDateRangeTo, 445 | submit 446 | } = useStacSearch(); 447 | 448 | return ( 449 | <> 450 | 451 | 452 | 23 | ); 24 | } 25 | 26 | Button.propTypes = DefaultButtonType; 27 | Button.defaultProps = { 28 | type: 'button' 29 | } 30 | 31 | export function PrimaryButton({ children, ...rest }) { 32 | return ; 33 | } 34 | 35 | PrimaryButton.propTypes = ButtonType; 36 | -------------------------------------------------------------------------------- /example/src/components/form/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import T from 'prop-types'; 3 | import Legend from './Legend'; 4 | 5 | function Checkbox({ label, name, options, values, onChange }) { 6 | const handleChange = useCallback((event) => { 7 | const { value } = event.target; 8 | 9 | const nextValues = values.includes(value) 10 | ? values.filter((v) => v !== value) 11 | : [ ...values, value ]; 12 | 13 | onChange(nextValues); 14 | }, [values, onChange]); 15 | 16 | return ( 17 |
18 | {label} 19 | {options.map(({ value, label: optionLabel }) => { 20 | const fieldId = `${name}-${value}`; 21 | 22 | return ( 23 |
24 | 32 | 33 |
34 | ); 35 | })} 36 |
37 | ); 38 | } 39 | 40 | Checkbox.propTypes = { 41 | label: T.string.isRequired, 42 | name: T.string.isRequired, 43 | options: T.arrayOf( 44 | T.shape({ 45 | value: T.string.isRequired, 46 | label: T.string.isRequired 47 | }) 48 | ).isRequired, 49 | values: T.arrayOf(T.string).isRequired, 50 | onChange: T.func.isRequired 51 | } 52 | 53 | export default Checkbox; 54 | -------------------------------------------------------------------------------- /example/src/components/form/Legend.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | 3 | function Legend({ children, className }) { 4 | return { children }; 5 | } 6 | 7 | Legend.propTypes = { 8 | children: T.node.isRequired, 9 | className: T.string 10 | }; 11 | 12 | export default Legend; 13 | -------------------------------------------------------------------------------- /example/src/components/form/index.js: -------------------------------------------------------------------------------- 1 | import Checkbox from "./Checkbox"; 2 | import Legend from "./Legend"; 3 | 4 | export { 5 | Checkbox, 6 | Legend 7 | } 8 | -------------------------------------------------------------------------------- /example/src/components/headers/index.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | 3 | export function H2({ children, className }) { 4 | return

{children}

; 5 | } 6 | 7 | const Props = { 8 | children: T.node.isRequired, 9 | className: T.string 10 | } 11 | 12 | H2.propTypes = Props; 13 | -------------------------------------------------------------------------------- /example/src/config.js: -------------------------------------------------------------------------------- 1 | // This uses collection IDs from https://planetarycomputer.microsoft.com/api/stac/v1 2 | export const collectionsOptions = [ 3 | // { 4 | // value: 'aster-l1t', 5 | // label: 'ASTER L1T' 6 | // }, { 7 | // value: 'landsat-8-c2-l2', 8 | // label: 'Landsat 8 Collection 2 Level-2' 9 | // }, { 10 | // value: 'sentinel-2-l2a', 11 | // label: 'Sentinel-2 Level-2A' 12 | // }, 13 | { 14 | value: 'modis-09A1-061', 15 | label: 'MODIS Surface Reflectance 8-Day (500m)' 16 | } 17 | ]; 18 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /example/src/layout/Header/index.jsx: -------------------------------------------------------------------------------- 1 | function Header() { 2 | return ( 3 |
4 | 5 | STAC-React Example App 6 | 7 |
8 | ); 9 | } 10 | 11 | export default Header; 12 | -------------------------------------------------------------------------------- /example/src/layout/Panel/index.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | 3 | function Panel({ children, className }) { 4 | return
{ children }
; 5 | } 6 | 7 | Panel.propTypes = { 8 | children: T.node.isRequired, 9 | className: T.string 10 | }; 11 | 12 | export default Panel; 13 | -------------------------------------------------------------------------------- /example/src/layout/Section/index.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | 3 | function Section({ children, className }) { 4 | return
{ children }
; 5 | } 6 | 7 | Section.propTypes = { 8 | children: T.node.isRequired, 9 | className: T.string 10 | }; 11 | 12 | export default Section; 13 | -------------------------------------------------------------------------------- /example/src/pages/Main/ItemList.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | import { TItemList } from "./proptypes"; 3 | 4 | import { H2 } from "../../components/headers"; 5 | import Panel from "../../layout/Panel"; 6 | import { Button } from "../../components/buttons"; 7 | 8 | 9 | 10 | function PaginationButton ({disabled, onClick, children}) { 11 | return ( 12 | 19 | ); 20 | } 21 | 22 | PaginationButton.propTypes = { 23 | disabled: T.bool, 24 | onClick: T.func.isRequired, 25 | children: T.node.isRequired 26 | } 27 | 28 | 29 | 30 | function ItemList ({ items, isLoading, error, nextPage, previousPage }) { 31 | return ( 32 | 33 |
34 |

Item List

35 | {isLoading && (

Loading...

)} 36 | {error && (

{ error }

)} 37 | {items && ( 38 |
    39 | {items.features.map(({ id }) => ( 40 |
  • { id }
  • 41 | ))} 42 |
43 | )} 44 |
45 |
46 | Previous page 47 | Next page 48 |
49 |
50 | ); 51 | } 52 | 53 | ItemList.propTypes = { 54 | items: TItemList, 55 | isLoading: T.bool, 56 | error: T.string, 57 | previousPage: T.func, 58 | nextPage: T.func 59 | } 60 | 61 | export default ItemList; 62 | -------------------------------------------------------------------------------- /example/src/pages/Main/Map.jsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect } from 'react'; 2 | import T from 'prop-types'; 3 | 4 | import mapbox from 'mapbox-gl'; 5 | import MapboxDraw from '@mapbox/mapbox-gl-draw'; 6 | import StaticMode from '@mapbox/mapbox-gl-draw-static-mode'; 7 | import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; 8 | import 'mapbox-gl/dist/mapbox-gl.css'; 9 | 10 | import { TItemList } from './proptypes'; 11 | 12 | 13 | mapbox.accessToken = process.env.REACT_APP_MAPBOX_TOKEN || ''; 14 | 15 | const addDrawControl = (map, drawingCompleted) => { 16 | const { modes } = MapboxDraw; 17 | 18 | const options = { 19 | modes: { 20 | ...modes, 21 | draw_rectangle: DrawRectangle, 22 | static: StaticMode 23 | }, 24 | boxSelect: false, 25 | displayControlsDefault: false, 26 | }; 27 | const draw = new MapboxDraw(options); 28 | map.addControl(draw); 29 | map.on('draw.create', (e) => { 30 | const { features } = e; 31 | const feature = features[0]; 32 | map.getCanvas().style.cursor = ''; 33 | setTimeout(() => draw.changeMode('static'), 0); 34 | drawingCompleted(feature); 35 | }); 36 | return draw; 37 | }; 38 | 39 | function Map ({ className, isBboxDrawEnabled, handleDrawComplete, items }) { 40 | const mapContainerRef = useRef(); 41 | const drawControleRef = useRef(); 42 | const [ map, setMap ] = useState(); 43 | 44 | useEffect(() => { 45 | const m = new mapbox.Map({ 46 | container: mapContainerRef.current, 47 | style: 'mapbox://styles/mapbox/dark-v10', 48 | center: [-99.31640625, 40.04443758460856], 49 | zoom: 3, 50 | dragRotate: false, 51 | touchZoomRotate: false, 52 | attributionControl: true, 53 | }); 54 | 55 | const onLoad = () => { 56 | setMap(m); 57 | m.addSource('items', { 58 | type: 'geojson', 59 | data: { type: 'FeatureCollection', features: []} 60 | }); 61 | 62 | m.addLayer({ 63 | 'id': 'items', 64 | 'type': 'line', 65 | 'source': 'items', 66 | 'layout': {}, 67 | 'paint': { 68 | 'line-color': '#0080ff', 69 | 'line-width': 1 70 | } 71 | }); 72 | }; 73 | m.on('load', onLoad); 74 | drawControleRef.current = addDrawControl(m, handleDrawComplete); 75 | 76 | return () => { 77 | m.off('load', onLoad); 78 | if (map) { 79 | map.remove(); 80 | } 81 | }; 82 | }, []); 83 | 84 | useEffect(() => { 85 | if (isBboxDrawEnabled) { 86 | drawControleRef.current.deleteAll(); 87 | drawControleRef.current.changeMode('draw_rectangle'); 88 | map.getCanvas().style.cursor = 'crosshair'; 89 | } 90 | }, [isBboxDrawEnabled, map]); 91 | 92 | useEffect(() => { 93 | if(map) { 94 | if(items) { 95 | map.getSource('items').setData(items) 96 | } else { 97 | map.getSource('items').setData({ type: 'FeatureCollection', features: []}) 98 | } 99 | } 100 | }, [items, map]); 101 | 102 | return
Map
103 | } 104 | 105 | Map.propTypes = { 106 | className: T.string, 107 | isBboxDrawEnabled: T.bool, 108 | handleDrawComplete: T.func.isRequired, 109 | item: TItemList 110 | }; 111 | 112 | export default Map; 113 | -------------------------------------------------------------------------------- /example/src/pages/Main/QueryBuilder.jsx: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | import { useCallback, useMemo } from 'react'; 3 | 4 | import { PrimaryButton } from "../../components/buttons"; 5 | import { Checkbox, Legend } from '../../components/form'; 6 | import { H2 } from "../../components/headers"; 7 | import Panel from "../../layout/Panel"; 8 | import Section from '../../layout/Section'; 9 | 10 | function QueryBuilder ({ 11 | setIsBboxDrawEnabled, 12 | collections, 13 | selectedCollections, 14 | setCollections, 15 | handleSubmit, 16 | dateRangeFrom, 17 | setDateRangeFrom, 18 | dateRangeTo, 19 | setDateRangeTo 20 | }) { 21 | const handleEnableBbox = useCallback(() => setIsBboxDrawEnabled(true), [setIsBboxDrawEnabled]); 22 | 23 | const handleRangeFromChange = useCallback((e) => setDateRangeFrom(e.target.value), [setDateRangeFrom]); 24 | const handleRangeToChange = useCallback((e) => setDateRangeTo(e.target.value), [setDateRangeTo]); 25 | 26 | const collectionOptions = useMemo( 27 | () => collections.collections ? collections.collections.map(({ id, title }) => ({ value: id, label: title})) : [], 28 | [collections] 29 | ); 30 | 31 | return ( 32 | 33 |

Query Builder

34 | 35 |
36 | 43 |
44 | 45 |
46 |
47 | Select Date Range 48 | 49 | 50 | 51 | 52 |
53 |
54 | 55 |
56 | Set bbox 57 |
58 | 59 |
60 | Submit 61 |
62 |
63 | ); 64 | } 65 | 66 | QueryBuilder.propTypes = { 67 | setIsBboxDrawEnabled: T.func.isRequired, 68 | handleSubmit: T.func.isRequired, 69 | collections: T.object, 70 | selectedCollections: T.arrayOf(T.string), 71 | setCollections: T.func.isRequired, 72 | dateRangeFrom: T.string.isRequired, 73 | setDateRangeFrom: T.func.isRequired, 74 | dateRangeTo: T.string.isRequired, 75 | setDateRangeTo: T.func.isRequired 76 | } 77 | 78 | QueryBuilder.defaultProps = { 79 | collections: {} 80 | } 81 | 82 | export default QueryBuilder; 83 | -------------------------------------------------------------------------------- /example/src/pages/Main/index.jsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "react"; 2 | import { useStacSearch, useCollections, useStacApi, StacApiProvider } from "stac-react"; 3 | 4 | import ItemList from "./ItemList"; 5 | import Map from "./Map"; 6 | import QueryBuilder from "./QueryBuilder"; 7 | 8 | const options = { 9 | headers: { 10 | Authorization: "Basic " + btoa(process.env.REACT_APP_STAC_API_TOKEN + ":") 11 | } 12 | }; 13 | 14 | function Main() { 15 | const [isBboxDrawEnabled, setIsBboxDrawEnabled] = useState(false); 16 | const { collections } = useCollections(); 17 | const { 18 | setBbox, 19 | collections: selectedCollections, 20 | setCollections, 21 | dateRangeFrom, 22 | setDateRangeFrom, 23 | dateRangeTo, 24 | setDateRangeTo, 25 | submit, 26 | results, 27 | state, 28 | error, 29 | nextPage, 30 | previousPage 31 | } = useStacSearch(); 32 | 33 | const handleDrawComplete = useCallback((feature) => { 34 | setIsBboxDrawEnabled(false); 35 | 36 | const { coordinates } = feature.geometry; 37 | const bbox = [...coordinates[0][0], ...coordinates[0][2]]; 38 | setBbox(bbox); 39 | }, [setBbox]); 40 | 41 | return ( 42 |
43 | 54 | 61 | 67 |
68 | ); 69 | } 70 | 71 | export default Main; 72 | -------------------------------------------------------------------------------- /example/src/pages/Main/proptypes.js: -------------------------------------------------------------------------------- 1 | import T from 'prop-types'; 2 | 3 | export const TItem = { 4 | id: T.string.isRequired 5 | }; 6 | 7 | export const TItemList = T.shape({ 8 | features: T.arrayOf(T.shape(TItem)) 9 | }); 10 | -------------------------------------------------------------------------------- /example/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /example/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./src/**/*.{js,jsx,ts,tsx}", 4 | ], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [], 9 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | setupFilesAfterEnv: ['./jest.setup.ts'], 6 | testMatch: [ 7 | '**/*.test.ts', 8 | '**/*.test.tsx' 9 | ] 10 | }; 11 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | 3 | import { enableFetchMocks } from 'jest-fetch-mock'; 4 | enableFetchMocks(); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@developmentseed/stac-react", 3 | "version": "0.1.0-alpha.10", 4 | "description": "React components and hooks for building STAC-API front-ends", 5 | "repository": "git@github.com:developmentseed/stac-react.git", 6 | "author": "Oliver Roick ", 7 | "license": "MIT", 8 | "private": false, 9 | "main": "./dist/stac-react.cjs", 10 | "module": "./dist/stac-react.es.mjs", 11 | "types": "./dist/index.d.ts", 12 | "source": "./src/index.ts", 13 | "peerDependencies": { 14 | "react": "^18.1.0", 15 | "react-dom": "^18.1.0" 16 | }, 17 | "devDependencies": { 18 | "@rollup/plugin-typescript": "^9.0.2", 19 | "@testing-library/jest-dom": "^5.16.4", 20 | "@testing-library/react": "^13.1.1", 21 | "@testing-library/react-hooks": "^8.0.0", 22 | "@types/geojson": "^7946.0.8", 23 | "@types/jest": "^27.4.1", 24 | "@types/react": "^18.0.8", 25 | "@types/react-dom": "^18.0.3", 26 | "@typescript-eslint/eslint-plugin": "^5.21.0", 27 | "@typescript-eslint/parser": "^5.21.0", 28 | "eslint": "^8.14.0", 29 | "eslint-plugin-react": "^7.29.4", 30 | "eslint-plugin-react-hooks": "^4.5.0", 31 | "jest": "^27.5.1", 32 | "jest-fetch-mock": "^3.0.3", 33 | "react": "^18.1.0", 34 | "react-dom": "^18.1.0", 35 | "rollup": "^3.4.0", 36 | "rollup-plugin-dts": "^5.0.0", 37 | "ts-jest": "^27.1.4", 38 | "typescript": "^4.6.4" 39 | }, 40 | "scripts": { 41 | "test": "jest", 42 | "lint": "eslint 'src/**/*.ts' 'src/**/*.tsx'", 43 | "build": "rollup -c rollup.config.mjs" 44 | }, 45 | "dependencies": {} 46 | } 47 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import dts from "rollup-plugin-dts"; 3 | 4 | const input = "./src/index.ts"; 5 | const sourcemap = true; 6 | 7 | export default [ 8 | { 9 | input, 10 | output: { 11 | file: "dist/stac-react.es.mjs", 12 | format: "es", 13 | sourcemap, 14 | }, 15 | plugins: [ 16 | typescript({ exclude: ["**/*.test.ts"] }) 17 | ], 18 | }, 19 | { 20 | input, 21 | output: { 22 | file: "dist/index.d.ts", 23 | format: "es", 24 | }, 25 | plugins: [dts()], 26 | }, 27 | { 28 | input, 29 | output: { 30 | file: "dist/stac-react.cjs", 31 | format: "cjs", 32 | sourcemap, 33 | }, 34 | plugins: [ 35 | typescript({ exclude: ["**/*.test.ts"] }) 36 | ], 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /src/context/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useContext, useState, useCallback } from 'react'; 2 | import { createContext } from 'react'; 3 | 4 | import StacApi from '../stac-api'; 5 | import useStacApi from '../hooks/useStacApi'; 6 | import type { CollectionsResponse, Item } from '../types/stac'; 7 | import { GenericObject } from '../types'; 8 | 9 | type StacApiContextType = { 10 | stacApi?: StacApi; 11 | collections?: CollectionsResponse; 12 | setCollections: (collections?: CollectionsResponse) => void; 13 | getItem: (id: string) => Item | undefined; 14 | addItem: (id: string, item: Item) => void; 15 | deleteItem: (id: string) => void; 16 | } 17 | 18 | type StacApiProviderType = { 19 | apiUrl: string; 20 | children: React.ReactNode; 21 | options?: GenericObject; 22 | } 23 | 24 | export const StacApiContext = createContext({} as StacApiContextType); 25 | 26 | export function StacApiProvider({ children, apiUrl, options }: StacApiProviderType) { 27 | const { stacApi } = useStacApi(apiUrl, options); 28 | const [ collections, setCollections ] = useState(); 29 | const [ items, setItems ] = useState(new Map()); 30 | 31 | const getItem = useCallback((id: string) => items.get(id), [items]); 32 | 33 | const addItem = useCallback((itemPath: string, item: Item) => { 34 | setItems(new Map(items.set(itemPath, item))); 35 | }, [items]); 36 | 37 | const deleteItem = useCallback((itemPath: string) => { 38 | const tempItems = new Map(items); 39 | items.delete(itemPath); 40 | setItems(tempItems); 41 | }, [items]); 42 | 43 | const contextValue = useMemo(() => ({ 44 | stacApi, 45 | collections, 46 | setCollections, 47 | getItem, 48 | addItem, 49 | deleteItem 50 | }), [addItem, collections, deleteItem, getItem, stacApi]); 51 | 52 | return ( 53 | 54 | { children } 55 | 56 | ); 57 | } 58 | 59 | export function useStacApiContext() { 60 | const { 61 | stacApi, 62 | collections, 63 | setCollections, 64 | getItem, 65 | addItem, 66 | deleteItem 67 | } = useContext(StacApiContext); 68 | 69 | return { 70 | stacApi, 71 | collections, 72 | setCollections, 73 | getItem, 74 | addItem, 75 | deleteItem 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/hooks/useCollection.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | import { renderHook, act } from '@testing-library/react-hooks'; 3 | import useCollection from './useCollection'; 4 | import wrapper from './wrapper'; 5 | 6 | describe('useCollection' ,() => { 7 | beforeEach(() => { 8 | fetch.resetMocks(); 9 | }); 10 | 11 | it('queries collection', async () => { 12 | fetch 13 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 14 | .mockResponseOnce(JSON.stringify({ 15 | collections: [ 16 | {id: 'abc', title: 'Collection A'}, 17 | {id: 'def', title: 'Collection B'} 18 | ] 19 | })); 20 | 21 | const { result, waitForNextUpdate } = renderHook( 22 | () => useCollection('abc'), 23 | { wrapper } 24 | ); 25 | await waitForNextUpdate(); 26 | await waitForNextUpdate(); 27 | expect(result.current.collection).toEqual({id: 'abc', title: 'Collection A'}); 28 | expect(result.current.state).toEqual('IDLE'); 29 | }); 30 | 31 | it('returns error if collection does not exist', async () => { 32 | fetch 33 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 34 | .mockResponseOnce(JSON.stringify({ 35 | collections: [ 36 | {id: 'abc', title: 'Collection A'}, 37 | {id: 'def', title: 'Collection B'} 38 | ] 39 | })); 40 | 41 | const { result, waitForNextUpdate } = renderHook( 42 | () => useCollection('ghi'), 43 | { wrapper } 44 | ); 45 | await waitForNextUpdate(); 46 | await waitForNextUpdate(); 47 | expect(result.current.error).toEqual({ 48 | status: 404, 49 | statusText: 'Not found', 50 | detail: 'Collection does not exist' 51 | }); 52 | }); 53 | 54 | it('handles error with JSON response', async () => { 55 | fetch 56 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 57 | .mockResponseOnce(JSON.stringify({ error: 'Wrong query' }), { status: 400, statusText: 'Bad Request' }); 58 | 59 | const { result, waitForNextUpdate } = renderHook( 60 | () => useCollection('abc'), 61 | { wrapper } 62 | ); 63 | await waitForNextUpdate(); 64 | await waitForNextUpdate(); 65 | 66 | expect(result.current.error).toEqual({ 67 | status: 400, 68 | statusText: 'Bad Request', 69 | detail: { error: 'Wrong query' } 70 | }); 71 | }); 72 | 73 | it('handles error with non-JSON response', async () => { 74 | fetch 75 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 76 | .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); 77 | 78 | const { result, waitForNextUpdate } = renderHook( 79 | () => useCollection('abc'), 80 | { wrapper } 81 | ); 82 | await waitForNextUpdate(); 83 | await waitForNextUpdate(); 84 | 85 | expect(result.current.error).toEqual({ 86 | status: 400, 87 | statusText: 'Bad Request', 88 | detail: 'Wrong query' 89 | }); 90 | }); 91 | 92 | it('reloads collection', async () => { 93 | fetch 94 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 95 | .mockResponseOnce(JSON.stringify({ 96 | collections: [ 97 | {id: 'abc', title: 'Collection A'}, 98 | {id: 'def', title: 'Collection B'} 99 | ] 100 | })) 101 | .mockResponseOnce(JSON.stringify({ 102 | collections: [ 103 | {id: 'abc', title: 'Collection A - Updated'}, 104 | {id: 'def', title: 'Collection B'} 105 | ] 106 | })); 107 | 108 | const { result, waitForNextUpdate } = renderHook( 109 | () => useCollection('abc'), 110 | { wrapper } 111 | ); 112 | await waitForNextUpdate(); 113 | await waitForNextUpdate(); 114 | expect(result.current.collection).toEqual({id: 'abc', title: 'Collection A'}); 115 | 116 | act(() => result.current.reload()); 117 | 118 | await waitForNextUpdate(); 119 | expect(result.current.collection).toEqual({id: 'abc', title: 'Collection A - Updated'}); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/hooks/useCollection.ts: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useEffect } from 'react'; 2 | 3 | import type { ApiError, LoadingState } from '../types'; 4 | import type { Collection } from '../types/stac'; 5 | import useCollections from './useCollections'; 6 | 7 | type StacCollectionHook = { 8 | collection?: Collection, 9 | state: LoadingState, 10 | error?: ApiError, 11 | reload: () => void 12 | }; 13 | 14 | function useCollection(collectionId: string): StacCollectionHook { 15 | const { collections, state, error: requestError, reload } = useCollections(); 16 | const [ error, setError ] = useState(); 17 | 18 | useEffect(() => { 19 | setError(requestError); 20 | }, [requestError]); 21 | 22 | const collection = useMemo( 23 | () => { 24 | const coll = collections?.collections.find(({ id }) => id === collectionId); 25 | if (!coll) { 26 | setError({ 27 | status: 404, 28 | statusText: 'Not found', 29 | detail: 'Collection does not exist' 30 | }); 31 | } 32 | return coll; 33 | }, 34 | [collectionId, collections] 35 | ); 36 | 37 | return { 38 | collection, 39 | state, 40 | error, 41 | reload 42 | }; 43 | } 44 | 45 | export default useCollection; 46 | -------------------------------------------------------------------------------- /src/hooks/useCollections.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | import { renderHook, act } from '@testing-library/react-hooks'; 3 | import useCollections from './useCollections'; 4 | import wrapper from './wrapper'; 5 | 6 | describe('useCollections', () => { 7 | beforeEach(() => { 8 | fetch.resetMocks(); 9 | }); 10 | 11 | it('queries collections', async () => { 12 | fetch 13 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 14 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 15 | 16 | const { result, waitForNextUpdate } = renderHook( 17 | () => useCollections(), 18 | { wrapper } 19 | ); 20 | await waitForNextUpdate(); 21 | await waitForNextUpdate(); 22 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/collections'); 23 | expect(result.current.collections).toEqual({ data: '12345' }); 24 | expect(result.current.state).toEqual('IDLE'); 25 | }); 26 | 27 | it('reloads collections', async () => { 28 | fetch 29 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 30 | .mockResponseOnce(JSON.stringify({ data: 'original' })) 31 | .mockResponseOnce(JSON.stringify({ data: 'reloaded' })); 32 | 33 | const { result, waitForNextUpdate } = renderHook( 34 | () => useCollections(), 35 | { wrapper } 36 | ); 37 | await waitForNextUpdate(); 38 | await waitForNextUpdate(); 39 | expect(result.current.collections).toEqual({ data: 'original' }); 40 | 41 | expect(result.current.state).toEqual('IDLE'); 42 | act(() => result.current.reload()); 43 | 44 | await waitForNextUpdate(); 45 | expect(result.current.collections).toEqual({ data: 'reloaded' }); 46 | }); 47 | 48 | it('handles error with JSON response', async () => { 49 | fetch 50 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 51 | .mockResponseOnce(JSON.stringify({ error: 'Wrong query' }), { status: 400, statusText: 'Bad Request' }); 52 | 53 | const { result, waitForNextUpdate } = renderHook( 54 | () => useCollections(), 55 | { wrapper } 56 | ); 57 | 58 | await waitForNextUpdate(); 59 | await waitForNextUpdate(); 60 | 61 | expect(result.current.error).toEqual({ 62 | status: 400, 63 | statusText: 'Bad Request', 64 | detail: { error: 'Wrong query' } 65 | }); 66 | }); 67 | 68 | it('handles error with non-JSON response', async () => { 69 | fetch 70 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 71 | .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); 72 | 73 | const { result, waitForNextUpdate } = renderHook( 74 | () => useCollections(), 75 | { wrapper } 76 | ); 77 | await waitForNextUpdate(); 78 | await waitForNextUpdate(); 79 | expect(result.current.error).toEqual({ 80 | status: 400, 81 | statusText: 'Bad Request', 82 | detail: 'Wrong query' 83 | }); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/hooks/useCollections.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState, useMemo } from 'react'; 2 | import { type ApiError, type LoadingState } from '../types'; 3 | import type { CollectionsResponse } from '../types/stac'; 4 | import debounce from '../utils/debounce'; 5 | import { useStacApiContext } from '../context'; 6 | 7 | type StacCollectionsHook = { 8 | collections?: CollectionsResponse, 9 | reload: () => void, 10 | state: LoadingState 11 | error?: ApiError 12 | }; 13 | 14 | function useCollections(): StacCollectionsHook { 15 | const { stacApi, collections, setCollections } = useStacApiContext(); 16 | const [ state, setState ] = useState('IDLE'); 17 | const [ error, setError ] = useState(); 18 | 19 | const _getCollections = useCallback( 20 | () => { 21 | if (stacApi) { 22 | setState('LOADING'); 23 | 24 | stacApi.getCollections() 25 | .then(response => response.json()) 26 | .then(setCollections) 27 | .catch((err) => { 28 | setError(err); 29 | setCollections(undefined); 30 | }) 31 | .finally(() => setState('IDLE')); 32 | } 33 | }, 34 | [setCollections, stacApi] 35 | ); 36 | const getCollections = useMemo(() => debounce(_getCollections), [_getCollections]); 37 | 38 | useEffect( 39 | () => { 40 | if (stacApi && !error && !collections) { 41 | getCollections(); 42 | } 43 | }, 44 | [getCollections, stacApi, collections, error] 45 | ); 46 | 47 | return { 48 | collections, 49 | reload: getCollections, 50 | state, 51 | error, 52 | }; 53 | } 54 | 55 | export default useCollections; 56 | -------------------------------------------------------------------------------- /src/hooks/useItem.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | import { renderHook, act } from '@testing-library/react-hooks'; 3 | import useItem from './useItem'; 4 | import wrapper from './wrapper'; 5 | 6 | describe('useItem', () => { 7 | beforeEach(() => { 8 | fetch.resetMocks(); 9 | }); 10 | 11 | it('queries item', async () => { 12 | fetch 13 | .mockResponseOnce(JSON.stringify({ id: 'abc', links: [] })) 14 | .mockResponseOnce(JSON.stringify({ id: 'abc' })); 15 | 16 | const { result, waitForNextUpdate } = renderHook( 17 | () => useItem('https://fake-stac-api.net/items/abc'), 18 | { wrapper } 19 | ); 20 | await waitForNextUpdate(); 21 | expect(result.current.item).toEqual({ id: 'abc' }); 22 | expect(result.current.state).toEqual('IDLE'); 23 | }); 24 | 25 | it('handles error with JSON response', async () => { 26 | fetch 27 | .mockResponseOnce(JSON.stringify({ id: 'abc', links: [] })) 28 | .mockResponseOnce(JSON.stringify({ error: 'Wrong query' }), { status: 400, statusText: 'Bad Request' }); 29 | 30 | const { result, waitForNextUpdate } = renderHook( 31 | () => useItem('https://fake-stac-api.net/items/abc'), 32 | { wrapper } 33 | ); 34 | await waitForNextUpdate(); 35 | 36 | expect(result.current.error).toEqual({ 37 | status: 400, 38 | statusText: 'Bad Request', 39 | detail: { error: 'Wrong query' } 40 | }); 41 | }); 42 | 43 | it('handles error with non-JSON response', async () => { 44 | fetch 45 | .mockResponseOnce(JSON.stringify({ id: 'abc', links: [] })) 46 | .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); 47 | 48 | const { result, waitForNextUpdate } = renderHook( 49 | () => useItem('https://fake-stac-api.net/items/abc'), 50 | { wrapper } 51 | ); 52 | await waitForNextUpdate(); 53 | 54 | expect(result.current.error).toEqual({ 55 | status: 400, 56 | statusText: 'Bad Request', 57 | detail: 'Wrong query' 58 | }); 59 | }); 60 | 61 | it('reloads item', async () => { 62 | fetch 63 | .mockResponseOnce(JSON.stringify({ id: 'abc', links: [] })) 64 | .mockResponseOnce(JSON.stringify({ id: 'abc' })) 65 | .mockResponseOnce(JSON.stringify({ id: 'abc', description: 'Updated' })); 66 | 67 | const { result, waitForNextUpdate } = renderHook( 68 | () => useItem('https://fake-stac-api.net/items/abc'), 69 | { wrapper } 70 | ); 71 | await waitForNextUpdate(); 72 | expect(result.current.item).toEqual({ id: 'abc' }); 73 | 74 | act(() => result.current.reload()); 75 | 76 | await waitForNextUpdate(); 77 | expect(result.current.item).toEqual({ id: 'abc', description: 'Updated' }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/hooks/useItem.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback} from 'react'; 2 | import { Item } from '../types/stac'; 3 | import { ApiError, LoadingState } from '../types'; 4 | import { useStacApiContext } from '../context'; 5 | 6 | type ItemHook = { 7 | item?: Item; 8 | state: LoadingState; 9 | error?: ApiError; 10 | reload: () => void; 11 | } 12 | 13 | function useItem(url: string): ItemHook { 14 | const { stacApi, getItem, addItem, deleteItem } = useStacApiContext(); 15 | const [ state, setState ] = useState('IDLE'); 16 | const [ item, setItem ] = useState(); 17 | const [ error, setError ] = useState(); 18 | 19 | useEffect(() => { 20 | if (!stacApi) return; 21 | 22 | setState('LOADING'); 23 | new Promise((resolve, reject) => { 24 | const i = getItem(url); 25 | if (i) { 26 | resolve(i); 27 | } else { 28 | stacApi.fetch(url) 29 | .then(r => r.json()) 30 | .then(r => { 31 | addItem(url, r); 32 | resolve(r); 33 | }) 34 | .catch((err) => reject(err)); 35 | } 36 | }) 37 | .then(setItem) 38 | .catch((err) => setError(err)) 39 | .finally(() => setState('IDLE')); 40 | }, [stacApi, addItem, getItem, url]); 41 | 42 | const fetchItem = useCallback(() => { 43 | if (!stacApi) return; 44 | 45 | setState('LOADING'); 46 | new Promise((resolve, reject) => { 47 | const i = getItem(url); 48 | if (i) { 49 | resolve(i); 50 | } else { 51 | stacApi.fetch(url) 52 | .then(r => r.json()) 53 | .then(r => { 54 | addItem(url, r); 55 | resolve(r); 56 | }) 57 | .catch((err) => reject(err)); 58 | } 59 | }) 60 | .then(setItem) 61 | .catch((err) => setError(err)) 62 | .finally(() => setState('IDLE')); 63 | }, [addItem, getItem, stacApi, url]); 64 | 65 | const reload = useCallback(() => { 66 | deleteItem(url); 67 | fetchItem(); 68 | }, [deleteItem, fetchItem, url]); 69 | 70 | return { 71 | item, 72 | state, 73 | error, 74 | reload 75 | }; 76 | } 77 | 78 | export default useItem; 79 | -------------------------------------------------------------------------------- /src/hooks/useStacApi.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | import useCollections from './useCollections'; 4 | import wrapper from './wrapper'; 5 | 6 | describe('useStacApi', () => { 7 | beforeEach(() => fetch.resetMocks()); 8 | it('initilises StacAPI', async () => { 9 | fetch 10 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net' }) 11 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 12 | 13 | const { waitForNextUpdate } = renderHook( 14 | () => useCollections(), 15 | { wrapper } 16 | ); 17 | await waitForNextUpdate(); 18 | await waitForNextUpdate(); 19 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/collections'); 20 | }); 21 | 22 | it('initilises StacAPI with redirect URL', async () => { 23 | fetch 24 | .mockResponseOnce(JSON.stringify({ links: [] }), { url: 'https://fake-stac-api.net/redirect/' }) 25 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 26 | 27 | const { waitForNextUpdate } = renderHook( 28 | () => useCollections(), 29 | { wrapper } 30 | ); 31 | await waitForNextUpdate(); 32 | await waitForNextUpdate(); 33 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/redirect/collections'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/hooks/useStacApi.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import StacApi, { SearchMode } from '../stac-api'; 3 | import { Link } from '../types/stac'; 4 | import { GenericObject } from '../types'; 5 | 6 | type StacApiHook = { 7 | stacApi?: StacApi 8 | } 9 | 10 | function useStacApi(url: string, options?: GenericObject): StacApiHook { 11 | const [ stacApi, setStacApi ] = useState(); 12 | 13 | useEffect(() => { 14 | let baseUrl: string; 15 | let searchMode = SearchMode.GET; 16 | 17 | fetch(url, { 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | ...options?.headers 21 | } 22 | }) 23 | .then(response => { 24 | baseUrl = response.url; 25 | return response; 26 | }) 27 | .then(response => response.json()) 28 | .then(response => { 29 | const doesPost = response.links.find(({ rel, method }: Link) => rel === 'search' && method === 'POST'); 30 | if (doesPost) { 31 | searchMode = SearchMode.POST; 32 | } 33 | }) 34 | .then(() => setStacApi(new StacApi(baseUrl, searchMode, options))); 35 | }, [url, options]); 36 | 37 | return { stacApi }; 38 | } 39 | 40 | export default useStacApi; 41 | -------------------------------------------------------------------------------- /src/hooks/useStacSearch.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'jest-fetch-mock'; 2 | import { renderHook, act } from '@testing-library/react-hooks'; 3 | import useStacSearch from './useStacSearch'; 4 | import wrapper from './wrapper'; 5 | 6 | function parseRequestPayload(mockApiCall?: RequestInit) { 7 | if (!mockApiCall) { 8 | throw new Error('Unable to parse request payload. The mock API call is undefined.'); 9 | } 10 | return JSON.parse(mockApiCall.body as string); 11 | } 12 | 13 | describe('useStacSearch — API supports POST', () => { 14 | beforeEach(() => fetch.resetMocks()); 15 | 16 | it('includes IDs in search', async () => { 17 | fetch 18 | .mockResponseOnce( 19 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 20 | { url: 'https://fake-stac-api.net' } 21 | ) 22 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 23 | 24 | const { result, waitForNextUpdate } = renderHook( 25 | () => useStacSearch(), 26 | { wrapper } 27 | ); 28 | await waitForNextUpdate(); 29 | 30 | act(() => result.current.setIds(['collection_1', 'collection_2'])); 31 | act(() => result.current.submit()); 32 | 33 | await waitForNextUpdate(); 34 | 35 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 36 | expect(postPayload).toEqual({ ids: ['collection_1', 'collection_2'], limit: 25 }); 37 | expect(result.current.results).toEqual({ data: '12345' }); 38 | }); 39 | 40 | it('includes Bbox in search', async () => { 41 | fetch 42 | .mockResponseOnce( 43 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 44 | { url: 'https://fake-stac-api.net' } 45 | ) 46 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 47 | 48 | const { result, waitForNextUpdate } = renderHook( 49 | () => useStacSearch(), 50 | { wrapper } 51 | ); 52 | await waitForNextUpdate(); 53 | 54 | act(() => result.current.setBbox([-0.59, 51.24, 0.30, 51.74])); 55 | act(() => result.current.submit()); 56 | 57 | await waitForNextUpdate(); 58 | 59 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 60 | expect(postPayload).toEqual({ bbox: [-0.59, 51.24, 0.30, 51.74], limit: 25 }); 61 | expect(result.current.results).toEqual({ data: '12345' }); 62 | }); 63 | 64 | it('includes Bbox in search in correct order', async () => { 65 | fetch 66 | .mockResponseOnce( 67 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 68 | { url: 'https://fake-stac-api.net' } 69 | ) 70 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 71 | 72 | const { result, waitForNextUpdate } = renderHook( 73 | () => useStacSearch(), 74 | { wrapper } 75 | ); 76 | await waitForNextUpdate(); 77 | 78 | act(() => result.current.setBbox([0.30, 51.74, -0.59, 51.24])); 79 | act(() => result.current.submit()); 80 | await waitForNextUpdate(); 81 | 82 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 83 | expect(postPayload).toEqual({ bbox: [-0.59, 51.24, 0.30, 51.74], limit: 25 }); 84 | expect(result.current.results).toEqual({ data: '12345' }); 85 | }); 86 | 87 | it('includes Collections in search', async () => { 88 | fetch 89 | .mockResponseOnce( 90 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 91 | { url: 'https://fake-stac-api.net' } 92 | ) 93 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 94 | 95 | const { result, waitForNextUpdate } = renderHook( 96 | () => useStacSearch(), 97 | { wrapper } 98 | ); 99 | await waitForNextUpdate(); 100 | 101 | act(() => result.current.setCollections(['wildfire', 'surface_temp'])); 102 | act(() => result.current.submit()); 103 | await waitForNextUpdate(); 104 | 105 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 106 | expect(postPayload).toEqual({ collections: ['wildfire', 'surface_temp'], limit: 25 }); 107 | expect(result.current.results).toEqual({ data: '12345' }); 108 | }); 109 | 110 | it('clears collections when array is empty', async () => { 111 | fetch 112 | .mockResponseOnce( 113 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 114 | { url: 'https://fake-stac-api.net' } 115 | ) 116 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 117 | 118 | const { result, waitForNextUpdate } = renderHook( 119 | () => useStacSearch(), 120 | { wrapper } 121 | ); 122 | await waitForNextUpdate(); 123 | 124 | act(() => result.current.setCollections([])); 125 | act(() => result.current.submit()); 126 | await waitForNextUpdate(); 127 | 128 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 129 | expect(postPayload).toEqual({ limit: 25 }); 130 | expect(result.current.results).toEqual({ data: '12345' }); 131 | }); 132 | 133 | it('includes date range in search', async () => { 134 | fetch 135 | .mockResponseOnce( 136 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 137 | { url: 'https://fake-stac-api.net' } 138 | ) 139 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 140 | 141 | const { result, waitForNextUpdate } = renderHook( 142 | () => useStacSearch(), 143 | { wrapper } 144 | ); 145 | await waitForNextUpdate(); 146 | 147 | act(() => result.current.setDateRangeFrom('2022-01-17')); 148 | act(() => result.current.setDateRangeTo('2022-05-17')); 149 | act(() => result.current.submit()); 150 | await waitForNextUpdate(); 151 | 152 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 153 | expect(postPayload).toEqual({ datetime: '2022-01-17/2022-05-17', limit: 25 }); 154 | expect(result.current.results).toEqual({ data: '12345' }); 155 | }); 156 | 157 | it('includes open date range in search (no to-date)', async () => { 158 | fetch 159 | .mockResponseOnce( 160 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 161 | { url: 'https://fake-stac-api.net' } 162 | ) 163 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 164 | 165 | const { result, waitForNextUpdate } = renderHook( 166 | () => useStacSearch(), 167 | { wrapper } 168 | ); 169 | await waitForNextUpdate(); 170 | 171 | act(() => result.current.setDateRangeFrom('2022-01-17')); 172 | act(() => result.current.submit()); 173 | await waitForNextUpdate(); 174 | 175 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 176 | expect(postPayload).toEqual({ datetime: '2022-01-17/..', limit: 25 }); 177 | expect(result.current.results).toEqual({ data: '12345' }); 178 | }); 179 | 180 | it('includes open date range in search (no from-date)', async () => { 181 | fetch 182 | .mockResponseOnce( 183 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 184 | { url: 'https://fake-stac-api.net' } 185 | ) 186 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 187 | 188 | const { result, waitForNextUpdate } = renderHook( 189 | () => useStacSearch(), 190 | { wrapper } 191 | ); 192 | await waitForNextUpdate(); 193 | 194 | act(() => result.current.setDateRangeTo('2022-05-17')); 195 | act(() => result.current.submit()); 196 | await waitForNextUpdate(); 197 | 198 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 199 | expect(postPayload).toEqual({ datetime: '../2022-05-17', limit: 25 }); 200 | expect(result.current.results).toEqual({ data: '12345' }); 201 | }); 202 | 203 | it('handles error with JSON response', async () => { 204 | fetch 205 | .mockResponseOnce( 206 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 207 | { url: 'https://fake-stac-api.net' } 208 | ) 209 | .mockResponseOnce(JSON.stringify({ error: 'Wrong query' }), { status: 400, statusText: 'Bad Request' }); 210 | 211 | const { result, waitForNextUpdate } = renderHook( 212 | () => useStacSearch(), 213 | { wrapper } 214 | ); 215 | await waitForNextUpdate(); 216 | 217 | act(() => result.current.submit()); 218 | await waitForNextUpdate(); 219 | expect(result.current.error).toEqual({ 220 | status: 400, 221 | statusText: 'Bad Request', 222 | detail: { error: 'Wrong query' } 223 | }); 224 | }); 225 | 226 | it('handles error with non-JSON response', async () => { 227 | fetch 228 | .mockResponseOnce( 229 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 230 | { url: 'https://fake-stac-api.net' } 231 | ) 232 | .mockResponseOnce('Wrong query', { status: 400, statusText: 'Bad Request' }); 233 | 234 | const { result, waitForNextUpdate } = renderHook( 235 | () => useStacSearch(), 236 | { wrapper } 237 | ); 238 | await waitForNextUpdate(); 239 | 240 | act(() => result.current.submit()); 241 | await waitForNextUpdate(); 242 | expect(result.current.error).toEqual({ 243 | status: 400, 244 | statusText: 'Bad Request', 245 | detail: 'Wrong query' 246 | }); 247 | }); 248 | 249 | it('includes nextPage callback', async () => { 250 | const response = { 251 | links: [{ 252 | rel: 'next', 253 | type: 'application/geo+json', 254 | method: 'POST', 255 | href: 'https://example.com/stac/search', 256 | body: { 257 | limit: 25, 258 | token: 'next:abc123' 259 | } 260 | }] 261 | }; 262 | fetch 263 | .mockResponseOnce( 264 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 265 | { url: 'https://fake-stac-api.net' } 266 | ) 267 | .mockResponseOnce(JSON.stringify(response)); 268 | 269 | const { result, waitForNextUpdate } = renderHook( 270 | () => useStacSearch(), 271 | { wrapper } 272 | ); 273 | await waitForNextUpdate(); 274 | 275 | act(() => result.current.setDateRangeTo('2022-05-17')); 276 | act(() => result.current.submit()); 277 | await waitForNextUpdate(); // wait to set results 278 | expect(result.current.results).toEqual(response); 279 | expect(result.current.nextPage).toBeDefined(); 280 | 281 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 282 | act(() => result.current.nextPage && result.current.nextPage()); 283 | await waitForNextUpdate(); 284 | 285 | const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); 286 | expect(result.current.results).toEqual({ data: '12345' }); 287 | expect(postPayload).toEqual(response.links[0].body); 288 | }); 289 | 290 | it('includes previousPage callback', async () => { 291 | const response = { 292 | links: [{ 293 | rel: 'prev', 294 | type: 'application/geo+json', 295 | method: 'POST', 296 | href: 'https://example.com/stac/search', 297 | body: { 298 | limit: 25, 299 | token: 'prev:abc123' 300 | } 301 | }] 302 | }; 303 | fetch 304 | .mockResponseOnce( 305 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 306 | { url: 'https://fake-stac-api.net' } 307 | ) 308 | .mockResponseOnce(JSON.stringify(response)); 309 | 310 | const { result, waitForNextUpdate } = renderHook( 311 | () => useStacSearch(), 312 | { wrapper } 313 | ); 314 | await waitForNextUpdate(); 315 | 316 | act(() => result.current.setDateRangeTo('2022-05-17')); 317 | act(() => result.current.submit()); 318 | await waitForNextUpdate(); // wait to set results 319 | expect(result.current.results).toEqual(response); 320 | expect(result.current.previousPage).toBeDefined(); 321 | 322 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 323 | act(() => result.current.previousPage && result.current.previousPage()); 324 | await waitForNextUpdate(); 325 | 326 | const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); 327 | expect(result.current.results).toEqual({ data: '12345' }); 328 | expect(postPayload).toEqual(response.links[0].body); 329 | }); 330 | 331 | it('includes previousPage callback (previous edition)', async () => { 332 | const response = { 333 | links: [{ 334 | rel: 'previous', 335 | type: 'application/geo+json', 336 | method: 'POST', 337 | href: 'https://example.com/stac/search', 338 | body: { 339 | limit: 25, 340 | token: 'prev:abc123' 341 | } 342 | }] 343 | }; 344 | fetch 345 | .mockResponseOnce( 346 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 347 | { url: 'https://fake-stac-api.net' } 348 | ) 349 | .mockResponseOnce(JSON.stringify(response)); 350 | 351 | const { result, waitForNextUpdate } = renderHook( 352 | () => useStacSearch(), 353 | { wrapper } 354 | ); 355 | await waitForNextUpdate(); 356 | 357 | act(() => result.current.setDateRangeTo('2022-05-17')); 358 | act(() => result.current.submit()); 359 | await waitForNextUpdate(); // wait to set results 360 | expect(result.current.results).toEqual(response); 361 | expect(result.current.previousPage).toBeDefined(); 362 | 363 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 364 | act(() => result.current.previousPage && result.current.previousPage()); 365 | await waitForNextUpdate(); 366 | 367 | const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); 368 | expect(result.current.results).toEqual({ data: '12345' }); 369 | expect(postPayload).toEqual(response.links[0].body); 370 | }); 371 | 372 | it('merges pagination body', async () => { 373 | const response = { 374 | links: [{ 375 | rel: 'previous', 376 | type: 'application/geo+json', 377 | method: 'POST', 378 | href: 'https://example.com/stac/search', 379 | body: { 380 | limit: 25, 381 | token: 'prev:abc123', 382 | merge: true 383 | } 384 | }] 385 | }; 386 | fetch 387 | .mockResponseOnce( 388 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 389 | { url: 'https://fake-stac-api.net' } 390 | ) 391 | .mockResponseOnce(JSON.stringify(response)); 392 | 393 | const { result, waitForNextUpdate } = renderHook( 394 | () => useStacSearch(), 395 | { wrapper } 396 | ); 397 | await waitForNextUpdate(); 398 | 399 | act(() => result.current.setBbox([-0.59, 51.24, 0.30, 51.74])); 400 | act(() => result.current.submit()); 401 | await waitForNextUpdate(); // wait to set results 402 | expect(result.current.results).toEqual(response); 403 | expect(result.current.previousPage).toBeDefined(); 404 | 405 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 406 | act(() => result.current.previousPage && result.current.previousPage()); 407 | await waitForNextUpdate(); 408 | 409 | const postPayload = parseRequestPayload(fetch.mock.calls[2][1]); 410 | expect(result.current.results).toEqual({ data: '12345' }); 411 | expect(postPayload).toEqual({ 412 | bbox: [-0.59, 51.24, 0.30, 51.74], 413 | ...response.links[0].body 414 | }); 415 | }); 416 | 417 | it('sends pagination header', async () => { 418 | const response = { 419 | links: [{ 420 | rel: 'previous', 421 | type: 'application/geo+json', 422 | method: 'POST', 423 | href: 'https://example.com/stac/search', 424 | body: { 425 | limit: 25, 426 | token: 'prev:abc123' 427 | }, 428 | headers: { 429 | next: '123abc' 430 | } 431 | }] 432 | }; 433 | fetch 434 | .mockResponseOnce( 435 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 436 | { url: 'https://fake-stac-api.net' } 437 | ) 438 | .mockResponseOnce(JSON.stringify(response)); 439 | 440 | const { result, waitForNextUpdate } = renderHook( 441 | () => useStacSearch(), 442 | { wrapper } 443 | ); 444 | await waitForNextUpdate(); 445 | 446 | act(() => result.current.setBbox([-0.59, 51.24, 0.30, 51.74])); 447 | act(() => result.current.submit()); 448 | await waitForNextUpdate(); // wait to set results 449 | expect(result.current.results).toEqual(response); 450 | expect(result.current.previousPage).toBeDefined(); 451 | 452 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 453 | act(() => result.current.previousPage && result.current.previousPage()); 454 | await waitForNextUpdate(); 455 | 456 | expect(result.current.results).toEqual({ data: '12345' }); 457 | const postHeader = fetch.mock.calls[2][1]?.headers; 458 | expect(postHeader).toEqual({ 'Content-Type': 'application/json', next: '123abc' }); 459 | }); 460 | 461 | it('loads next-page from GET request', async () => { 462 | const response = { 463 | links: [{ 464 | rel: 'next', 465 | href: 'https://fake-stac-api.net/?page=2' 466 | }] 467 | }; 468 | fetch 469 | .mockResponseOnce( 470 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 471 | { url: 'https://fake-stac-api.net' } 472 | ) 473 | .mockResponseOnce(JSON.stringify(response)); 474 | 475 | const { result, waitForNextUpdate } = renderHook( 476 | () => useStacSearch(), 477 | { wrapper } 478 | ); 479 | await waitForNextUpdate(); 480 | 481 | act(() => result.current.setDateRangeTo('2022-05-17')); 482 | act(() => result.current.submit()); 483 | await waitForNextUpdate(); // wait to set results 484 | expect(result.current.results).toEqual(response); 485 | expect(result.current.nextPage).toBeDefined(); 486 | 487 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 488 | act(() => result.current.nextPage && result.current.nextPage()); 489 | await waitForNextUpdate(); 490 | 491 | expect(fetch.mock.calls[2][0]).toEqual('https://fake-stac-api.net/?page=2'); 492 | expect(fetch.mock.calls[2][1]?.method).toEqual('GET'); 493 | expect(result.current.results).toEqual({ data: '12345' }); 494 | }); 495 | 496 | it('loads prev-page from GET request', async () => { 497 | const response = { 498 | links: [{ 499 | rel: 'prev', 500 | href: 'https://fake-stac-api.net/?page=2' 501 | }] 502 | }; 503 | fetch 504 | .mockResponseOnce( 505 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 506 | { url: 'https://fake-stac-api.net' } 507 | ) 508 | .mockResponseOnce(JSON.stringify(response)); 509 | 510 | const { result, waitForNextUpdate } = renderHook( 511 | () => useStacSearch(), 512 | { wrapper } 513 | ); 514 | await waitForNextUpdate(); 515 | 516 | act(() => result.current.setDateRangeTo('2022-05-17')); 517 | act(() => result.current.submit()); 518 | await waitForNextUpdate(); // wait to set results 519 | expect(result.current.results).toEqual(response); 520 | expect(result.current.previousPage).toBeDefined(); 521 | 522 | fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 523 | act(() => result.current.previousPage && result.current.previousPage()); 524 | await waitForNextUpdate(); 525 | 526 | expect(fetch.mock.calls[2][0]).toEqual('https://fake-stac-api.net/?page=2'); 527 | expect(fetch.mock.calls[2][1]?.method).toEqual('GET'); 528 | expect(result.current.results).toEqual({ data: '12345' }); 529 | }); 530 | 531 | it('includes sortBy in search', async () => { 532 | fetch 533 | .mockResponseOnce( 534 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 535 | { url: 'https://fake-stac-api.net' } 536 | ) 537 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 538 | 539 | const { result, waitForNextUpdate } = renderHook( 540 | () => useStacSearch(), 541 | { wrapper } 542 | ); 543 | await waitForNextUpdate(); 544 | 545 | act(() => result.current.setSortby([{ field: 'id', direction: 'asc'}])); 546 | act(() => result.current.submit()); 547 | 548 | await waitForNextUpdate(); 549 | 550 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 551 | expect(postPayload).toEqual({ sortby: [{ field: 'id', direction: 'asc'}], limit: 25 }); 552 | expect(result.current.results).toEqual({ data: '12345' }); 553 | }); 554 | 555 | it('includes limit in search', async () => { 556 | fetch 557 | .mockResponseOnce( 558 | JSON.stringify({ links: [{ rel: 'search', method: 'POST' }] }), 559 | { url: 'https://fake-stac-api.net' } 560 | ) 561 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 562 | 563 | const { result, waitForNextUpdate } = renderHook( 564 | () => useStacSearch(), 565 | { wrapper } 566 | ); 567 | await waitForNextUpdate(); 568 | 569 | act(() => result.current.setLimit(50)); 570 | act(() => result.current.submit()); 571 | 572 | await waitForNextUpdate(); 573 | 574 | const postPayload = parseRequestPayload(fetch.mock.calls[1][1]); 575 | expect(postPayload).toEqual({ limit: 50 }); 576 | expect(result.current.results).toEqual({ data: '12345' }); 577 | }); 578 | 579 | // it('should reset state with each new StacApi instance', async () => { 580 | // const bbox: Bbox = [-0.59, 51.24, 0.30, 51.74]; 581 | // fetch.mockResponseOnce(JSON.stringify({ data: '12345' })); 582 | 583 | // const { result, rerender, waitForNextUpdate } = renderHook( 584 | // ({ stacApi }) => useStacSearch(stacApi), { 585 | // initialProps: { stacApi }, 586 | // } 587 | // ); 588 | 589 | // act(() => result.current.setBbox(bbox)); 590 | // act(() => result.current.submit()); 591 | // await waitForNextUpdate(); 592 | 593 | // expect(result.current.results).toEqual({ data: '12345' }); 594 | // expect(result.current.bbox).toEqual(bbox); 595 | 596 | // const newStac = new StacApi('https://otherstack.com', SearchMode.POST); 597 | // rerender({ stacApi: newStac }); 598 | // expect(result.current.results).toBeUndefined(); 599 | // expect(result.current.bbox).toBeUndefined(); 600 | // }); 601 | }); 602 | 603 | describe('useStacSearch — API supports GET', () => { 604 | beforeEach(() => fetch.resetMocks()); 605 | 606 | it('includes Bbox in search', async () => { 607 | fetch 608 | .mockResponseOnce( 609 | JSON.stringify({ links: [] }), 610 | { url: 'https://fake-stac-api.net' } 611 | ) 612 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 613 | 614 | const { result, waitForNextUpdate } = renderHook( 615 | () => useStacSearch(), 616 | { wrapper } 617 | ); 618 | await waitForNextUpdate(); 619 | 620 | act(() => result.current.setBbox([-0.59, 51.24, 0.30, 51.74])); 621 | act(() => result.current.submit()); 622 | await waitForNextUpdate(); 623 | 624 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&bbox=-0.59%2C51.24%2C0.3%2C51.74'); 625 | expect(result.current.results).toEqual({ data: '12345' }); 626 | }); 627 | 628 | it('includes Collections in search', async () => { 629 | fetch 630 | .mockResponseOnce( 631 | JSON.stringify({ links: [] }), 632 | { url: 'https://fake-stac-api.net' } 633 | ) 634 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 635 | 636 | const { result, waitForNextUpdate } = renderHook( 637 | () => useStacSearch(), 638 | { wrapper } 639 | ); 640 | await waitForNextUpdate(); 641 | 642 | act(() => result.current.setCollections(['wildfire', 'surface_temp'])); 643 | act(() => result.current.submit()); 644 | await waitForNextUpdate(); 645 | 646 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&collections=wildfire%2Csurface_temp'); 647 | expect(result.current.results).toEqual({ data: '12345' }); 648 | }); 649 | 650 | it('includes date range in search', async () => { 651 | fetch 652 | .mockResponseOnce( 653 | JSON.stringify({ links: [] }), 654 | { url: 'https://fake-stac-api.net' } 655 | ) 656 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 657 | 658 | const { result, waitForNextUpdate } = renderHook( 659 | () => useStacSearch(), 660 | { wrapper } 661 | ); 662 | await waitForNextUpdate(); 663 | 664 | act(() => result.current.setDateRangeFrom('2022-01-17')); 665 | act(() => result.current.setDateRangeTo('2022-05-17')); 666 | act(() => result.current.submit()); 667 | await waitForNextUpdate(); 668 | 669 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&datetime=2022-01-17%2F2022-05-17'); 670 | expect(result.current.results).toEqual({ data: '12345' }); 671 | }); 672 | 673 | it('includes open date range in search (no to-date)', async () => { 674 | fetch 675 | .mockResponseOnce( 676 | JSON.stringify({ links: [] }), 677 | { url: 'https://fake-stac-api.net' } 678 | ) 679 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 680 | 681 | const { result, waitForNextUpdate } = renderHook( 682 | () => useStacSearch(), 683 | { wrapper } 684 | ); 685 | await waitForNextUpdate(); 686 | 687 | act(() => result.current.setDateRangeFrom('2022-01-17')); 688 | act(() => result.current.submit()); 689 | await waitForNextUpdate(); 690 | 691 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&datetime=2022-01-17%2F..'); 692 | expect(result.current.results).toEqual({ data: '12345' }); 693 | }); 694 | 695 | it('includes open date range in search (no from-date)', async () => { 696 | fetch 697 | .mockResponseOnce( 698 | JSON.stringify({ links: [] }), 699 | { url: 'https://fake-stac-api.net' } 700 | ) 701 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 702 | 703 | const { result, waitForNextUpdate } = renderHook( 704 | () => useStacSearch(), 705 | { wrapper } 706 | ); 707 | await waitForNextUpdate(); 708 | 709 | act(() => result.current.setDateRangeTo('2022-05-17')); 710 | act(() => result.current.submit()); 711 | await waitForNextUpdate(); 712 | 713 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&datetime=..%2F2022-05-17'); 714 | expect(result.current.results).toEqual({ data: '12345' }); 715 | }); 716 | 717 | it('includes sortby', async () => { 718 | fetch 719 | .mockResponseOnce( 720 | JSON.stringify({ links: [] }), 721 | { url: 'https://fake-stac-api.net' } 722 | ) 723 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 724 | 725 | const { result, waitForNextUpdate } = renderHook( 726 | () => useStacSearch(), 727 | { wrapper } 728 | ); 729 | await waitForNextUpdate(); 730 | 731 | act(() => result.current.setSortby([ 732 | { field: 'id', direction: 'asc' }, 733 | { field: 'properties.cloud', direction: 'desc' }, 734 | ])); 735 | act(() => result.current.submit()); 736 | await waitForNextUpdate(); 737 | 738 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=25&sortby=%2Bid%2C-properties.cloud'); 739 | expect(result.current.results).toEqual({ data: '12345' }); 740 | }); 741 | 742 | it('includes limit', async () => { 743 | fetch 744 | .mockResponseOnce( 745 | JSON.stringify({ links: [] }), 746 | { url: 'https://fake-stac-api.net' } 747 | ) 748 | .mockResponseOnce(JSON.stringify({ data: '12345' })); 749 | 750 | const { result, waitForNextUpdate } = renderHook( 751 | () => useStacSearch(), 752 | { wrapper } 753 | ); 754 | await waitForNextUpdate(); 755 | 756 | act(() => result.current.setLimit(50)); 757 | act(() => result.current.submit()); 758 | await waitForNextUpdate(); 759 | 760 | expect(fetch.mock.calls[1][0]).toEqual('https://fake-stac-api.net/search?limit=50'); 761 | expect(result.current.results).toEqual({ data: '12345' }); 762 | }); 763 | }); 764 | -------------------------------------------------------------------------------- /src/hooks/useStacSearch.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState, useMemo, useEffect } from 'react'; 2 | import debounce from '../utils/debounce'; 3 | import type { ApiError, LoadingState } from '../types'; 4 | import type { 5 | Link, 6 | Bbox, 7 | CollectionIdList, 8 | SearchPayload, 9 | SearchResponse, 10 | LinkBody, 11 | Sortby 12 | } from '../types/stac'; 13 | import { useStacApiContext } from '../context'; 14 | 15 | type PaginationHandler = () => void; 16 | 17 | type StacSearchHook = { 18 | ids?: string[] 19 | setIds: (ids: string[]) => void 20 | bbox?: Bbox 21 | setBbox: (bbox: Bbox) => void 22 | collections?: CollectionIdList 23 | setCollections: (collectionIds: CollectionIdList) => void 24 | dateRangeFrom?: string 25 | setDateRangeFrom: (date: string) => void 26 | dateRangeTo?: string 27 | setDateRangeTo: (date: string) => void 28 | limit?: number; 29 | setLimit: (limit: number) => void; 30 | sortby?: Sortby[] 31 | setSortby: (sort: Sortby[]) => void 32 | submit: () => void 33 | results?: SearchResponse 34 | state: LoadingState; 35 | error: ApiError | undefined 36 | nextPage: PaginationHandler | undefined 37 | previousPage: PaginationHandler | undefined 38 | } 39 | 40 | function useStacSearch(): StacSearchHook { 41 | const { stacApi } = useStacApiContext(); 42 | const [ results, setResults ] = useState(); 43 | const [ ids, setIds ] = useState(); 44 | const [ bbox, setBbox ] = useState(); 45 | const [ collections, setCollections ] = useState(); 46 | const [ dateRangeFrom, setDateRangeFrom ] = useState(''); 47 | const [ dateRangeTo, setDateRangeTo ] = useState(''); 48 | const [ limit, setLimit ] = useState(25); 49 | const [ sortby, setSortby ] = useState(); 50 | const [ state, setState ] = useState('IDLE'); 51 | const [ error, setError ] = useState(); 52 | 53 | const [ nextPageConfig, setNextPageConfig ] = useState(); 54 | const [ previousPageConfig, setPreviousPageConfig ] = useState(); 55 | 56 | const reset = () => { 57 | setResults(undefined); 58 | setBbox(undefined); 59 | setCollections(undefined); 60 | setIds(undefined); 61 | setDateRangeFrom(''); 62 | setDateRangeTo(''); 63 | setSortby(undefined); 64 | setLimit(25); 65 | }; 66 | 67 | /** 68 | * Reset state when stacApi changes 69 | */ 70 | useEffect(reset, [stacApi]); 71 | 72 | /** 73 | * Extracts the pagination config from the the links array of the items response 74 | */ 75 | const setPaginationConfig = useCallback( 76 | (links: Link[]) => { 77 | setNextPageConfig(links.find(({ rel }) => rel === 'next')); 78 | setPreviousPageConfig(links.find(({ rel }) => ['prev', 'previous'].includes(rel))); 79 | }, [] 80 | ); 81 | 82 | /** 83 | * Returns the search payload based on the current application state 84 | */ 85 | const getSearchPayload = useCallback( 86 | () => ({ 87 | ids, 88 | bbox, 89 | collections, 90 | dateRange: { from: dateRangeFrom, to: dateRangeTo }, 91 | sortby, 92 | limit 93 | }), 94 | [ ids, bbox, collections, dateRangeFrom, dateRangeTo, sortby, limit ] 95 | ); 96 | 97 | /** 98 | * Resets the state and processes the results from the provided request 99 | */ 100 | const processRequest = useCallback((request: Promise) => { 101 | setResults(undefined); 102 | setState('LOADING'); 103 | setError(undefined); 104 | setNextPageConfig(undefined); 105 | setPreviousPageConfig(undefined); 106 | 107 | request 108 | .then(response => response.json()) 109 | .then(data => { 110 | setResults(data); 111 | if (data.links) { 112 | setPaginationConfig(data.links); 113 | } 114 | }) 115 | .catch((err) => setError(err)) 116 | .finally(() => setState('IDLE')); 117 | }, [setPaginationConfig]); 118 | 119 | /** 120 | * Executes a POST request against the `search` endpoint using the provided payload and headers 121 | */ 122 | const executeSearch = useCallback( 123 | (payload: SearchPayload, headers = {}) => stacApi && processRequest(stacApi.search(payload, headers)), 124 | [stacApi, processRequest] 125 | ); 126 | 127 | /** 128 | * Execute a GET request against the provided URL 129 | */ 130 | const getItems = useCallback( 131 | (url: string) => stacApi && processRequest(stacApi.get(url)), 132 | [stacApi, processRequest] 133 | ); 134 | 135 | /** 136 | * Retreives a page from a paginatied item set using the provided link config. 137 | * Executes a POST request against the `search` endpoint if pagination uses POST 138 | * or retrieves the page items using GET against the link href 139 | */ 140 | const flipPage = useCallback( 141 | (link?: Link) => { 142 | if (link) { 143 | let payload = link.body as LinkBody; 144 | if (payload) { 145 | if (payload.merge) { 146 | payload = { 147 | ...payload, 148 | ...getSearchPayload() 149 | }; 150 | } 151 | executeSearch(payload, link.headers); 152 | } else { 153 | getItems(link.href); 154 | } 155 | } 156 | }, 157 | [executeSearch, getItems, getSearchPayload] 158 | ); 159 | 160 | const nextPageFn = useCallback( 161 | () => flipPage(nextPageConfig), 162 | [flipPage, nextPageConfig] 163 | ); 164 | 165 | const previousPageFn = useCallback( 166 | () => flipPage(previousPageConfig), 167 | [flipPage, previousPageConfig] 168 | ); 169 | 170 | const _submit = useCallback( 171 | () => { 172 | const payload = getSearchPayload(); 173 | executeSearch(payload); 174 | }, [executeSearch, getSearchPayload] 175 | ); 176 | const submit = useMemo(() => debounce(_submit), [_submit]); 177 | 178 | return { 179 | submit, 180 | ids, 181 | setIds, 182 | bbox, 183 | setBbox, 184 | collections, 185 | setCollections, 186 | dateRangeFrom, 187 | setDateRangeFrom, 188 | dateRangeTo, 189 | setDateRangeTo, 190 | results, 191 | state, 192 | error, 193 | sortby, 194 | setSortby, 195 | limit, 196 | setLimit, 197 | nextPage: nextPageConfig ? nextPageFn : undefined, 198 | previousPage: previousPageConfig ? previousPageFn : undefined 199 | }; 200 | } 201 | 202 | export default useStacSearch; 203 | -------------------------------------------------------------------------------- /src/hooks/wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StacApiProvider } from '../context'; 3 | 4 | type WrapperType = { 5 | children: React.ReactNode 6 | } 7 | 8 | const Wrapper = ({ children }: WrapperType) => {children}; 9 | export default Wrapper; 10 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import useStacSearch from './hooks/useStacSearch'; 2 | import useCollections from './hooks/useCollections'; 3 | import useCollection from './hooks/useCollection'; 4 | import useItem from './hooks/useItem'; 5 | import useStacApi from './hooks/useStacApi'; 6 | import { StacApiProvider } from './context'; 7 | 8 | export { 9 | useCollections, 10 | useCollection, 11 | useItem, 12 | useStacSearch, 13 | useStacApi, 14 | StacApiProvider, 15 | }; 16 | -------------------------------------------------------------------------------- /src/stac-api/index.ts: -------------------------------------------------------------------------------- 1 | import type { ApiError, GenericObject } from '../types'; 2 | import type { Bbox, SearchPayload, DateRange } from '../types/stac'; 3 | 4 | type RequestPayload = SearchPayload; 5 | type FetchOptions = { 6 | method?: string, 7 | payload?: RequestPayload, 8 | headers?: GenericObject 9 | } 10 | 11 | export enum SearchMode { 12 | GET = 'GET', 13 | POST = 'POST' 14 | } 15 | 16 | class StacApi { 17 | baseUrl: string; 18 | options?: GenericObject; 19 | searchMode = SearchMode.GET; 20 | 21 | constructor(baseUrl: string, searchMode: SearchMode, options?: GenericObject) { 22 | this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; 23 | this.searchMode = searchMode; 24 | this.options = options; 25 | } 26 | 27 | fixBboxCoordinateOrder(bbox?: Bbox): Bbox | undefined { 28 | if (!bbox) { 29 | return undefined; 30 | } 31 | 32 | const [lonMin, latMin, lonMax, latMax] = bbox; 33 | const sortedBbox: Bbox = [lonMin, latMin, lonMax, latMax]; 34 | 35 | if (lonMin > lonMax) { 36 | sortedBbox[0] = lonMax; 37 | sortedBbox[2] = lonMin; 38 | } 39 | 40 | if (latMin > latMax) { 41 | sortedBbox[1] = latMax; 42 | sortedBbox[3] = latMin; 43 | } 44 | 45 | return sortedBbox; 46 | } 47 | 48 | makeArrayPayload(arr?: any[]) { /* eslint-disable-line @typescript-eslint/no-explicit-any */ 49 | return arr?.length ? arr : undefined; 50 | } 51 | 52 | makeDatetimePayload(dateRange?: DateRange): string | undefined { 53 | if (!dateRange) { 54 | return undefined; 55 | } 56 | 57 | const { from, to } = dateRange; 58 | 59 | if (from || to ) { 60 | return `${from || '..'}/${to || '..'}`; 61 | } else { 62 | return undefined; 63 | } 64 | } 65 | 66 | payloadToQuery({ sortby, ...payload }: SearchPayload): string { 67 | const queryObj = {}; 68 | for (const [key, value] of Object.entries(payload)) { 69 | if (!value) continue; 70 | 71 | if (Array.isArray(value)) { 72 | queryObj[key] = value.join(','); 73 | } else { 74 | queryObj[key] = value; 75 | } 76 | } 77 | 78 | if(sortby) { 79 | queryObj['sortby'] = sortby 80 | .map(( { field, direction } ) => `${direction === 'asc' ? '+' : '-'}${field}`) 81 | .join(','); 82 | } 83 | 84 | return new URLSearchParams(queryObj).toString(); 85 | } 86 | 87 | async handleError(response: Response) { 88 | const { status, statusText } = response; 89 | const e: ApiError = { 90 | status, 91 | statusText 92 | }; 93 | 94 | // Some STAC APIs return errors as JSON others as string. 95 | // Clone the response so we can read the body as text if json fails. 96 | const clone = response.clone(); 97 | try { 98 | e.detail = await response.json(); 99 | } catch (err) { 100 | e.detail = await clone.text(); 101 | } 102 | return Promise.reject(e); 103 | } 104 | 105 | fetch(url: string, options: Partial = {}): Promise { 106 | const { method = 'GET', payload, headers = {} } = options; 107 | 108 | return fetch(url, { 109 | method, 110 | headers: { 111 | 'Content-Type': 'application/json', 112 | ...headers, 113 | ...this.options?.headers 114 | }, 115 | body: payload ? JSON.stringify(payload) : undefined 116 | }).then(async (response) => { 117 | if (response.ok) { 118 | return response; 119 | } 120 | 121 | return this.handleError(response); 122 | }); 123 | } 124 | 125 | search(payload: SearchPayload, headers = {}): Promise { 126 | const { ids, bbox, dateRange, collections, ...restPayload } = payload; 127 | const requestPayload = { 128 | ...restPayload, 129 | ids: this.makeArrayPayload(ids), 130 | collections: this.makeArrayPayload(collections), 131 | bbox: this.fixBboxCoordinateOrder(bbox), 132 | datetime: this.makeDatetimePayload(dateRange), 133 | }; 134 | 135 | if (this.searchMode === 'POST') { 136 | return this.fetch( 137 | `${this.baseUrl}/search`, 138 | { method: 'POST', payload: requestPayload, headers } 139 | ); 140 | } else { 141 | const query = this.payloadToQuery(requestPayload); 142 | return this.fetch( 143 | `${this.baseUrl}/search?${query}`, 144 | { method: 'GET', headers } 145 | ); 146 | } 147 | } 148 | 149 | getCollections(): Promise { 150 | return this.fetch(`${this.baseUrl}/collections`); 151 | } 152 | 153 | get(href: string, headers = {}): Promise { 154 | return this.fetch(href, { headers }); 155 | } 156 | } 157 | 158 | export default StacApi; 159 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export type GenericObject = { 2 | [key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any 3 | } 4 | 5 | export type ApiError = { 6 | detail?: GenericObject | string 7 | status: number, 8 | statusText: string 9 | } 10 | 11 | export type LoadingState = 'IDLE' | 'LOADING'; 12 | -------------------------------------------------------------------------------- /src/types/stac.d.ts: -------------------------------------------------------------------------------- 1 | import type { Geometry } from 'geojson'; 2 | import type { GenericObject } from '.'; 3 | 4 | export type Bbox = [number, number, number, number]; 5 | export type IdList = string[]; 6 | export type CollectionIdList = string[]; 7 | export type DateRange = { 8 | from?: string, 9 | to?: string 10 | } 11 | export type Sortby = { 12 | field: string; 13 | direction: 'asc' | 'desc'; 14 | } 15 | 16 | export type SearchPayload = { 17 | ids?: IdList, 18 | bbox?: Bbox, 19 | collections?: CollectionIdList, 20 | dateRange?: DateRange, 21 | sortby?: Sortby[] 22 | } 23 | 24 | export type LinkBody = SearchPayload & { 25 | merge?: boolean 26 | } 27 | 28 | export type SearchResponse = { 29 | type: 'FeatureCollection' 30 | features: Item[] 31 | links: Link[] 32 | } 33 | 34 | export type Link = { 35 | href: string 36 | rel: string 37 | type?: string 38 | hreflang?: string 39 | title?: string 40 | length?: number 41 | method?: string 42 | headers?: GenericObject 43 | body?: LinkBody 44 | merge?: boolean 45 | } 46 | 47 | export type ItemAsset = { 48 | href: string 49 | title?: string 50 | description?: string 51 | type?: string 52 | roles?: string[] 53 | } 54 | 55 | export type Item = { 56 | id: string, 57 | bbox: Bbox, 58 | geometry: Geometry, 59 | type: 'Feature' 60 | properties: GenericObject 61 | links: Link[] 62 | assets: ItemAsset[] 63 | } 64 | 65 | type Role = 'licensor' | 'producer' | 'processor' | 'host'; 66 | 67 | export type Provider = { 68 | name: string, 69 | description?: string, 70 | roles?: Role[], 71 | url: string 72 | } 73 | 74 | type SpatialExtent = { 75 | bbox: number[][] 76 | } 77 | 78 | type TemporalExtent = { 79 | interval: string | null[][] 80 | } 81 | 82 | export type Extent = { 83 | spatial: SpatialExtent, 84 | temporal: TemporalExtent, 85 | } 86 | 87 | export type Collection = { 88 | type: 'Collection', 89 | stac_version: string, 90 | stac_extensions?: string[], 91 | id: string, 92 | title?: string, 93 | keywords?: string[], 94 | license: string, 95 | providers: Provider[], 96 | extent: Extent, 97 | links: Link[] 98 | assets: GenericObject, 99 | } 100 | 101 | export type CollectionsResponse = { 102 | collections: Collection[], 103 | links: Link[] 104 | } 105 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | const debounce = any)>(fn: F, ms = 250) => { 2 | let timeoutId: ReturnType; 3 | 4 | return function (this: any, ...args: any[]) { 5 | clearTimeout(timeoutId); 6 | timeoutId = setTimeout(() => fn.apply(this, args), ms); 7 | }; 8 | }; 9 | 10 | export default debounce; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "es2016", "es2017"], 7 | "jsx": "react", 8 | "declaration": true, 9 | "moduleResolution": "node", 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "esModuleInterop": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noImplicitAny": true, 16 | "strictNullChecks": true, 17 | "suppressImplicitAnyIndexErrors": true, 18 | "allowSyntheticDefaultImports": true 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "dist"] 22 | } 23 | --------------------------------------------------------------------------------