├── .dockerignore ├── .github └── workflows │ ├── checks.yml │ └── deploy-gh.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── README.md ├── adr └── 0001-stac-admin-plugin-system.md ├── docs ├── CUSTOM_CLIENT.md ├── PLUGINS.md ├── README.md ├── WIDGETS.md └── images │ ├── field-array-enum-select.png │ ├── field-array-enum-tagger.png │ ├── field-array-enum.png │ ├── field-array-label-array.png │ ├── field-array-label-string.png │ ├── field-array-objects.png │ ├── field-array-tagger.png │ ├── field-array.png │ ├── field-json.png │ ├── field-object.png │ ├── field-string-enum.png │ ├── field-string-select.png │ ├── field-string-tagger.png │ └── field-string.png ├── eslint.config.mjs ├── jest-setup.ts ├── jest.config.ts ├── lerna.json ├── nx.json ├── package-lock.json ├── package.json ├── packages ├── client │ ├── .babelrc │ ├── .editorconfig │ ├── .env.example │ ├── .gitignore │ ├── .parcelrc │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── posthtml.config.js │ ├── src │ │ ├── App.tsx │ │ ├── _custom-types │ │ │ └── global.d.ts │ │ ├── api │ │ │ └── index.ts │ │ ├── auth │ │ │ └── Context.tsx │ │ ├── components │ │ │ ├── DeleteMenuItem.tsx │ │ │ ├── InnerPageHeader.tsx │ │ │ ├── ItemCard.tsx │ │ │ ├── ItemCardThumbPlaceholder.tsx │ │ │ ├── MainNavigation.tsx │ │ │ ├── Map │ │ │ │ ├── BackgroundTiles.tsx │ │ │ │ └── index.ts │ │ │ ├── Notifications.tsx │ │ │ ├── Pagination.tsx │ │ │ ├── SmartLink.tsx │ │ │ ├── StacBrowserMenuItem.tsx │ │ │ ├── auth │ │ │ │ ├── ButtonWithAuth.tsx │ │ │ │ ├── Callback.tsx │ │ │ │ ├── MenuItemWithAuth.tsx │ │ │ │ ├── RequireAuth.tsx │ │ │ │ └── UserInfo.tsx │ │ │ └── icons │ │ │ │ └── form.tsx │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── usePageTitle.tsx │ │ │ └── usePrevious.tsx │ │ ├── index.html │ │ ├── main.tsx │ │ ├── media │ │ │ └── layout │ │ │ │ └── logo.svg │ │ ├── pages │ │ │ ├── CollectionDetail │ │ │ │ ├── CollectionMap.tsx │ │ │ │ └── index.tsx │ │ │ ├── CollectionForm │ │ │ │ ├── EditForm.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── useCollectionTransaction.ts │ │ │ ├── CollectionList │ │ │ │ ├── index.tsx │ │ │ │ └── useCollections.ts │ │ │ ├── Home.tsx │ │ │ ├── ItemDetail │ │ │ │ ├── AssetList.tsx │ │ │ │ ├── ItemMap.tsx │ │ │ │ ├── PropertyList.tsx │ │ │ │ ├── Roles.tsx │ │ │ │ ├── TableValue.tsx │ │ │ │ └── index.tsx │ │ │ ├── ItemForm │ │ │ │ └── index.tsx │ │ │ ├── ItemList │ │ │ │ ├── DrawBboxControl.tsx │ │ │ │ ├── ItemListFilter.tsx │ │ │ │ └── index.tsx │ │ │ ├── NotFound.tsx │ │ │ └── Sandbox │ │ │ │ └── index.tsx │ │ ├── plugin-system │ │ │ └── config.ts │ │ ├── theme │ │ │ ├── color-palette.ts │ │ │ └── theme.ts │ │ ├── types │ │ │ └── index.ts │ │ └── utils │ │ │ ├── format.ts │ │ │ ├── usePaginateHook.test.ts │ │ │ └── usePaginateHook.ts │ ├── static │ │ ├── .nojekyll │ │ └── meta │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon.png │ │ │ ├── icon-192.png │ │ │ ├── icon-512.png │ │ │ ├── icon.svg │ │ │ ├── manifest.webmanifest │ │ │ └── meta-image.png │ ├── tasks │ │ ├── build.mjs │ │ ├── check-env-vars.mjs │ │ ├── server.mjs │ │ └── setup-gh-pages.mjs │ └── tsconfig.json ├── data-core │ ├── README.md │ ├── lib │ │ ├── components │ │ │ ├── __snapshots__ │ │ │ │ └── plugin-box.test.tsx.snap │ │ │ ├── error-box.tsx │ │ │ ├── plugin-box.test.tsx │ │ │ ├── plugin-box.tsx │ │ │ ├── types.ts │ │ │ ├── widget-renderer.test.tsx │ │ │ └── widget-renderer.tsx │ │ ├── config │ │ │ ├── config.test.ts │ │ │ └── index.ts │ │ ├── context │ │ │ ├── plugin-config.tsx │ │ │ └── plugin.tsx │ │ ├── index.ts │ │ ├── plugin-utils │ │ │ ├── plugin.ts │ │ │ ├── resolve.test.ts │ │ │ ├── resolve.ts │ │ │ ├── scroll-to-invalid.tsx │ │ │ ├── use-plugins-hook.ts │ │ │ ├── validate.test.ts │ │ │ └── validate.ts │ │ └── schema │ │ │ ├── index.ts │ │ │ ├── schema.test.ts │ │ │ └── types.ts │ ├── package.json │ └── tsconfig.json ├── data-plugins │ ├── README.md │ ├── lib │ │ ├── collections │ │ │ ├── core.ts │ │ │ ├── ext-item-assets.ts │ │ │ ├── ext-render.ts │ │ │ ├── kitchen-sink.ts │ │ │ └── meta.ts │ │ ├── index.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── package.json │ └── tsconfig.json └── data-widgets │ ├── README.md │ ├── lib │ ├── components │ │ ├── __snapshots__ │ │ │ └── array-fieldset.elements.test.tsx.snap │ │ ├── array-fieldset.elements.test.tsx │ │ ├── elements.tsx │ │ ├── icons │ │ │ └── indent.tsx │ │ ├── json-jsoneditor.tsx │ │ ├── object-property.test.tsx │ │ └── object-property.tsx │ ├── config │ │ └── index.ts │ ├── index.ts │ ├── utils │ │ ├── __snapshots__ │ │ │ └── utils.test.ts.snap │ │ ├── index.tsx │ │ ├── use-render-key.ts │ │ └── utils.test.ts │ └── widgets │ │ ├── array-input.tsx │ │ ├── array.tsx │ │ ├── checkbox.tsx │ │ ├── input.tsx │ │ ├── json.tsx │ │ ├── number.tsx │ │ ├── object-fieldset.tsx │ │ ├── object.tsx │ │ ├── radio.tsx │ │ ├── select.tsx │ │ ├── tagger.tsx │ │ └── text.tsx │ ├── package.json │ └── tsconfig.json ├── rollup.config.mjs └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | **/node_modules 4 | .pnp 5 | .pnp.js 6 | 7 | # Testing 8 | coverage 9 | **/coverage 10 | 11 | # Production builds 12 | dist 13 | **/dist 14 | build 15 | **/build 16 | 17 | # Cache directories 18 | .cache 19 | **/.cache 20 | .npm 21 | .eslintcache 22 | .vite 23 | .nx 24 | **/.parcel-cache 25 | 26 | # Environment files 27 | .env.* 28 | !.env.example 29 | 30 | # IDE specific files 31 | .idea 32 | .vscode 33 | *.swp 34 | *.swo 35 | 36 | # OS specific files 37 | .DS_Store 38 | Thumbs.db 39 | 40 | # Logs 41 | logs 42 | *.log 43 | npm-debug.log* 44 | yarn-debug.log* 45 | yarn-error.log* 46 | 47 | # Temporary files 48 | *.tmp 49 | *.temp -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | # This workflow performs basic checks: 2 | # 3 | # 1. run a preparation step to install and cache node modules 4 | # 2. once prep succeeds, lint and test run in parallel 5 | # 6 | # The checks only run on non-draft Pull Requests. They don't run on the main 7 | # branch prior to deploy. It's recommended to use branch protection to avoid 8 | # pushes straight to 'main'. 9 | 10 | name: Checks 11 | 12 | on: 13 | pull_request: 14 | types: 15 | - opened 16 | - synchronize 17 | - reopened 18 | - ready_for_review 19 | 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.ref }} 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | prep: 26 | if: github.event.pull_request.draft == false 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Use Node.js ${{ env.NODE }} 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version-file: '.nvmrc' 37 | 38 | - name: Cache node_modules 39 | uses: actions/cache@v4 40 | id: cache-node-modules 41 | with: 42 | path: node_modules 43 | key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} 44 | 45 | - name: Install 46 | run: npm install 47 | 48 | lint: 49 | needs: prep 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Checkout 54 | uses: actions/checkout@v4 55 | 56 | - name: Use Node.js ${{ env.NODE }} 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version-file: '.nvmrc' 60 | 61 | - name: Cache node_modules 62 | uses: actions/cache@v4 63 | id: cache-node-modules 64 | with: 65 | path: node_modules 66 | key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} 67 | 68 | - name: Install 69 | run: npm install 70 | 71 | - name: Lint 72 | run: npm run lint 73 | 74 | test: 75 | needs: prep 76 | runs-on: ubuntu-latest 77 | 78 | steps: 79 | - name: Checkout 80 | uses: actions/checkout@v4 81 | 82 | - name: Use Node.js ${{ env.NODE }} 83 | uses: actions/setup-node@v4 84 | with: 85 | node-version-file: '.nvmrc' 86 | 87 | - name: Cache node_modules 88 | uses: actions/cache@v4 89 | id: cache-node-modules 90 | with: 91 | path: node_modules 92 | key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} 93 | 94 | - name: Install 95 | run: npm install 96 | 97 | - name: Build plugins 98 | run: npm run plugins:build 99 | 100 | - name: Test 101 | run: npm run test 102 | 103 | build: 104 | needs: prep 105 | runs-on: ubuntu-latest 106 | # Just testing purposes 107 | env: 108 | REACT_APP_STAC_API: https://stac.eoapi.dev 109 | PUBLIC_URL: http://stac-manager.ds.io 110 | 111 | steps: 112 | - name: Checkout 113 | uses: actions/checkout@v4 114 | 115 | - name: Use Node.js ${{ env.NODE }} 116 | uses: actions/setup-node@v4 117 | with: 118 | node-version-file: '.nvmrc' 119 | 120 | - name: Cache node_modules 121 | uses: actions/cache@v4 122 | id: cache-node-modules 123 | with: 124 | path: node_modules 125 | key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} 126 | 127 | - name: Install 128 | run: npm install 129 | 130 | - name: Create .env file 131 | run: mv packages/client/.env.example packages/client/.env 132 | 133 | - name: Test 134 | run: npm run all:build -------------------------------------------------------------------------------- /.github/workflows/deploy-gh.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Github Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | env: 9 | PUBLIC_URL: ${{ vars.PUBLIC_URL }} 10 | REACT_APP_STAC_API: ${{ vars.REACT_APP_STAC_API }} 11 | REACT_APP_STAC_BROWSER: ${{ vars.REACT_APP_STAC_BROWSER }} 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: write 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | 28 | - name: Use Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version-file: '.nvmrc' 32 | 33 | - name: Cache node_modules 34 | uses: actions/cache@v4 35 | id: cache-node-modules 36 | with: 37 | path: node_modules 38 | key: ${{ runner.os }}-build-${{ hashFiles('**/package.json') }} 39 | 40 | - name: Cache dist 41 | uses: actions/cache@v4 42 | id: cache-dist 43 | with: 44 | path: packages/client/dist 45 | key: ${{ runner.os }}-build-${{ github.sha }} 46 | 47 | - name: Install 48 | run: npm install 49 | 50 | - name: Create .env file 51 | run: mv packages/client/.env.example packages/client/.env 52 | 53 | - name: Setup SPA on Github Pages 54 | run: node packages/client/tasks/setup-gh-pages.mjs 55 | 56 | - name: Build 57 | run: npm run all:build 58 | 59 | deploy: 60 | runs-on: ubuntu-latest 61 | needs: build 62 | 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v4 66 | 67 | - name: Restore dist cache 68 | uses: actions/cache@v4 69 | id: cache-dist 70 | with: 71 | path: packages/client/dist 72 | key: ${{ runner.os }}-build-${{ github.sha }} 73 | 74 | - name: Deploy 🚀 75 | uses: JamesIves/github-pages-deploy-action@v4 76 | with: 77 | branch: gh-pages 78 | clean: true 79 | single-commit: true 80 | folder: packages/client/dist 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################################################ 2 | ############### .gitignore ################## 3 | ################################################ 4 | # 5 | # This file is only relevant if you are using git. 6 | # 7 | # Files which match the splat patterns below will 8 | # be ignored by git. This keeps random crap and 9 | # and sensitive credentials from being uploaded to 10 | # your repository. It allows you to configure your 11 | # app for your machine without accidentally 12 | # committing settings which will smash the local 13 | # settings of other developers on your team. 14 | # 15 | # Some reasonable defaults are included below, 16 | # but, of course, you should modify/extend/prune 17 | # to fit your needs! 18 | ################################################ 19 | 20 | node_modules 21 | 22 | 23 | ################################################ 24 | # Node.js / NPM 25 | # 26 | # Common files generated by Node, NPM, and the 27 | # related ecosystem. 28 | ################################################ 29 | 30 | lib-cov 31 | *.seed 32 | *.log 33 | *.out 34 | *.pid 35 | npm-debug.log 36 | yarn-error.log 37 | .parcel-cache 38 | 39 | 40 | ################################################ 41 | # Miscellaneous 42 | # 43 | # Common files generated by text editors, 44 | # operating systems, file systems, etc. 45 | ################################################ 46 | 47 | *~ 48 | *# 49 | .DS_STORE 50 | .DS_Store 51 | .netbeans 52 | nbproject 53 | .idea 54 | .resources 55 | .node_history 56 | temp 57 | tmp 58 | .tmp 59 | dist 60 | parcel-bundle-reports 61 | .nx -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 80 7 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # STAC-Manager 📡 📄 — Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity, and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism. 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct that could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within and in public spaces when an individual represents the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at zac@developmentseed.org. All complaints will be reviewed and investigated, resulting in a response deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality about the reporter of an incident. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other project leadership members. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # STAC-Manager 📡 📄 — Contributing 2 | 3 | First off, thank you for considering contributing to the STAC-Manager Interface! People like you make the open-source community a great place to learn, inspire, and create. 4 | 5 | ## How to Contribute 6 | 7 | There are many ways you can contribute to our project: 8 | 9 | 1. **Reporting Bugs** 10 | - If you find a bug, please check the issue tracker to see if it has already been reported. If it hasn't, feel free to open a new issue. 11 | - When writing your bug report, please include: 12 | - A clear and descriptive title. 13 | - A detailed description of the issue. 14 | - Steps to reproduce the issue. 15 | - Any relevant code or screenshots. 16 | - Your environment details (OS, browser, etc.). 17 | 18 | 2. **Suggesting Enhancements** 19 | - We welcome suggestions for enhancements! Feel free to use the issue tracker to propose new features or improvements. 20 | 21 | 3. **Pull Requests** 22 | - Fork the repository and create your branch from `main`. 23 | - Write clear and meaningful commit messages. 24 | - Include comments in your code where necessary. 25 | - Update the README.md with details of changes to the interface, if applicable. 26 | - Ensure any install or build dependencies are removed before the end of the layer when doing a build. 27 | - Open a pull request with a clear title and description. 28 | 29 | ## Code of Conduct 30 | Our project adheres to a code of conduct. By participating, you are expected to uphold this code. Please read the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details. 31 | 32 | ## Getting Help 33 | If you need help, feel free to create an issue or contact the project maintainers. 34 | 35 | Thank you for contributing to the STAC-Manager Interface! 36 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # STAC-Manager 📡 📄 — Development 2 | 3 | ## Installation and Usage 4 | The steps below will walk you through setting up your own instance of the project. 5 | 6 | ### Install Project Dependencies 7 | To set up the development environment for this website, you'll need to install the following on your system: 8 | 9 | - [Node](http://nodejs.org/) v20 (To manage multiple node versions we recommend [nvm](https://github.com/creationix/nvm)) 10 | 11 | ### Install Application Dependencies 12 | 13 | If you use [`nvm`](https://github.com/creationix/nvm), activate the desired Node version: 14 | 15 | ``` 16 | nvm install 17 | ``` 18 | 19 | Install Node modules: 20 | 21 | ``` 22 | npm install 23 | ``` 24 | 25 | ### Running the App 26 | 27 | To run the client app in development mode: 28 | ``` 29 | npm run plugins:build 30 | npm run client:serve 31 | ``` 32 | 33 | If you're going to work on the form builder plugin system as well, you may want to run the watch mode on the packages: 34 | ``` 35 | npm run plugins:watch 36 | ``` 37 | 38 | ### Building for Production 39 | Build the app for production: 40 | ``` 41 | npm run all:build 42 | ``` 43 | This bundles the app in production mode, optimizing the build for performance. The build is minified, and filenames include hashes. 44 | 45 | ## Contributing 46 | Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 47 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node.js runtime as a parent image 2 | FROM node:slim 3 | 4 | # Set the working directory 5 | WORKDIR /app 6 | 7 | # Copy the rest of the application code 8 | COPY . . 9 | 10 | # Install dependencies 11 | RUN npm i 12 | RUN npm i -g http-server 13 | 14 | RUN npm run all:build 15 | 16 | EXPOSE 80 17 | 18 | ENTRYPOINT ["http-server", "-p", "80", "packages/client/dist"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 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-Manager 📡 📄 2 | 3 | ## Introduction 4 | 5 | STAC Manager allows you to list, create, read, update, and delete STAC collections and items in a web application. 6 | 7 | It currently connects to a STAC API via the the [STAC API - Transation Extension](https://github.com/stac-api-extensions/transaction) and can be configured to support standard authentication and authorization flows, if needed. 8 | 9 | The application is extendable with plugins to provide user-friendly interfaces for various STAC extensions or custom properties. 10 | 11 | The primary intended use is for detail-oriented curation of STAC collections by subject matter experts, for example fine-tuning providers or keywords, adding [STAC Rendering Extension](https://github.com/stac-extensions/render) configurations, or starting a new collection. It does not target the generation and ingestion of large STAC item sets, which is better done programmatically. 12 | 13 | We just released this software and are curious to hear about use cases and limitations - please share your experience via this GitHub repository. 14 | 15 | 16 | ## Repository structure 17 | 18 | This repository is a monorepo for the STAC-Manager project managed using [lerna](https://lerna.js.org/). 19 | It contains the stac-manager web app along with the form build plugin system that powers it. 20 | 21 | All the packages are located in the `packages` directory structured as follows: 22 | 23 | - [`@stac-manager/client`](./packages/client) - STAC-Manager web app. 24 | - [`@stac-manager/data-core`](./packages/data-core) - Core functionality of the form builder plugin system. 25 | - [`@stac-manager/data-widgets`](./packages/data-widgets) - Form components to be used by the form builder plugin system, when custom ones are not provided. 26 | - [`@stac-manager/data-plugins`](./packages/data-plugins) - Data plugins for the forms. Each plugin defines how a section of the data structure is displayed and edited. 27 | 28 | ## Development & Technical Documentation 29 | 30 | To set up the project for development, follow the instructions in the [development documentation](./DEVELOPMENT.md) and get familiar with the app architecture and the plugin system by reading the [technical documentation](./docs/README.md). 31 | 32 | ## License 33 | This project is licensed under the MIT license - see the LICENSE.md file for details. 34 | 35 | ## Docker 36 | To run the STAC-Manager in a Docker container, you can use the provided Dockerfile. 37 | 38 | **Build the Docker image** 39 | ```bash 40 | docker build -t stac-manager . 41 | ``` 42 | 43 | **Run the Docker container** 44 | ```bash 45 | docker run --rm -p 8080:80 --name stac-manager -e 'PUBLIC_URL=http://your-url.com' stac-manager 46 | ``` 47 | 48 | > [!NOTE] 49 | > The application performs a complete build during container startup to ensure environment variables are properly integrated. This process may take a couple minutes to complete. 50 | -------------------------------------------------------------------------------- /adr/0001-stac-admin-plugin-system.md: -------------------------------------------------------------------------------- 1 | # STAC admin plugin system 2 | 3 | Status: ACCEPTED 4 | Deciders: @danielfdsilva @emmanuelmathot @j08lue @oliverroick 5 | Date: 2024-08-26 6 | 7 | 8 | ## Context and Problem Statement 9 | 10 | We need to expand the current scope of STAC Admin to support editing more STAC metadata, including properties provided by various [STAC extensions](https://stac-extensions.github.io/). The goal with this ADR is to enable the STAC Admin interface to follow the flexibility of STAC. 11 | 12 | The form to edit the STAC metadata may differ from instance to instance and from collection to collection. Ideally the form should be easily customizable and extensible to suit the different needs. 13 | 14 | ## Decision Drivers 15 | 16 | The chosen option should allow: 17 | - Each instance controls what fields are available in the editor. 18 | - Through instance-specific plugins, instances can define additional custom fields if required. 19 | 20 | ## Decision 21 | 22 | The structure of the form is defined by a plugin system. Each plugin is responsible for a section of the editor and defines the fields that should be shown. 23 | This allows for a more modular approach to the editor. Each instance can use a different set of plugins to define the editor's structure. These can be custom plugins that the implementers of a given instance develop or pre-made plugins from the community. 24 | 25 | Drawing inspiration from the JSON schema spec, each plugin defines a schema to create the editor. For each field type there is a corresponding default widget to render it, but plugins can define their own widgets (in the form of a React component) to be used by the fields. 26 | 27 | We will provide and maintain plugins to that support editing meta data specified by a variety of commonly used STAC extensions. 28 | 29 | ### Consequences 30 | 31 | #### Pros 32 | With a plugin system the editor can be easily customized to suit the needs of each instance. Implementors have the option to use a pre-made solution and have something up and running quickly or to develop their own plugins with editing widgets that suit their needs. 33 | 34 | #### Cons 35 | Having plugins define a schema, inevitably results in some duplication, given that the schema for the different extensions already exists defined in the STAC spec. However this schema may not be a one-to-one match to the desired form structure. Additionally, implementing a full JSON schema renderer would be a significant amount of work and would likely be overkill for the needs of the editor. 36 | 37 | Nevertheless, this can be mitigated, by having the plugin query the STAC spec on initialization to get a base schema from which to derive the editor schema. 38 | 39 | ## More Information 40 | 41 | The initial proposal for the plugin system was outlined in the [Flexible plugin system for the STAC metadata editor](https://github.com/EOEPCA/data-access/issues/73) issue. 42 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # STAC-Manager 📡 📄 — Technical Documentation 2 | 3 | - [STAC-Manager 📡 📄 — Technical Documentation](#stac-manager----technical-documentation) 4 | - [Configuration](#configuration) 5 | - [Example config](#example-config) 6 | - [Plugins \& Widgets](#plugins--widgets) 7 | - [Custom Client](#custom-client) 8 | 9 | STAC-Manager is a react app designed for managing the values of a STAC (SpatioTemporal Asset Catalog) collection and its items. 10 | The ecosystem is composed of a web app (the client) and a plugin system that powers it. 11 | The different parts of the project are located in the `packages` directory structured as follows: 12 | 13 | - [`@stac-manager/client`](../packages/client) - STAC-Manager web app. 14 | - [`@stac-manager/data-core`](../packages/data-core) - Core functionality of the form builder plugin system. 15 | - [`@stac-manager/data-widgets`](../packages/data-widgets) - Form components to be used by the form builder plugin system, when custom ones are not provided. 16 | - [`@stac-manager/data-plugins`](../packages/data-plugins) - Data plugins for the forms. Each plugin defines how a section of the data structure is displayed and edited. 17 | 18 | The `@stac-manager/data-*` packages contain the default implementation of the plugin system, the widgets used to render the forms and some core functions to allow the system to be extended. 19 | 20 | The plugin system is responsible to dynamically generate forms based on a schema definition. 21 | Each plugin handles a specific part of the data and is responsible for defining the schema for the form, converting the data to the form format, and converting the form data back to the original data format. 22 | 23 | ## Configuration 24 | 25 | ### Client 26 | 27 | See the client-specific instructions, such as configuring the server's STAC API, in the [README of the client package](../packages/client#client-specific-instructions). 28 | 29 | ### Plugins 30 | 31 | STAC-Manager's [config file](/packages/client/src/plugin-system/config.ts) specifies the plugins that the app uses for Collections and Items while extending the `Default Plugin Widget Configuration` which defines the widgets for the basic field types. 32 | _See the [data-widgets/config](/packages/data-widgets/lib/config/index.ts) for a list of existent widgets._ 33 | 34 | When creating a new plugin or a new widget, the configuration should be updated with the new plugin/widget. 35 | 36 | #### Example config 37 | 38 | The config object should contain a list of plugins to use for the collections and items, as well as the widget configuration (which widgets to use for which field types). 39 | 40 | ```ts 41 | import { extendPluginConfig } from '@stac-manager/data-core'; 42 | import { defaultPluginWidgetConfig } from '@stac-manager/data-widgets'; 43 | import { 44 | PluginOne, 45 | PluginTwo, 46 | PluginTwoB, 47 | CustomWidgetComponent 48 | } from './somewhere'; 49 | 50 | export const config = extendPluginConfig(defaultPluginWidgetConfig, { 51 | collectionPlugins: [ 52 | new PluginOne(), 53 | new PluginTwo(), 54 | ], 55 | itemPlugins: [ 56 | new PluginTwoB(), 57 | ], 58 | 59 | 'ui:widget': { 60 | 'customWidget': CustomWidgetComponent, 61 | } 62 | }); 63 | ``` 64 | 65 | ## Plugins & Widgets 66 | 67 | Data plugins are the central part of STAC-Manager. Check the [plugin system documentation](./PLUGINS.md) to understand how they work and how to create a new plugin. 68 | 69 | Widgets are the visual representation of the fields in the form. Check the [widget documentation](./WIDGETS.md) for more information on how to create a new widget. 70 | 71 | ## Custom Client 72 | 73 | If you want to build your own app using the plugin system check the [custom client documentation](./docs/CUSTOM_CLIENT.md) for more information on how to set it up. 74 | -------------------------------------------------------------------------------- /docs/images/field-array-enum-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-enum-select.png -------------------------------------------------------------------------------- /docs/images/field-array-enum-tagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-enum-tagger.png -------------------------------------------------------------------------------- /docs/images/field-array-enum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-enum.png -------------------------------------------------------------------------------- /docs/images/field-array-label-array.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-label-array.png -------------------------------------------------------------------------------- /docs/images/field-array-label-string.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-label-string.png -------------------------------------------------------------------------------- /docs/images/field-array-objects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-objects.png -------------------------------------------------------------------------------- /docs/images/field-array-tagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array-tagger.png -------------------------------------------------------------------------------- /docs/images/field-array.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-array.png -------------------------------------------------------------------------------- /docs/images/field-json.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-json.png -------------------------------------------------------------------------------- /docs/images/field-object.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-object.png -------------------------------------------------------------------------------- /docs/images/field-string-enum.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-string-enum.png -------------------------------------------------------------------------------- /docs/images/field-string-select.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-string-select.png -------------------------------------------------------------------------------- /docs/images/field-string-tagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-string-tagger.png -------------------------------------------------------------------------------- /docs/images/field-string.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/docs/images/field-string.png -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | import pluginReact from 'eslint-plugin-react'; 5 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 6 | 7 | export default [ 8 | { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, 9 | { languageOptions: { globals: globals.browser } }, 10 | pluginJs.configs.recommended, 11 | ...tseslint.configs.recommended, 12 | pluginReact.configs.flat.recommended, 13 | eslintPluginPrettierRecommended, 14 | { 15 | name: 'Custom Rules ', 16 | rules: { 17 | 'no-console': 2, 18 | 'prefer-promise-reject-errors': 0, 19 | // 'import/order': 2, 20 | 'react/button-has-type': 2, 21 | 'react/jsx-closing-bracket-location': 2, 22 | 'react/jsx-closing-tag-location': 2, 23 | 'react/jsx-curly-spacing': 2, 24 | 'react/jsx-curly-newline': 2, 25 | 'react/jsx-equals-spacing': 2, 26 | 'react/jsx-max-props-per-line': [2, { maximum: 1, when: 'multiline' }], 27 | 'react/jsx-first-prop-new-line': 2, 28 | 'react/jsx-curly-brace-presence': [ 29 | 2, 30 | { props: 'never', children: 'never' } 31 | ], 32 | 'react/jsx-pascal-case': 2, 33 | 'react/jsx-props-no-multi-spaces': 2, 34 | 'react/jsx-tag-spacing': [2, { beforeClosing: 'never' }], 35 | 'react/jsx-wrap-multilines': 2, 36 | 'react/no-array-index-key': 2, 37 | 'react/no-typos': 2, 38 | 'react/no-unused-prop-types': 2, 39 | 'react/no-unused-state': 2, 40 | 'react/self-closing-comp': 2, 41 | 'react/style-prop-object': 2, 42 | 'react/void-dom-elements-no-children': 2, 43 | 'react/function-component-definition': [ 44 | 2, 45 | { namedComponents: ['function-declaration', 'arrow-function'] } 46 | ], 47 | // 'react-hooks/rules-of-hooks': 2, // Checks rules of Hooks 48 | // 'react-hooks/exhaustive-deps': 1, // Checks effect dependencies 49 | // 'fp/no-mutating-methods': 1, 50 | '@typescript-eslint/no-explicit-any': 'warn' 51 | } 52 | } 53 | ]; 54 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "version": "0.0.0", 4 | "packages": [ 5 | "packages/*" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "targetDefaults": { 3 | "serve": { 4 | "dependsOn": [] 5 | }, 6 | "build": { 7 | "cache": true, 8 | "dependsOn": ["^build"], 9 | "outputs": [ 10 | "{projectRoot}/dist" 11 | ] 12 | }, 13 | "stage": { 14 | "cache": true, 15 | "dependsOn": [], 16 | "outputs": [ 17 | "{projectRoot}/dist" 18 | ] 19 | }, 20 | "clean": { 21 | "dependsOn": [] 22 | }, 23 | "lint": { 24 | "cache": true, 25 | "dependsOn": [] 26 | }, 27 | "lint:scripts": { 28 | "cache": true, 29 | "dependsOn": [] 30 | }, 31 | "ts-check": { 32 | "cache": true, 33 | "dependsOn": [] 34 | }, 35 | "test": { 36 | "cache": true, 37 | "dependsOn": [] 38 | }, 39 | "watch": { 40 | "dependsOn": [] 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "plugins:watch": "lerna watch --ignore='@stac-manager/client' -- lerna run build --scope=$LERNA_PACKAGE_NAME --ignore='@stac-manager/client'", 9 | "plugins:build": "lerna run build --ignore='@stac-manager/client'", 10 | "plugins:clean": "lerna run clean --ignore='@stac-manager/client'", 11 | "client:serve": "lerna run serve --scope='@stac-manager/client'", 12 | "client:build": "lerna run build --scope='@stac-manager/client'", 13 | "client:stage": "lerna run stage --scope='@stac-manager/client'", 14 | "all:build": "lerna run build", 15 | "all:clean": "lerna run clean", 16 | "test": "jest", 17 | "lint": "lerna run lint" 18 | }, 19 | "devDependencies": { 20 | "@eslint/js": "^9.13.0", 21 | "@rollup/plugin-commonjs": "^28.0.1", 22 | "@rollup/plugin-node-resolve": "^15.3.0", 23 | "@rollup/plugin-replace": "^6.0.1", 24 | "@types/jest": "^29.5.14", 25 | "@types/node": "^22.10.2", 26 | "@types/rollup-plugin-peer-deps-external": "^2.2.5", 27 | "@types/testing-library__jest-dom": "^5.14.9", 28 | "babel-jest": "^29.7.0", 29 | "eslint": "^9.13.0", 30 | "eslint-config-prettier": "^9.1.0", 31 | "eslint-plugin-prettier": "^5.2.1", 32 | "eslint-plugin-react": "^7.37.2", 33 | "jest-environment-jsdom": "^29.7.0", 34 | "lerna": "^8.1.8", 35 | "prettier": "^3.3.3", 36 | "rollup": "^4.24.2", 37 | "rollup-plugin-dts": "^6.1.1", 38 | "rollup-plugin-import-css": "^3.5.6", 39 | "rollup-plugin-peer-deps-external": "^2.2.4", 40 | "rollup-plugin-typescript2": "^0.36.0", 41 | "ts-jest": "^29.2.5", 42 | "ts-node": "^10.9.2", 43 | "typescript": "^5.6.3", 44 | "typescript-eslint": "^8.12.1" 45 | }, 46 | "@parcel/resolver-default": { 47 | "packageExports": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": ["@babel/preset-react", "@babel/preset-typescript"], 5 | "plugins": ["@babel/plugin-transform-modules-commonjs"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/client/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # Matches multiple files with brace expansion notation 12 | # Set default charset 13 | [*.{js}] 14 | charset = utf-8 15 | 16 | # Indentation override for all JS under lib directory 17 | [lib/**.js] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | # Matches the exact files either package.json or .travis.yml 22 | [{package.json,.travis.yml}] 23 | indent_style = space 24 | indent_size = 2 25 | -------------------------------------------------------------------------------- /packages/client/.env.example: -------------------------------------------------------------------------------- 1 | # ============================================= 2 | # STAC Manager Environment Example File 3 | # ============================================= 4 | # IMPORTANT: DO NOT MODIFY THIS FILE! 5 | # Instead, create a copy named '.env' and modify that file. 6 | # This example file serves as a template and documentation. 7 | # ============================================= 8 | 9 | # ================= 10 | # App Configuration 11 | # ================= 12 | 13 | # The title of the application shown in browser tab and headers 14 | APP_TITLE=STAC Manager 15 | 16 | # A brief description of the application for metadata purposes 17 | APP_DESCRIPTION=Plugin based STAC editor 18 | 19 | # The base URL where the app is being served from 20 | # DO NOT set this in the .env file. Set it as an environment variable before building. 21 | # See the README for instructions. 22 | # PUBLIC_URL= Do not set here 23 | 24 | # =============== 25 | # API Integration 26 | # =============== 27 | 28 | # URL of the STAC Browser instance (optional) 29 | # If not set, will default to Radiant Earth's STAC Browser 30 | REACT_APP_STAC_BROWSER= 31 | 32 | # URL of the STAC API endpoint (required) 33 | # This is the API the app will interact with for STAC operations 34 | REACT_APP_STAC_API= 35 | 36 | # ==================== 37 | # Keycloak Auth Config 38 | # ==================== 39 | # If not provided, authentication will be disabled. 40 | 41 | # Base URL of the Keycloak server 42 | REACT_APP_KEYCLOAK_URL= 43 | 44 | # Client ID registered in Keycloak 45 | REACT_APP_KEYCLOAK_CLIENT_ID= 46 | 47 | # Realm name in Keycloak 48 | REACT_APP_KEYCLOAK_REALM= 49 | 50 | # ================= 51 | # Theme Customization 52 | # ================= 53 | 54 | # Primary color for the application theme (hex color code) 55 | # Default: #6A5ACD (SlateBlue) 56 | # REACT_APP_THEME_PRIMARY_COLOR='#6A5ACD' 57 | 58 | # Secondary color for the application theme (hex color code) 59 | # Default: #048A81 (Teal) 60 | # REACT_APP_THEME_SECONDARY_COLOR='#048A81' 61 | -------------------------------------------------------------------------------- /packages/client/.gitignore: -------------------------------------------------------------------------------- 1 | ################################################ 2 | # Environment 3 | ################################################ 4 | 5 | .env* 6 | !.env.example -------------------------------------------------------------------------------- /packages/client/.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@parcel/config-default"], 3 | "reporters": ["...", "@parcel/reporter-bundle-analyzer"], 4 | "resolvers": ["parcel-resolver-ignore", "..."] 5 | } -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # @stac-manager/client 2 | 3 | ## Introduction 4 | The STAC-Manager is a tool designed for managing the values of a STAC (SpatioTemporal Asset Catalog) collection and its items. This interface provides a user-friendly way to modify and update the properties of collections and items within a STAC catalog. 5 | 6 | ## Installation and Usage 7 | See root README.md for instructions on how to install and run the project. 8 | 9 | ## Client specific instructions 10 | 11 | ### Environment Configuration 12 | 13 | The application uses environment variables for configuration. A template file `.env.example` is provided as a template. 14 | 15 | To configure the application: 16 | 1. Copy `.env.example` to `.env` 17 | 2. Modify the `.env` file with your specific configuration values 18 | 3. Never modify `.env.example` directly as it serves as documentation 19 | 20 | Some client options are controlled by environment variables. These are: 21 | ``` 22 | # App config 23 | ## Title and description of the app for metadata 24 | APP_TITLE 25 | APP_DESCRIPTION 26 | ## If the app is being served in from a subfolder, the domain url must be set. 27 | PUBLIC_URL 28 | 29 | # API 30 | REACT_APP_STAC_BROWSER 31 | REACT_APP_STAC_API 32 | 33 | # Auth 34 | REACT_APP_KEYCLOAK_URL 35 | REACT_APP_KEYCLOAK_CLIENT_ID 36 | REACT_APP_KEYCLOAK_REALM 37 | 38 | # Theming 39 | REACT_APP_THEME_PRIMARY_COLOR 40 | REACT_APP_THEME_SECONDARY_COLOR 41 | ``` 42 | 43 | **Public URL** 44 | It is recommended to always set the `PUBLIC_URL` environment variable on a production build. 45 | If the app is being served from a subfolder, the `PUBLIC_URL` should include the subfolder path. **Do not include a trailing slash.** 46 | 47 | For example, if the app is being served from `https://example.com/stac-manager`, the `PUBLIC_URL` should be set to `https://example.com/stac-manager`. 48 | 49 | > [!IMPORTANT] 50 | > The `PUBLIC_URL` environment variable must be set before running the build script, and therefore the `.env` file cannot be used to set this variable. 51 | 52 | You must provide a value for the `REACT_APP_STAC_API` environment variable. This should be the URL of the STAC API you wish to interact with. 53 | 54 | If the `REACT_APP_STAC_BROWSER` environment variable is not set, [Radiant Earth's STAC Browser](https://radiantearth.github.io/stac-browser/) will be used by default, which will connect to the STAC API specified in `REACT_APP_STAC_API`. 55 | 56 | **Auth** 57 | The client uses Keycloack for authentication, which is disabled by default. To 58 | enable it you must provide values for the `REACT_APP_KEYCLOAK_*` environment variables. These can be obtained through the Keycloak server. 59 | 60 | ### Theming 61 | 62 | The Stac manager client allows for simple theming to give the instance a different look and feel. 63 | The primary and secondary colors can be set using the `REACT_APP_THEME_PRIMARY_COLOR` and `REACT_APP_THEME_SECONDARY_COLOR` environment variables. These should be set to a valid CSS color value. 64 | 65 | The app has a default logo shown below, but it can be customized by replacing the necessary files. 66 | 67 | STAC Manager Logo 68 | 69 | The logo should be a square image with a size of 512x512 pixels and should be placed in the `static/meta` folder with the name `icon-512.png`. 70 | 71 | To ensure the branding is consistent, the remaining meta images (in the `static/meta` folder) should also be replaced: 72 | ``` 73 | icon-192.png 192x192 74 | icon-512.png 512x512 75 | favicon.png 32x32 76 | apple-touch-icon.png 360x360 77 | meta-image.png 1920x1080 78 | ``` -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stac-manager/client", 3 | "description": "Stac manager app", 4 | "version": "1.0.0", 5 | "source": "./src/index.html", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/developmentseed/stac-admin.git" 9 | }, 10 | "author": { 11 | "name": "Development Seed", 12 | "url": "https://developmentseed.org" 13 | }, 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/developmentseed/stac-admin/issues" 17 | }, 18 | "homepage": "https://github.com/developmentseed/stac-admin", 19 | "scripts": { 20 | "serve": "npm run clean && NODE_ENV=development node tasks/server.mjs", 21 | "build": "npm run clean && NODE_ENV=production node tasks/build.mjs", 22 | "stage": "npm run clean && NODE_ENV=staging node tasks/build.mjs", 23 | "clean": "rm -rf dist .parcel-cache", 24 | "lint": "eslint src/", 25 | "ts-check": "yarn tsc --noEmit --skipLibCheck", 26 | "test": "jest" 27 | }, 28 | "engines": { 29 | "node": "20.x" 30 | }, 31 | "browserslist": "> 0.5%, last 2 versions, not dead", 32 | "devDependencies": { 33 | "@babel/core": "^7.26.0", 34 | "@parcel/reporter-bundle-analyzer": "^2.12.0", 35 | "@parcel/reporter-bundle-buddy": "^2.12.0", 36 | "@types/babel__core": "^7", 37 | "@types/react": "^18.3.4", 38 | "@types/react-dom": "^18.3.0", 39 | "buffer": "^6.0.3", 40 | "events": "^3.3.0", 41 | "fancy-log": "^2.0.0", 42 | "fs-extra": "^11.2.0", 43 | "parcel": "^2.12.0", 44 | "parcel-resolver-ignore": "^2.2.0", 45 | "portscanner": "^2.2.0", 46 | "posthtml-expressions": "^1.11.4", 47 | "process": "^0.11.10", 48 | "stream-browserify": "^3.0.0", 49 | "watcher": "^2.3.1" 50 | }, 51 | "dependencies": { 52 | "@chakra-ui/react": "^2.8.2", 53 | "@developmentseed/stac-react": "^0.1.0-alpha.10", 54 | "@devseed-ui/collecticons-chakra": "^3.0.3", 55 | "@emotion/react": "^11.11.3", 56 | "@emotion/styled": "^11.11.0", 57 | "@floating-ui/react": "^0.26.25", 58 | "@mapbox/mapbox-gl-draw": "^1.4.3", 59 | "@mapbox/mapbox-gl-draw-static-mode": "^1.0.1", 60 | "@parcel/core": "^2.12.0", 61 | "@radiantearth/stac-fields": "^1.5.0", 62 | "@stac-manager/data-core": "*", 63 | "@stac-manager/data-plugins": "*", 64 | "@stac-manager/data-widgets": "*", 65 | "@testing-library/jest-dom": "^6.6.2", 66 | "@testing-library/react": "^16.2.0", 67 | "@testing-library/user-event": "^14.5.2", 68 | "@turf/bbox": "^7.1.0", 69 | "@turf/bbox-polygon": "^7.1.0", 70 | "@types/jest": "^29.5.14", 71 | "@types/keycloak-js": "^2.5.4", 72 | "@types/mapbox__mapbox-gl-draw": "^1.4.8", 73 | "@types/react": "^18.3.12", 74 | "@types/react-dom": "^18.3.1", 75 | "formik": "^2.4.6", 76 | "framer-motion": "^10.16.5", 77 | "keycloak-js": "^26.1.4", 78 | "mapbox-gl-draw-rectangle-mode": "^1.0.4", 79 | "maplibre-gl": "^3.6.2", 80 | "polished": "^4.3.1", 81 | "react": "^18.3.1", 82 | "react-cool-dimensions": "^3.0.1", 83 | "react-dom": "^18.3.1", 84 | "react-hook-form": "^7.53.1", 85 | "react-icons": "^4.12.0", 86 | "react-map-gl": "^7.1.7", 87 | "react-router-dom": "^6.27.0", 88 | "stac-ts": "^1.0.4" 89 | }, 90 | "parcelIgnore": [ 91 | ".*/meta/" 92 | ], 93 | "alias": { 94 | "$components": "~/src/components", 95 | "$styles": "~/src/styles", 96 | "$utils": "~/src/utils", 97 | "$hooks": "~/src/hooks", 98 | "$pages": "~/src/pages", 99 | "$test": "~/test" 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /packages/client/posthtml.config.js: -------------------------------------------------------------------------------- 1 | /* global process, module */ 2 | // https://github.com/parcel-bundler/parcel/issues/1209#issuecomment-942927265 3 | 4 | module.exports = { 5 | plugins: { 6 | 'posthtml-expressions': { 7 | locals: { 8 | appTitle: process.env.APP_TITLE, 9 | appDescription: process.env.APP_DESCRIPTION, 10 | baseurl: process.env.PUBLIC_URL || '/' 11 | } 12 | } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /packages/client/src/_custom-types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'mapbox-gl-draw-rectangle-mode'; 2 | declare module '@mapbox/mapbox-gl-draw-static-mode'; 3 | declare module '@radiantearth/stac-fields'; 4 | 5 | interface Array { 6 | /** 7 | * Returns the last element in the array. 8 | */ 9 | last: T; 10 | } 11 | -------------------------------------------------------------------------------- /packages/client/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { GenericObject, ApiError } from '../types'; 2 | 3 | class Api { 4 | static fetch(url: string, options: GenericObject) { 5 | return fetch(url, options).then(async (response) => { 6 | if (response.ok) { 7 | return response.json(); 8 | } 9 | 10 | const { status, statusText } = response; 11 | const e: ApiError = { 12 | status, 13 | statusText 14 | }; 15 | // Some STAC APIs return errors as JSON others as string. 16 | // Clone the response so we can read the body as text if json fails. 17 | const clone = response.clone(); 18 | try { 19 | e.detail = await response.json(); 20 | /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ 21 | } catch (err) { 22 | e.detail = await clone.text(); 23 | } 24 | return Promise.reject(e); 25 | }); 26 | } 27 | } 28 | 29 | export default Api; 30 | -------------------------------------------------------------------------------- /packages/client/src/auth/Context.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useContext, 4 | useEffect, 5 | useRef, 6 | useState 7 | } from 'react'; 8 | import Keycloak, { KeycloakInstance } from 'keycloak-js'; 9 | 10 | const url = process.env.REACT_APP_KEYCLOAK_URL; 11 | const realm = process.env.REACT_APP_KEYCLOAK_REALM; 12 | const clientId = process.env.REACT_APP_KEYCLOAK_CLIENT_ID; 13 | 14 | const isAuthEnabled = !!(url && realm && clientId); 15 | 16 | const keycloak: KeycloakInstance | undefined = isAuthEnabled 17 | ? new (Keycloak as any)({ 18 | url, 19 | realm, 20 | clientId 21 | }) 22 | : undefined; 23 | 24 | export type KeycloakContextProps = { 25 | initStatus: 'loading' | 'success' | 'error'; 26 | isLoading: boolean; 27 | profile?: Keycloak.KeycloakProfile; 28 | } & ( 29 | | { 30 | keycloak: KeycloakInstance; 31 | isEnabled: true; 32 | } 33 | | { 34 | keycloak: undefined; 35 | isEnabled: false; 36 | } 37 | ); 38 | 39 | const KeycloakContext = createContext({ 40 | initStatus: 'loading', 41 | isEnabled: isAuthEnabled 42 | } as KeycloakContextProps); 43 | 44 | export const KeycloakProvider = (props: { children: React.ReactNode }) => { 45 | const [initStatus, setInitStatus] = 46 | useState('loading'); 47 | const [profile, setProfile] = useState< 48 | Keycloak.KeycloakProfile | undefined 49 | >(); 50 | 51 | const wasInit = useRef(false); 52 | 53 | useEffect(() => { 54 | async function initialize() { 55 | if (!keycloak) return; 56 | // Keycloak can only be initialized once. This is a workaround to avoid 57 | // multiple initialization attempts, specially by React double rendering. 58 | if (wasInit.current) return; 59 | wasInit.current = true; 60 | 61 | try { 62 | await keycloak.init({ 63 | // onLoad: 'login-required', 64 | onLoad: 'check-sso', 65 | checkLoginIframe: false 66 | }); 67 | if (keycloak.authenticated) { 68 | const profile = 69 | await (keycloak.loadUserProfile() as unknown as Promise); 70 | setProfile(profile); 71 | } 72 | 73 | setInitStatus('success'); 74 | } catch (err) { 75 | setInitStatus('error'); 76 | // eslint-disable-next-line no-console 77 | console.error('Failed to initialize keycloak adapter:', err); 78 | } 79 | } 80 | initialize(); 81 | }, []); 82 | 83 | const base = { 84 | initStatus, 85 | isLoading: isAuthEnabled && initStatus === 'loading', 86 | profile 87 | }; 88 | 89 | return ( 90 | 105 | {props.children} 106 | 107 | ); 108 | }; 109 | 110 | export const useKeycloak = () => { 111 | const ctx = useContext(KeycloakContext); 112 | 113 | if (!ctx) { 114 | throw new Error('useKeycloak must be used within a KeycloakProvider'); 115 | } 116 | 117 | return ctx; 118 | }; 119 | -------------------------------------------------------------------------------- /packages/client/src/components/DeleteMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MenuItemProps } from '@chakra-ui/react'; 3 | import { CollecticonTrashBin } from '@devseed-ui/collecticons-chakra'; 4 | 5 | import { MenuItemWithAuth } from './auth/MenuItemWithAuth'; 6 | 7 | export function DeleteMenuItem(props: MenuItemProps) { 8 | return ( 9 | } 11 | color='danger.500' 12 | _hover={{ bg: 'danger.200' }} 13 | _focus={{ bg: 'danger.200' }} 14 | {...props} 15 | > 16 | Delete 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /packages/client/src/components/InnerPageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Flex, Heading, Text, forwardRef, FlexProps } from '@chakra-ui/react'; 3 | 4 | interface InnerPageHeaderProps extends FlexProps { 5 | title: string; 6 | overline?: string; 7 | actions?: React.ReactNode; 8 | } 9 | 10 | export const InnerPageHeader = forwardRef( 11 | ({ title, overline, actions, ...rest }, ref) => { 12 | return ( 13 | 22 | {overline && ( 23 | 24 | {overline} 25 | 26 | )} 27 | 28 | 29 | {title} 30 | 31 | {actions && {actions}} 32 | 33 | 34 | ); 35 | } 36 | ); 37 | 38 | export const InnerPageHeaderSticky = forwardRef( 39 | (props, ref) => { 40 | const [isAtTop, setIsAtTop] = useState(false); 41 | 42 | const localRef = useRef(null); 43 | 44 | useEffect(() => { 45 | const el = localRef.current; 46 | if (!el) return; 47 | const observer = new IntersectionObserver( 48 | ([entry]) => { 49 | setIsAtTop(entry.intersectionRatio < 1); 50 | }, 51 | { threshold: [1] } 52 | ); 53 | 54 | observer.observe(el); 55 | 56 | return () => { 57 | observer.unobserve(el); 58 | }; 59 | }, []); 60 | 61 | const headerRef: React.RefCallback = (v) => { 62 | localRef.current = v; 63 | if (typeof ref === 'function') { 64 | ref(v); 65 | } else if (ref != null) { 66 | (ref as React.MutableRefObject).current = v; 67 | } 68 | }; 69 | 70 | return ( 71 | 79 | ); 80 | } 81 | ); 82 | -------------------------------------------------------------------------------- /packages/client/src/components/ItemCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Card, 4 | CardHeader, 5 | CardBody, 6 | CardFooter, 7 | Flex, 8 | Image, 9 | Text, 10 | Heading, 11 | Box, 12 | HStack, 13 | Tag, 14 | Skeleton, 15 | SkeletonText 16 | } from '@chakra-ui/react'; 17 | import SmartLink from './SmartLink'; 18 | import { ItemCardThumbPlaceholder } from './ItemCardThumbPlaceholder'; 19 | 20 | interface ItemCardProps { 21 | imageSrc?: string; 22 | imageAlt?: string; 23 | showPlaceholder?: boolean; 24 | title?: string; 25 | subtitle?: string; 26 | description?: string; 27 | tags?: string[]; 28 | to?: string; 29 | renderMenu?: () => React.ReactNode; 30 | } 31 | 32 | export function ItemCard({ 33 | imageSrc, 34 | imageAlt, 35 | showPlaceholder, 36 | title, 37 | subtitle, 38 | description, 39 | tags, 40 | to, 41 | renderMenu 42 | }: ItemCardProps) { 43 | const renderLink = (children: React.ReactNode) => { 44 | return to ? ( 45 | 46 | {children} 47 | 48 | ) : ( 49 | <>{children} 50 | ); 51 | }; 52 | 53 | const shouldUsePlaceholder = showPlaceholder && !imageSrc; 54 | 55 | return ( 56 | 57 | {imageSrc && 58 | renderLink( 59 | {imageAlt} 67 | )} 68 | {shouldUsePlaceholder && renderLink()} 69 | 70 | 71 | {(title || subtitle) && ( 72 | 73 | {title && ( 74 | 75 | {renderLink(title)} 76 | 77 | )} 78 | {subtitle && ( 79 | 80 | {subtitle} 81 | 82 | )} 83 | 84 | )} 85 | {renderMenu && {renderMenu()}} 86 | 87 | 88 | {description && ( 89 | 90 | {description} 91 | 92 | )} 93 | {tags && tags.length > 0 && ( 94 | 95 | 96 | {tags.map((tag) => ( 97 | // 98 | 99 | {tag} 100 | 101 | ))} 102 | 103 | 104 | )} 105 | 106 | ); 107 | } 108 | 109 | export function ItemCardLoading(props: { mini?: boolean }) { 110 | return ( 111 | 112 | 113 | 114 | 115 | 116 | 117 | {!props.mini && ( 118 | <> 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | )} 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /packages/client/src/components/MainNavigation.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | List, 4 | ListItem, 5 | Button, 6 | ButtonProps, 7 | Flex, 8 | Divider 9 | } from '@chakra-ui/react'; 10 | import { 11 | CollecticonFolder, 12 | CollecticonPlusSmall 13 | } from '@devseed-ui/collecticons-chakra'; 14 | 15 | import SmartLink, { SmartLinkProps } from './SmartLink'; 16 | import { UserInfo } from './auth/UserInfo'; 17 | import { useKeycloak } from 'src/auth/Context'; 18 | 19 | function NavItem(props: ButtonProps & SmartLinkProps) { 20 | return ( 21 | 22 | 26 | 29 | {paginate.left.map((p) => ( 30 | 33 | ))} 34 | {paginate.hasLeftBreak && } 35 | {paginate.pages.map((p) => ( 36 | 44 | ))} 45 | {paginate.hasRightBreak && } 46 | {paginate.right.map((p) => ( 47 | 50 | ))} 51 | 54 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /packages/client/src/components/SmartLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link as ChLink, LinkProps } from '@chakra-ui/react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export interface SmartLinkProps extends LinkProps { 6 | to: string; 7 | } 8 | 9 | export default React.forwardRef( 10 | function SmartLink(props, ref) { 11 | const { to, ...rest } = props; 12 | 13 | const isExternal = 14 | to.match(/^(https?:)?\/\//) || to.match(/^(mailto|tel):/); 15 | 16 | return isExternal ? ( 17 | 18 | ) : ( 19 | 20 | ); 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /packages/client/src/components/StacBrowserMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MenuItem, MenuItemProps } from '@chakra-ui/react'; 3 | import { CollecticonGlobe } from '@devseed-ui/collecticons-chakra'; 4 | import SmartLink from './SmartLink'; 5 | 6 | const baseStacBrowserUrl = 7 | process.env.REACT_APP_STAC_BROWSER || 8 | `https://radiantearth.github.io/stac-browser/#/external/${process.env.REACT_APP_STAC_API}`; 9 | 10 | export function StacBrowserMenuItem( 11 | props: MenuItemProps & { resourcePath: string } 12 | ) { 13 | const { resourcePath, ...rest } = props; 14 | return ( 15 | } 17 | as={SmartLink} 18 | to={`${baseStacBrowserUrl}${resourcePath}`} 19 | {...rest} 20 | > 21 | View in STAC Browser 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/client/src/components/auth/ButtonWithAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, ButtonProps, forwardRef } from '@chakra-ui/react'; 3 | import SmartLink, { SmartLinkProps } from '../SmartLink'; 4 | import { useKeycloak } from 'src/auth/Context'; 5 | 6 | export const ButtonWithAuth = forwardRef< 7 | SmartLinkProps & ButtonProps, 8 | typeof Button 9 | >((props, ref) => { 10 | const { isEnabled, keycloak } = useKeycloak(); 11 | 12 | if (!isEnabled) { 13 | return 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /packages/client/src/components/auth/UserInfo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Avatar, Button } from '@chakra-ui/react'; 3 | import { 4 | CollecticonLogin, 5 | CollecticonLogout 6 | } from '@devseed-ui/collecticons-chakra'; 7 | 8 | import { useKeycloak } from 'src/auth/Context'; 9 | 10 | async function hash(string: string) { 11 | const utf8 = new TextEncoder().encode(string); 12 | const hashBuffer = await crypto.subtle.digest('SHA-256', utf8); 13 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 14 | const hashHex = hashArray 15 | .map((bytes) => bytes.toString(16).padStart(2, '0')) 16 | .join(''); 17 | return hashHex; 18 | } 19 | 20 | export function UserInfo() { 21 | const { profile, isLoading, isEnabled, keycloak } = useKeycloak(); 22 | 23 | const [userEmailHash, setUserEmailHash] = useState(''); 24 | useEffect(() => { 25 | if (profile?.email) { 26 | hash(profile.email).then(setUserEmailHash); 27 | } 28 | }, [profile?.email]); 29 | 30 | if (!isEnabled) { 31 | return null; 32 | } 33 | 34 | const isAuthenticated = keycloak.authenticated; 35 | 36 | if (!isAuthenticated || !profile || isLoading) { 37 | return ( 38 | 51 | ); 52 | } 53 | 54 | const username = 55 | `${profile.firstName} ${profile.lastName}`.trim() || profile.username; 56 | 57 | return ( 58 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /packages/client/src/components/icons/form.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createCollecticon } from '@devseed-ui/collecticons-chakra'; 3 | 4 | export const CollecticonForm = createCollecticon((props) => ( 5 | <> 6 | {props.title && {props.title}} 7 | 13 | 19 | 20 | 21 | )); 22 | -------------------------------------------------------------------------------- /packages/client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import usePageTitle from './usePageTitle'; 2 | import usePrevious from './usePrevious'; 3 | 4 | export { usePageTitle, usePrevious }; 5 | -------------------------------------------------------------------------------- /packages/client/src/hooks/usePageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | function usePageTitle(title: string) { 4 | useEffect(() => { 5 | document.title = title; 6 | }, [title]); 7 | } 8 | export default usePageTitle; 9 | -------------------------------------------------------------------------------- /packages/client/src/hooks/usePrevious.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | function usePrevious(value: T) { 4 | const [current, setCurrent] = useState(value); 5 | const [previous, setPrevious] = useState(value); 6 | 7 | if (value !== current) { 8 | setPrevious(current); 9 | setCurrent(value); 10 | } 11 | 12 | return previous; 13 | } 14 | 15 | export default usePrevious; 16 | -------------------------------------------------------------------------------- /packages/client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {{appTitle}} 21 | 22 | 23 | 24 | 25 | 28 | 29 | 64 | 65 | 66 | 67 | 68 |
69 |
70 | 75 |

logotype

76 |

In the beginning the Universe was created. 77 |

78 |

This has made a lot of people very angry and been widely regarded as a bad move.

79 |
80 |
81 | 82 | 83 |
84 | 85 |
86 | 87 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /packages/client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | import { ChakraProvider, ColorModeScript } from '@chakra-ui/react'; 5 | import { StacApiProvider } from '@developmentseed/stac-react'; 6 | import { PluginConfigProvider } from '@stac-manager/data-core'; 7 | 8 | import { App } from './App'; 9 | import theme from './theme/theme'; 10 | import { config } from './plugin-system/config'; 11 | import { KeycloakProvider } from './auth/Context'; 12 | 13 | const publicUrl = process.env.PUBLIC_URL || ''; 14 | 15 | let basename: string | undefined; 16 | if (publicUrl) { 17 | try { 18 | basename = new URL(publicUrl).pathname; 19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 20 | } catch (error) { 21 | // no-op 22 | } 23 | } 24 | 25 | const composingComponents = [ 26 | [ChakraProvider, { theme }], 27 | [Router, { basename }], 28 | [KeycloakProvider, {}], 29 | [StacApiProvider, { apiUrl: process.env.REACT_APP_STAC_API! }], 30 | [PluginConfigProvider, { config }] 31 | ] as const; 32 | 33 | // Root component. 34 | function Root() { 35 | useEffect(() => { 36 | // Hide the welcome banner. 37 | const banner = document.querySelector('#welcome-banner'); 38 | if (!banner) return; 39 | banner.classList.add('dismissed'); 40 | setTimeout(() => banner.remove(), 500); 41 | }, []); 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | const rootNode = document.querySelector('#app-container')!; 54 | const root = createRoot(rootNode); 55 | root.render(); 56 | 57 | /** 58 | * Composes components to to avoid deep nesting trees. Useful for contexts. 59 | * 60 | * @param {node} children Component children 61 | * @param {array} components The components to compose. 62 | */ 63 | function Composer(props: { 64 | children: React.ReactNode; 65 | components: readonly (readonly [React.ComponentType, any])[]; 66 | }) { 67 | const { children, components } = props; 68 | const itemToCompose = [...components].reverse(); 69 | 70 | return itemToCompose.reduce( 71 | (acc, [Component, props = {}]) => {acc}, 72 | children 73 | ); 74 | } 75 | 76 | Object.defineProperty(Array.prototype, 'last', { 77 | enumerable: false, 78 | configurable: true, 79 | get: function () { 80 | return this[this.length - 1]; 81 | }, 82 | set: undefined 83 | }); 84 | -------------------------------------------------------------------------------- /packages/client/src/pages/CollectionDetail/CollectionMap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import Map, { Layer, Source, MapRef } from 'react-map-gl/maplibre'; 3 | import { LngLatBounds } from 'maplibre-gl'; 4 | import bboxPolygon from '@turf/bbox-polygon'; 5 | 6 | import { BackgroundTiles } from '../../components/Map'; 7 | import { StacCollection } from 'stac-ts'; 8 | 9 | const extentOutline = { 10 | 'line-color': '#276749', 11 | 'line-width': 2, 12 | 'line-dasharray': [2, 2] 13 | }; 14 | 15 | const dataOutline = { 16 | 'line-color': '#C53030', 17 | 'line-width': 1 18 | }; 19 | 20 | type CollectionMapProps = { 21 | collection: StacCollection; 22 | }; 23 | 24 | function CollectionMap({ collection }: CollectionMapProps) { 25 | const [map, setMap] = useState(); 26 | const setMapRef = (m: MapRef) => setMap(m); 27 | 28 | // Create GeoJSON polygon from extent 29 | const extent = useMemo(() => { 30 | if (!collection) return; 31 | const [x1, y1, x2, y2] = collection.extent.spatial.bbox[0]; 32 | return bboxPolygon([x1, y1, x2, y2]); 33 | }, [collection]); 34 | 35 | // Create GeoJSON Feature collection from data extents 36 | const dataExtents = useMemo(() => { 37 | if (!collection) return; 38 | if (collection.extent.spatial.bbox.length > 1) { 39 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 40 | const [_, ...data] = collection.extent.spatial.bbox; 41 | return { 42 | type: 'FeatureCollection', 43 | features: data.map(([x1, y1, x2, y2]) => bboxPolygon([x1, y1, x2, y2])) 44 | }; 45 | } 46 | }, [collection]); 47 | 48 | // Fit the map view around the current collection extent 49 | useEffect(() => { 50 | if (collection && map) { 51 | let [x1, y1, x2, y2] = collection.extent.spatial.bbox[0]; 52 | const bounds = new LngLatBounds([x1, y1, x2, y2]); 53 | for ( 54 | let i = 1, len = collection.extent.spatial.bbox.length; 55 | i < len; 56 | i++ 57 | ) { 58 | [x1, y1, x2, y2] = collection.extent.spatial.bbox[i]; 59 | bounds.extend([x1, y1, x2, y2]); 60 | } 61 | [x1, y1, x2, y2] = bounds.toArray().flat(); 62 | map.fitBounds([x1, y1, x2, y2], { padding: 30, duration: 0 }); 63 | } 64 | }, [collection, map]); 65 | 66 | return ( 67 | 68 | 69 | {extent && ( 70 | 71 | 72 | 73 | )} 74 | {dataExtents && ( 75 | 76 | 77 | 78 | )} 79 | 80 | ); 81 | } 82 | 83 | export default CollectionMap; 84 | -------------------------------------------------------------------------------- /packages/client/src/pages/CollectionForm/useCollectionTransaction.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { StacCollection } from 'stac-ts'; 3 | import Api from '../../api'; 4 | import { LoadingState, ApiError } from '../../types'; 5 | 6 | type UseCollectionTransactionType = { 7 | update: (id: string, data: StacCollection) => Promise; 8 | create: (data: StacCollection) => Promise; 9 | error?: ApiError; 10 | state: LoadingState; 11 | }; 12 | 13 | export function useCollectionTransaction(): UseCollectionTransactionType { 14 | const [error, setError] = useState(); 15 | const [state, setState] = useState('IDLE'); 16 | 17 | const createRequest = useCallback( 18 | async (url: string, method: string, data: StacCollection) => { 19 | setState('LOADING'); 20 | 21 | try { 22 | return Api.fetch(url, { 23 | method: method, 24 | headers: { 'Content-Type': 'application/json' }, 25 | body: JSON.stringify(data) 26 | }); 27 | } catch (error) { 28 | setError(error as ApiError); 29 | } finally { 30 | setState('IDLE'); 31 | } 32 | }, 33 | [] 34 | ); 35 | 36 | return { 37 | update: (id: string, data: StacCollection) => 38 | createRequest( 39 | `${process.env.REACT_APP_STAC_API}/collections/${id}`, 40 | 'PUT', 41 | data 42 | ), 43 | create: (data: StacCollection) => 44 | createRequest( 45 | `${process.env.REACT_APP_STAC_API}/collections/`, 46 | 'POST', 47 | data 48 | ), 49 | error, 50 | state 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /packages/client/src/pages/CollectionList/useCollections.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Why this file exists: 3 | * @developmentseed/stac-react does not allow to change the limit and offset of 4 | * the collections endpoint. This file is a temporary workaround to allow 5 | * pagination. 6 | */ 7 | 8 | import { useStacApi } from '@developmentseed/stac-react'; 9 | import { useCallback, useEffect, useState } from 'react'; 10 | import { StacCollection, StacLink } from 'stac-ts'; 11 | 12 | type ApiError = { 13 | detail?: { [key: string]: any } | string; 14 | status: number; 15 | statusText: string; 16 | }; 17 | 18 | type LoadingState = 'IDLE' | 'LOADING'; 19 | 20 | const debounce = any>(fn: F, ms = 250) => { 21 | let timeoutId: ReturnType; 22 | 23 | return function (this: any, ...args: any[]) { 24 | clearTimeout(timeoutId); 25 | timeoutId = setTimeout(() => fn.apply(this, args), ms); 26 | }; 27 | }; 28 | 29 | type ApiResponse = { 30 | collections: StacCollection[]; 31 | links: StacLink[]; 32 | numberMatched: number; 33 | numberReturned: number; 34 | }; 35 | 36 | type StacCollectionsHook = { 37 | collections?: ApiResponse; 38 | reload: () => void; 39 | state: LoadingState; 40 | error?: ApiError; 41 | nextPage?: () => void; 42 | prevPage?: () => void; 43 | setOffset: (newOffset: number) => void; 44 | }; 45 | 46 | export function useCollections(opts?: { 47 | limit?: number; 48 | initialOffset?: number; 49 | }): StacCollectionsHook { 50 | const { limit = 10, initialOffset = 0 } = opts || {}; 51 | 52 | const { stacApi } = useStacApi(process.env.REACT_APP_STAC_API!); 53 | 54 | const [collections, setCollections] = useState(); 55 | const [state, setState] = useState('IDLE'); 56 | const [error, setError] = useState(); 57 | 58 | const [offset, setOffset] = useState(initialOffset); 59 | 60 | const [hasNext, setHasNext] = useState(false); 61 | const [hasPrev, setHasPrev] = useState(false); 62 | 63 | const _getCollections = useCallback( 64 | async (offset: number, limit: number) => { 65 | if (stacApi) { 66 | setState('LOADING'); 67 | 68 | try { 69 | const res = await stacApi.fetch( 70 | `${stacApi.baseUrl}/collections?limit=${limit}&offset=${offset}` 71 | ); 72 | const data: ApiResponse = await res.json(); 73 | 74 | setHasNext(!!data.links.find((l) => l.rel === 'next')); 75 | setHasPrev( 76 | !!data.links.find((l) => ['prev', 'previous'].includes(l.rel)) 77 | ); 78 | 79 | setCollections(data); 80 | } catch (err: any) { 81 | setError(err); 82 | setCollections(undefined); 83 | } finally { 84 | setState('IDLE'); 85 | } 86 | } 87 | }, 88 | [stacApi] 89 | ); 90 | 91 | const getCollections = useCallback( 92 | (offset: number, limit: number) => 93 | debounce(() => _getCollections(offset, limit))(), 94 | [_getCollections] 95 | ); 96 | 97 | const nextPage = useCallback(() => { 98 | setOffset(offset + limit); 99 | }, [offset, limit]); 100 | 101 | const prevPage = useCallback(() => { 102 | setOffset(offset - limit); 103 | }, [offset, limit]); 104 | 105 | useEffect(() => { 106 | if (stacApi && !error && !collections) { 107 | getCollections(offset, limit); 108 | } 109 | }, [getCollections, stacApi, collections, error, offset, limit]); 110 | 111 | return { 112 | collections, 113 | reload: useCallback( 114 | () => getCollections(offset, limit), 115 | [getCollections, offset, limit] 116 | ), 117 | nextPage: hasNext ? nextPage : undefined, 118 | prevPage: hasPrev ? prevPage : undefined, 119 | setOffset, 120 | state, 121 | error 122 | }; 123 | } 124 | -------------------------------------------------------------------------------- /packages/client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | 4 | import { usePageTitle } from '../hooks'; 5 | 6 | function Home() { 7 | usePageTitle(process.env.APP_TITLE!); 8 | 9 | return ; 10 | } 11 | 12 | export default Home; 13 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemDetail/AssetList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Badge, 4 | Box, 5 | Flex, 6 | Heading, 7 | IconButton, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | SimpleGrid 13 | } from '@chakra-ui/react'; 14 | import { StacAsset } from 'stac-ts'; 15 | import StacFields from '@radiantearth/stac-fields'; 16 | import { 17 | CollecticonEllipsisVertical, 18 | CollecticonLink 19 | } from '@devseed-ui/collecticons-chakra'; 20 | import { zeroPad } from '$utils/format'; 21 | import { ItemCard } from '$components/ItemCard'; 22 | import SmartLink from '$components/SmartLink'; 23 | 24 | type AssetProps = { 25 | assetKey: string; 26 | asset: StacAsset & { 27 | alternate?: { [key: string]: Alternate }; 28 | }; 29 | }; 30 | 31 | type Alternate = { 32 | href: string; 33 | title?: string; 34 | description?: string; 35 | }; 36 | 37 | function Asset({ asset, assetKey }: AssetProps) { 38 | const { title, description, roles, type, href, alternate } = asset; 39 | const formattedProperties = StacFields.formatAsset({ type })[0].properties; 40 | 41 | return ( 42 | { 48 | return alternate ? ( 49 | 50 | } 54 | variant='outline' 55 | size='sm' 56 | /> 57 | 58 | {Object.entries(alternate).map( 59 | ([key, val]: [string, Alternate]) => ( 60 | 61 | {val.title || val.href} 62 | 63 | ) 64 | )} 65 | 66 | 67 | ) : ( 68 | } 73 | variant='outline' 74 | size='sm' 75 | /> 76 | ); 77 | }} 78 | /> 79 | ); 80 | } 81 | 82 | type AssetListProps = { 83 | assets: { [key: string]: StacAsset }; 84 | }; 85 | 86 | function AssetList({ assets }: AssetListProps) { 87 | const assetsList = Object.entries(assets); 88 | return ( 89 | 90 | 91 | 92 | 93 | Assets {zeroPad(assetsList.length)} 94 | 95 | 96 | 97 | 98 | 102 | {assetsList.map(([key, asset]) => ( 103 | 104 | ))} 105 | 106 | 107 | ); 108 | } 109 | 110 | export default AssetList; 111 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemDetail/ItemMap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import Map, { Source, Layer, MapRef } from 'react-map-gl/maplibre'; 3 | import { StacAsset } from 'stac-ts'; 4 | import getBbox from '@turf/bbox'; 5 | 6 | import { BackgroundTiles } from '$components/Map'; 7 | 8 | const resultsOutline = { 9 | 'line-color': '#C53030', 10 | 'line-width': 2 11 | }; 12 | 13 | const resultsFill = { 14 | 'fill-color': '#C53030', 15 | 'fill-opacity': 0.1 16 | }; 17 | 18 | const cogMediaTypes = [ 19 | 'image/tiff; application=geotiff; profile=cloud-optimized', 20 | 'image/vnd.stac.geotiff' 21 | ]; 22 | 23 | export function ItemMap( 24 | props: { item: any } & React.ComponentProps 25 | ) { 26 | const { item, ...rest } = props; 27 | 28 | const [map, setMap] = useState(); 29 | const setMapRef = (m: MapRef) => setMap(m); 30 | 31 | // Fit the map view around the current results bbox 32 | useEffect(() => { 33 | const bounds = item && getBbox(item); 34 | 35 | if (map && bounds) { 36 | const [x1, y1, x2, y2] = bounds; 37 | map.fitBounds([x1, y1, x2, y2], { padding: 30, duration: 0 }); 38 | } 39 | }, [item, map]); 40 | 41 | const previewAsset = useMemo(() => { 42 | if (!item) return; 43 | 44 | return Object.values(item.assets).reduce((preview, asset) => { 45 | const { type, href, roles } = asset as StacAsset; 46 | if (cogMediaTypes.includes(type || '')) { 47 | if (!preview) { 48 | return href; 49 | } else { 50 | if (roles && roles.includes('visual')) { 51 | return href; 52 | } 53 | } 54 | } 55 | return preview; 56 | }, undefined); 57 | }, [item]); 58 | 59 | return ( 60 | 61 | 62 | {previewAsset && ( 63 | 72 | 73 | 74 | )} 75 | 76 | 77 | {!previewAsset && ( 78 | 79 | )} 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemDetail/PropertyList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Table, 5 | TableContainer, 6 | Tbody, 7 | Td, 8 | Text, 9 | Th, 10 | Thead, 11 | Tr 12 | } from '@chakra-ui/react'; 13 | import { Property, PropertyGroup } from '../../types'; 14 | import TableValue from './TableValue'; 15 | 16 | type PropertyListProps = { 17 | properties: PropertyGroup; 18 | headerLevel?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; 19 | }; 20 | 21 | type PropType = [string, Property]; 22 | 23 | const IGNORE_PROPS = ['proj:bbox', 'proj:geometry']; 24 | 25 | function PropertyList({ properties, headerLevel = 'h2' }: PropertyListProps) { 26 | const { label, properties: props } = properties; 27 | return ( 28 | 29 | 30 | {label || 'Common Metadata'} 31 | 32 | {Object.entries(props) 33 | .filter(([key]: PropType) => !IGNORE_PROPS.includes(key)) 34 | .map(([key, val]: PropType, index: number) => 35 | val.itemOrder && val.itemOrder.length > 1 ? ( 36 | 37 | {val.label} 38 | 39 | 40 | 41 | 42 | {val.itemOrder.map((item) => ( 43 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 44 | 45 | ))} 46 | 47 | 48 | 49 | {val.value.map((value) => ( 50 | 51 | {val.itemOrder.map((item) => ( 52 | 55 | ))} 56 | 57 | ))} 58 | 59 |
{val.items![item].label}
53 | 54 |
60 |
61 |
62 | ) : ( 63 | 72 | 73 | 74 | 75 | ) 76 | )} 77 | 78 | ); 79 | } 80 | 81 | export default PropertyList; 82 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemDetail/Roles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type RolesProps = { 4 | roles: { 5 | spec: { 6 | label: 'Purpose'; 7 | mapping: { 8 | data: 'Data'; 9 | graphic: 'Illustration'; 10 | metadata: 'Metadata'; 11 | overview: 'Overview'; 12 | thumbnail: 'Preview'; 13 | visual: 'Visualization'; 14 | [key: string]: string; 15 | }; 16 | }; 17 | value: string[]; 18 | }; 19 | }; 20 | 21 | function Roles({ roles }: RolesProps) { 22 | const { value, spec } = roles; 23 | const { mapping } = spec; 24 | return <>{value.map((val) => mapping[val] || val).join(', ')}; 25 | } 26 | 27 | export default Roles; 28 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemDetail/TableValue.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ListItem, UnorderedList } from '@chakra-ui/react'; 3 | 4 | type TableValueProps = { 5 | value: any; // eslint-disable-line @typescript-eslint/no-explicit-any 6 | }; 7 | 8 | function TableValue({ value }: TableValueProps) { 9 | if (Array.isArray(value)) { 10 | return ( 11 | 12 | {value.map((v, i) => ( 13 | /* eslint-disable-next-line react/no-array-index-key */ 14 | {v} 15 | ))} 16 | 17 | ); 18 | } 19 | 20 | if (value === Object(value)) { 21 | // This is an object 22 | return ( 23 | 24 | {Object.entries(value).map(([k, v]) => ( 25 | 26 | {k}: 27 | 28 | ))} 29 | 30 | ); 31 | } 32 | 33 | return value; 34 | } 35 | 36 | export default TableValue; 37 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemForm/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { usePageTitle } from '../../hooks'; 4 | 5 | function ItemForm() { 6 | const { itemId } = useParams(); 7 | usePageTitle(`Edit item ${itemId}`); 8 | 9 | return

Page not available

; 10 | } 11 | 12 | export default ItemForm; 13 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemList/DrawBboxControl.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | import MapboxDraw from '@mapbox/mapbox-gl-draw'; 4 | import StaticMode from '@mapbox/mapbox-gl-draw-static-mode'; 5 | import DrawRectangle from 'mapbox-gl-draw-rectangle-mode'; 6 | import { GeoJSONFeature, GeoJSONPolygon } from 'stac-ts/src/types/geojson'; 7 | import { Map } from 'maplibre-gl'; 8 | 9 | const addDrawControl = ( 10 | map: Map, 11 | drawingCompleted: (f: GeoJSONFeature) => void 12 | ) => { 13 | const { modes } = MapboxDraw; 14 | 15 | const options = { 16 | modes: { 17 | ...modes, 18 | draw_rectangle: DrawRectangle, 19 | static: StaticMode 20 | }, 21 | boxSelect: false, 22 | displayControlsDefault: false 23 | }; 24 | const draw = new MapboxDraw(options) as any; // eslint-disable-line @typescript-eslint/no-explicit-any 25 | 26 | map.addControl(draw); 27 | map.on('draw.create', (e) => { 28 | const { features } = e; 29 | const feature = features[0]; 30 | map.getCanvas().style.cursor = ''; 31 | setTimeout(() => draw.changeMode('static'), 0); 32 | drawingCompleted(feature); 33 | }); 34 | return draw; 35 | }; 36 | 37 | type DrawBboxControlProps = { 38 | handleDrawComplete: (bbox: number[]) => void; 39 | isEnabled: boolean; 40 | bbox?: number[]; 41 | map: any; // eslint-disable-line @typescript-eslint/no-explicit-any 42 | }; 43 | 44 | function DrawBboxControl({ 45 | map, 46 | handleDrawComplete, 47 | isEnabled, 48 | bbox 49 | }: DrawBboxControlProps) { 50 | const drawControlRef = useRef(); 51 | 52 | // Callback when drawing is finished. Receives a feature and returns 53 | // its bounding box. With this control users can only draw squares 54 | // so the simple method is sufficient. 55 | const handleDraw = useCallback( 56 | (feature: GeoJSONFeature) => { 57 | const { coordinates } = feature.geometry as GeoJSONPolygon; 58 | const bbox = [...coordinates[0][0], ...coordinates[0][2]]; 59 | handleDrawComplete(bbox); 60 | }, 61 | [handleDrawComplete] 62 | ); 63 | 64 | useEffect(() => { 65 | if (map && !drawControlRef.current) { 66 | drawControlRef.current = addDrawControl(map, handleDraw); 67 | } 68 | }, [map, handleDraw]); 69 | 70 | useEffect(() => { 71 | if (isEnabled && drawControlRef.current) { 72 | drawControlRef.current.deleteAll(); 73 | drawControlRef.current.changeMode('draw_rectangle'); 74 | map.getCanvas().style.cursor = 'crosshair'; 75 | } 76 | }, [isEnabled, map]); 77 | 78 | useEffect(() => { 79 | if (!bbox && drawControlRef.current) { 80 | drawControlRef.current.deleteAll(); 81 | } 82 | }, [bbox]); 83 | 84 | return null; 85 | } 86 | 87 | export default DrawBboxControl; 88 | -------------------------------------------------------------------------------- /packages/client/src/pages/ItemList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Flex } from '@chakra-ui/react'; 3 | import { useStacSearch } from '@developmentseed/stac-react'; 4 | 5 | import { usePageTitle } from '../../hooks'; 6 | import ItemListFilter from './ItemListFilter'; 7 | import ItemResults from '../../components/ItemResults'; 8 | import { InnerPageHeader } from '$components/InnerPageHeader'; 9 | 10 | function ItemList() { 11 | usePageTitle('Items'); 12 | const { 13 | results, 14 | state, 15 | sortby, 16 | setSortby, 17 | limit, 18 | setLimit, 19 | submit, 20 | nextPage, 21 | previousPage, 22 | ...searchState 23 | } = useStacSearch(); 24 | 25 | // Submit handlers and effects 26 | useEffect(() => { 27 | // Automatically submit to receive initial results 28 | if (results) return; 29 | submit(); 30 | }, [submit]); 31 | 32 | return ( 33 | 34 | 35 | 36 | 47 | 48 | 49 |

Test.

50 |
51 |
52 | ); 53 | } 54 | 55 | export default ItemList; 56 | -------------------------------------------------------------------------------- /packages/client/src/pages/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Flex, Text } from '@chakra-ui/react'; 3 | 4 | import { usePageTitle } from '../hooks'; 5 | import SmartLink from '$components/SmartLink'; 6 | import { InnerPageHeader } from '$components/InnerPageHeader'; 7 | 8 | function NotFound() { 9 | usePageTitle('Not found'); 10 | 11 | return ( 12 | 13 | 14 | 15 | The resource you're looking for could not be found. 16 | 17 | Perhaps in the mean time you can check out the{' '} 18 | collections page. 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default NotFound; 26 | -------------------------------------------------------------------------------- /packages/client/src/pages/Sandbox/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Flex, SimpleGrid } from '@chakra-ui/react'; 3 | 4 | import { usePageTitle } from '../../hooks'; 5 | import { InnerPageHeader } from '$components/InnerPageHeader'; 6 | import { ItemCard } from '$components/ItemCard'; 7 | 8 | export default function Sandbox() { 9 | usePageTitle('Sandbox'); 10 | 11 | return ( 12 | 13 | 14 | 15 |

This is the sandbox.

16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /packages/client/src/plugin-system/config.ts: -------------------------------------------------------------------------------- 1 | import { extendPluginConfig } from '@stac-manager/data-core'; 2 | import { defaultPluginWidgetConfig } from '@stac-manager/data-widgets'; 3 | import { 4 | PluginCore, 5 | PluginItemAssets, 6 | // PluginKitchenSink, 7 | PluginRender 8 | } from '@stac-manager/data-plugins'; 9 | 10 | export const config = extendPluginConfig(defaultPluginWidgetConfig, { 11 | collectionPlugins: [ 12 | new PluginCore(), 13 | new PluginItemAssets(), 14 | new PluginRender() 15 | ], 16 | // collectionPlugins: [new PluginKitchenSink()], 17 | itemPlugins: [], 18 | 19 | 'ui:widget': {} 20 | }); 21 | -------------------------------------------------------------------------------- /packages/client/src/theme/color-palette.ts: -------------------------------------------------------------------------------- 1 | import { rgba, tint, shade } from 'polished'; 2 | 3 | /** 4 | * Curry the polished rgba function to allow switching the parameters. 5 | */ 6 | const _rgba = (alpha: number) => (color: string) => rgba(color, alpha); 7 | 8 | const colorPaletteSettings = [ 9 | { 10 | code: '50', 11 | colorFn: tint(0.96) 12 | }, 13 | { 14 | code: '50a', 15 | colorFn: _rgba(0.04) 16 | }, 17 | { 18 | code: '100', 19 | colorFn: tint(0.92) 20 | }, 21 | { 22 | code: '100a', 23 | colorFn: _rgba(0.08) 24 | }, 25 | { 26 | code: '200', 27 | colorFn: tint(0.84) 28 | }, 29 | { 30 | code: '200a', 31 | colorFn: _rgba(0.16) 32 | }, 33 | { 34 | code: '300', 35 | colorFn: tint(0.68) 36 | }, 37 | { 38 | code: '300a', 39 | colorFn: _rgba(0.32) 40 | }, 41 | { 42 | code: '400', 43 | colorFn: tint(0.36) 44 | }, 45 | { 46 | code: '400a', 47 | colorFn: _rgba(0.64) 48 | }, 49 | { 50 | code: '500', 51 | colorFn: (v: string) => v 52 | }, 53 | { 54 | code: '600', 55 | colorFn: shade(0.16) 56 | }, 57 | { 58 | code: '700', 59 | colorFn: shade(0.32) 60 | }, 61 | { 62 | code: '800', 63 | colorFn: shade(0.48) 64 | }, 65 | { 66 | code: '900', 67 | colorFn: shade(0.64) 68 | } 69 | ]; 70 | 71 | /** 72 | * Creates a color palette base off of the provided base color including 73 | * lightened/darkened/transparent versions of that color. 74 | * 75 | * Uses a scale from 50 - 900 to indicate the color value. Values lower than 500 76 | * are lightened, above 500 are darkened and values ending with `a` have a alpha 77 | * channel. 78 | * 79 | * List of returned colors: 80 | * name.50 Lightened 96% 81 | * name.50a Opacity 4% 82 | * name.100 Lightened 92% 83 | * name.100a Opacity 8% 84 | * name.200 Lightened 84% 85 | * name.200a Opacity 16% 86 | * name.300 Lightened 68% 87 | * name.300a Opacity 32% 88 | * name.400 Lightened 36% 89 | * name.400a Opacity 64% 90 | * name.500 Same as base color 91 | * name.600 Darkened 16% 92 | * name.700 Darkened 32% 93 | * name.800 Darkened 48% 94 | * name.900 Darkened 64% 95 | * 96 | * @param {string} name Name of the color variable 97 | * @param {string} baseColor Base color for the palette. Used as middle color 98 | * with value 500. 99 | * 100 | * @returns object 101 | */ 102 | export function createColorPalette(baseColor: string) { 103 | return colorPaletteSettings.reduce( 104 | (acc, c) => ({ 105 | ...acc, 106 | [c.code]: c.colorFn(baseColor) 107 | }), 108 | {} 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /packages/client/src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react'; 2 | import { createColorPalette } from './color-palette'; 3 | import { adjustHue, setLightness, setSaturation } from 'polished'; 4 | 5 | const primary = process.env.REACT_APP_THEME_PRIMARY_COLOR || '#6A5ACD'; 6 | const secondary = process.env.REACT_APP_THEME_SECONDARY_COLOR || '#048A81'; 7 | const base = setSaturation(0.32, setLightness(0.16, adjustHue(48, primary))); 8 | 9 | const theme = { 10 | colors: { 11 | primary: createColorPalette(primary), 12 | secondary: createColorPalette(secondary), 13 | base: createColorPalette(base), 14 | danger: createColorPalette('#FF5353'), 15 | warning: createColorPalette('#FFC849'), 16 | success: createColorPalette('#46D6CD'), 17 | info: createColorPalette('#1A5BDB'), 18 | surface: createColorPalette('#FFF'), 19 | gray: createColorPalette(base) 20 | }, 21 | fonts: { 22 | body: 'Inter', 23 | heading: 'Inter' 24 | }, 25 | fontSizes: { 26 | xs: '0.75rem', 27 | sm: '1rem', 28 | md: '1.25rem', 29 | lg: '1.5rem', 30 | xl: '1.75rem', 31 | '2xl': '2rem', 32 | '3xl': '2.25rem', 33 | '4xl': '2.5rem', 34 | '5xl': '2.75rem', 35 | '6xl': '3rem', 36 | '7xl': '3.25rem', 37 | '8xl': '3.5rem', 38 | '9xl': '3.75rem', 39 | '10xl': '4rem' 40 | }, 41 | styles: { 42 | global: { 43 | body: { 44 | fontSize: 'sm', 45 | color: 'base.500', 46 | mW: '100vw', 47 | overflowX: 'hidden' 48 | }, 49 | '*': { 50 | lineHeight: 'calc(0.5rem + 1em)' 51 | } 52 | } 53 | }, 54 | textStyles: { 55 | lead: { 56 | sm: { 57 | fontSize: 'md' 58 | }, 59 | lg: { 60 | fontSize: 'lg' 61 | } 62 | } 63 | }, 64 | components: { 65 | Heading: { 66 | baseStyle: { 67 | fontWeight: '700' 68 | } 69 | }, 70 | Link: { 71 | baseStyle: { 72 | color: 'primary.500' 73 | } 74 | }, 75 | Menu: { 76 | baseStyle: { 77 | item: { _hover: { textDecoration: 'none !important' } } 78 | } 79 | }, 80 | FormLabel: { 81 | baseStyle: { 82 | fontSize: 'sm' 83 | } 84 | }, 85 | Card: { 86 | variants: { 87 | filled: { 88 | container: { 89 | background: 'base.50' 90 | } 91 | } 92 | } 93 | }, 94 | Input: { 95 | variants: { 96 | outline: { 97 | field: { 98 | border: '2px solid' 99 | } 100 | } 101 | } 102 | }, 103 | Select: { 104 | variants: { 105 | outline: { 106 | field: { 107 | border: '2px solid' 108 | } 109 | } 110 | } 111 | }, 112 | Button: { 113 | baseStyle: { 114 | borderRadius: 'md', 115 | fontWeight: '700', 116 | ':is(a):hover': { 117 | textDecoration: 'none' 118 | } 119 | }, 120 | sizes: { 121 | xs: { 122 | fontSize: 'xs' 123 | }, 124 | sm: { 125 | fontSize: 'xs' 126 | }, 127 | md: { 128 | fontSize: 'sm' 129 | }, 130 | lg: { 131 | fontSize: 'sm' 132 | } 133 | }, 134 | variants: { 135 | outline: { 136 | border: '2px solid', 137 | '.chakra-button__group[data-attached][data-orientation=horizontal] > &:not(:last-of-type)': 138 | { marginEnd: '-2px' }, 139 | '.chakra-button__group[data-attached][data-orientation=vertical] > &:not(:last-of-type)': 140 | { marginBottom: '-2px' } 141 | }, 142 | 'soft-outline': (props: any) => { 143 | const { colorScheme: c } = props; 144 | return { 145 | border: '2px solid', 146 | borderColor: `${c}.200`, 147 | '.chakra-button__group[data-attached][data-orientation=horizontal] > &:not(:last-of-type)': 148 | { marginEnd: '-2px' }, 149 | '.chakra-button__group[data-attached][data-orientation=vertical] > &:not(:last-of-type)': 150 | { marginBottom: '-2px' }, 151 | _hover: { 152 | bg: `${c}.50a` 153 | }, 154 | _active: { 155 | bg: `${c}.100a` 156 | } 157 | }; 158 | } 159 | } 160 | } 161 | } 162 | }; 163 | 164 | export default extendTheme(theme); 165 | -------------------------------------------------------------------------------- /packages/client/src/types/index.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 | 13 | export type PropertyItem = { 14 | label: string; 15 | sortable: boolean; 16 | order: 1; 17 | }; 18 | 19 | export type Property = { 20 | formatted: string; 21 | itemOrder: string[]; 22 | items?: { [key: string]: PropertyItem }; 23 | label: string; 24 | value: GenericObject[]; 25 | }; 26 | 27 | export type PropertyGroup = { 28 | extension: string; 29 | label: string; 30 | properties: { 31 | [key: string]: Property; 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/client/src/utils/usePaginateHook.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | function sq(start: number, end: number) { 4 | return Array.from({ length: end - start + 1 }, (_, i) => i + start); 5 | } 6 | 7 | export function usePaginate({ 8 | numPages, 9 | currentPage, 10 | onPageChange, 11 | marginsRange = 2, 12 | pageRange = 3 13 | }: { 14 | numPages: number; 15 | currentPage: number; 16 | onPageChange: React.Dispatch>; 17 | marginsRange?: number; 18 | pageRange?: number; 19 | }) { 20 | const pageRangeHalf = Math.floor(pageRange / 2); 21 | const marginsRangeAndEllipsis = marginsRange ? marginsRange + 2 : 0; 22 | 23 | if (pageRange < 1) { 24 | throw new Error('pageRange must be at least 1'); 25 | } 26 | 27 | if (marginsRange < 0) { 28 | throw new Error('marginsRange cannot be negative'); 29 | } 30 | 31 | if (currentPage < 1 || currentPage > numPages) { 32 | throw new Error('current page is out of bounds. [1, numPages]'); 33 | } 34 | 35 | let left: number[] = []; 36 | let right: number[] = []; 37 | let pages: number[] = []; 38 | 39 | if (numPages <= pageRange + 2 * (marginsRange + 1)) { 40 | left = []; 41 | right = []; 42 | pages = sq(1, numPages); 43 | } else { 44 | // Create the pages array taking the page range into account. 45 | // It is adjusted if needed below 46 | pages = sq(currentPage - pageRangeHalf, currentPage + pageRangeHalf); 47 | 48 | // We only show the left side when there's enough space to show whole margin 49 | // plus the ellipsis plus at least one page hidden before half the page 50 | // range. 51 | if (currentPage > marginsRangeAndEllipsis + pageRangeHalf) { 52 | left = sq(1, marginsRange); 53 | } else { 54 | // Adjust the pages array to the left. 55 | pages = sq( 56 | 1, 57 | Math.max(marginsRangeAndEllipsis + pageRangeHalf * 2, pageRange) 58 | ); 59 | } 60 | 61 | // Same behavior for the right side. 62 | if (currentPage < numPages - marginsRangeAndEllipsis - pageRangeHalf + 1) { 63 | right = sq(numPages - marginsRange + 1, numPages); 64 | } else { 65 | // Adjust the pages array to the right. 66 | pages = sq( 67 | numPages - 68 | Math.max(marginsRangeAndEllipsis + pageRangeHalf, pageRange - 1), 69 | numPages 70 | ); 71 | } 72 | } 73 | 74 | const hasNext = currentPage < numPages; 75 | const hasPrevious = currentPage > 1; 76 | 77 | return { 78 | pages, 79 | left, 80 | right, 81 | hasLeftBreak: !!left.length, 82 | hasRightBreak: !!right.length, 83 | goNext: useCallback(() => { 84 | if (hasNext) { 85 | onPageChange((v) => v + 1); 86 | } 87 | }, [hasNext, onPageChange]), 88 | goPrevious: useCallback(() => { 89 | if (hasPrevious) { 90 | onPageChange((v) => v - 1); 91 | } 92 | }, [hasPrevious, onPageChange]), 93 | goFirst: useCallback(() => { 94 | onPageChange(1); 95 | }, [onPageChange]), 96 | goLast: useCallback(() => { 97 | onPageChange(numPages); 98 | }, [numPages, onPageChange]), 99 | goToPage: useCallback( 100 | (page: number) => { 101 | if (page > 0 && page <= numPages) { 102 | onPageChange(page); 103 | } 104 | }, 105 | [numPages, onPageChange] 106 | ), 107 | hasNext, 108 | hasPrevious 109 | }; 110 | } 111 | -------------------------------------------------------------------------------- /packages/client/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/.nojekyll -------------------------------------------------------------------------------- /packages/client/static/meta/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/meta/apple-touch-icon.png -------------------------------------------------------------------------------- /packages/client/static/meta/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/meta/favicon.png -------------------------------------------------------------------------------- /packages/client/static/meta/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/meta/icon-192.png -------------------------------------------------------------------------------- /packages/client/static/meta/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/meta/icon-512.png -------------------------------------------------------------------------------- /packages/client/static/meta/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/client/static/meta/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "icons": [ 3 | { "src": "/meta/icon-192.png", "type": "image/png", "sizes": "192x192" }, 4 | { "src": "/meta/icon-512.png", "type": "image/png", "sizes": "512x512" } 5 | ] 6 | } -------------------------------------------------------------------------------- /packages/client/static/meta/meta-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/developmentseed/stac-manager/27c7fb31ec7ec2f8a45143bd4aafeb93f5340643/packages/client/static/meta/meta-image.png -------------------------------------------------------------------------------- /packages/client/tasks/build.mjs: -------------------------------------------------------------------------------- 1 | /* global process */ 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import fs from 'fs-extra'; 5 | import log from 'fancy-log'; 6 | import { Parcel } from '@parcel/core'; 7 | 8 | import { 9 | checkRequiredEnvVars, 10 | loadEnvironmentVariables 11 | } from './check-env-vars.mjs'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | 16 | loadEnvironmentVariables(); 17 | checkRequiredEnvVars(['REACT_APP_STAC_API', 'PUBLIC_URL']); 18 | 19 | // ///////////////////////////////////////////////////////////////////////////// 20 | // --------------------------- Variables -------------------------------------// 21 | // ---------------------------------------------------------------------------// 22 | 23 | // Environment 24 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'; 25 | 26 | const readPackage = () => 27 | JSON.parse(fs.readFileSync(`${__dirname}/../package.json`)); 28 | 29 | // Set the version in an env variable. 30 | process.env.APP_VERSION = readPackage().version; 31 | process.env.APP_BUILD_TIME = Date.now(); 32 | 33 | // Simple task to copy the static files to the dist directory. The static 34 | // directory will be watched so that files are copied when anything changes. 35 | async function copyFiles() { 36 | const source = `${__dirname}/../static/`; 37 | const dist = `${__dirname}/../dist`; 38 | 39 | await fs.remove(dist); 40 | await fs.ensureDir(dist); 41 | await fs.copy(source, dist); 42 | log.info('📦 Copied static files to dist.'); 43 | } 44 | 45 | async function parcelBuild() { 46 | const publicUrl = process.env.PUBLIC_URL || '/'; 47 | 48 | if (publicUrl && publicUrl !== '/') { 49 | log.warn(`🌍 Building using public URL: ${publicUrl}`); 50 | } else { 51 | log.warn(`🌍 Building without public URL`); 52 | } 53 | 54 | const bundler = new Parcel({ 55 | entries: `${__dirname}/../src/index.html`, 56 | defaultConfig: `${__dirname}/../.parcelrc`, 57 | cacheDir: `${__dirname}/../.parcel-cache`, 58 | mode: 'production', 59 | defaultTargetOptions: { 60 | distDir: `${__dirname}/../dist`, 61 | publicUrl 62 | }, 63 | additionalReporters: [ 64 | { 65 | packageName: '@parcel/reporter-cli', 66 | resolveFrom: __filename 67 | } 68 | ] 69 | }); 70 | 71 | try { 72 | let { bundleGraph, buildTime } = await bundler.run(); 73 | let bundles = bundleGraph.getBundles(); 74 | log.info(`✨ Built ${bundles.length} bundles in ${buildTime}ms!`); 75 | } catch (err) { 76 | log.warn(err.diagnostics); 77 | process.exit(1); 78 | } 79 | } 80 | 81 | copyFiles(); 82 | parcelBuild(); 83 | -------------------------------------------------------------------------------- /packages/client/tasks/check-env-vars.mjs: -------------------------------------------------------------------------------- 1 | /* global process */ 2 | import log from 'fancy-log'; 3 | import dotenv from 'dotenv'; 4 | 5 | /** 6 | * Check if required environment variables are set 7 | * @param {string[]} requiredVars - Array of required environment variable names 8 | * @throws {Error} - If any required variables are missing 9 | */ 10 | export function checkRequiredEnvVars(requiredVars) { 11 | const missingVars = requiredVars.filter((varName) => !process.env[varName]); 12 | 13 | if (missingVars.length > 0) { 14 | log.error('ERROR: Missing required environment variables:'); 15 | missingVars.forEach((v) => log.error(` - ${v}`)); 16 | console.log(); // eslint-disable-line no-console 17 | log.info('Make sure to:'); 18 | log.info('1. Copy .env.example to .env'); 19 | log.info('2. Fill in all required values in .env'); 20 | console.log(); // eslint-disable-line no-console 21 | process.exit(1); 22 | } 23 | } 24 | 25 | /** 26 | * Loads environment variables from `.env` files based on the current 27 | * `NODE_ENV`. 28 | * 29 | * The function determines the appropriate `.env` files to load in the following 30 | * order: 31 | * 1. `.env` - Always included. 32 | * 2. `.env.local` - Included unless the `NODE_ENV` is `test`. 33 | * 3. `.env.` - Included based on the current `NODE_ENV`. 34 | * 4. `.env..local` - Included based on the current `NODE_ENV`. 35 | * 36 | * Files are loaded in the order specified above, and later files override 37 | * variables from earlier ones. The `.env.local` file is skipped for the `test` 38 | * environment to ensure consistent test results across different environments. 39 | */ 40 | export function loadEnvironmentVariables() { 41 | const dotenvFiles = [ 42 | '.env', 43 | // Don't include `.env.local` for `test` environment 44 | // since normally you expect tests to produce the same 45 | // results for everyone 46 | process.env.NODE_ENV === 'test' ? null : '.env.local', 47 | `.env.${process.env.NODE_ENV}`, 48 | `.env.${process.env.NODE_ENV}.local` 49 | ].filter(Boolean); 50 | 51 | dotenvFiles.forEach((dotenvFile) => { 52 | dotenv.config({ path: dotenvFile }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /packages/client/tasks/server.mjs: -------------------------------------------------------------------------------- 1 | /* global process */ 2 | import path from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import fs from 'fs-extra'; 5 | import Watcher from 'watcher'; 6 | import portscanner from 'portscanner'; 7 | import log from 'fancy-log'; 8 | import { Parcel } from '@parcel/core'; 9 | 10 | import { 11 | checkRequiredEnvVars, 12 | loadEnvironmentVariables 13 | } from './check-env-vars.mjs'; 14 | 15 | const __filename = fileURLToPath(import.meta.url); 16 | const __dirname = path.dirname(__filename); 17 | const __appRoot = path.join(__dirname, '..'); 18 | 19 | loadEnvironmentVariables(); 20 | checkRequiredEnvVars(['REACT_APP_STAC_API']); 21 | 22 | // ///////////////////////////////////////////////////////////////////////////// 23 | // --------------------------- Variables -------------------------------------// 24 | // ---------------------------------------------------------------------------// 25 | 26 | // Environment 27 | process.env.NODE_ENV = process.env.NODE_ENV || 'development'; 28 | 29 | const readPackage = () => 30 | JSON.parse(fs.readFileSync(`${__dirname}/../package.json`)); 31 | 32 | // Set the version in an env variable. 33 | process.env.APP_VERSION = readPackage().version; 34 | process.env.APP_BUILD_TIME = Date.now(); 35 | 36 | async function findPort() { 37 | return new Promise((resolve, reject) => { 38 | portscanner.findAPortNotInUse(9000, 9999, function (error, port) { 39 | return error ? reject(error) : resolve(port); 40 | }); 41 | }); 42 | } 43 | 44 | // Simple task to copy the static files to the dist directory. The static 45 | // directory will be watched so that files are copied when anything changes. 46 | async function copyFiles() { 47 | const source = `${__appRoot}/static/`; 48 | const dist = `${__appRoot}/dist`; 49 | 50 | await fs.ensureDir(dist); 51 | await fs.copy(source, dist); 52 | log.info('📦 Copied static files to dist.'); 53 | 54 | const watcher = new Watcher(source, { 55 | renameDetection: true, 56 | ignoreInitial: true 57 | }); 58 | 59 | watcher.on('all', (event, targetPath) => { 60 | log.info(`📦 ${event} ${targetPath}`); 61 | fs.copy(source, dist); 62 | }); 63 | } 64 | 65 | // Parcel development server 66 | async function parcelServe() { 67 | const port = await findPort(); 68 | 69 | if (port !== 9000) { 70 | log.warn(` Port 9000 is busy. Using port ${port} instead.`); 71 | } 72 | 73 | const bundler = new Parcel({ 74 | entries: `${__appRoot}/src/index.html`, 75 | defaultConfig: `${__appRoot}/.parcelrc`, 76 | cacheDir: `${__appRoot}/.parcel-cache`, 77 | defaultTargetOptions: { 78 | distDir: `${__appRoot}/dist` 79 | }, 80 | shouldAutoInstall: false, 81 | additionalReporters: [ 82 | { 83 | packageName: '@parcel/reporter-cli', 84 | resolveFrom: __appRoot 85 | } 86 | ], 87 | serveOptions: { 88 | port 89 | }, 90 | hmrOptions: { 91 | port 92 | } 93 | }); 94 | 95 | await bundler.watch((err, event) => { 96 | if (err) { 97 | // fatal error 98 | throw err; 99 | } 100 | 101 | if (event.type === 'buildSuccess') { 102 | let bundles = event.bundleGraph.getBundles(); 103 | log.info(`✨ Built ${bundles.length} bundles in ${event.buildTime}ms!`); 104 | } else if (event.type === 'buildFailure') { 105 | log.warn(event.diagnostics); 106 | } 107 | }); 108 | } 109 | 110 | copyFiles(); 111 | parcelServe(); 112 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "strictNullChecks": true, 5 | "baseUrl": "./", 6 | "jsx": "react", 7 | "downlevelIteration": true, 8 | "paths": { 9 | /* Specify a set of entries that re-map imports to additional lookup locations. */ 10 | "$components/*": ["./src/components/*"], 11 | "$utils/*": ["./src/utils/*"], 12 | "$styles/*": ["./src/styles/*"], 13 | "$hooks/*": ["./src/hooks/*"], 14 | "$pages/*": ["./src/pages/*"] 15 | }, 16 | // https://www.credera.com/en-us/insights/typescript-adding-custom-type-definitions-for-existing-libraries/ 17 | "typeRoots": ["src/_custom-types", "node_modules/@types", "../../node_modules/@types"] 18 | }, 19 | "exclude": [ 20 | ".git", 21 | ".app-cache", 22 | ".parcel-cache", 23 | "parcel-bundle-reports", 24 | ".npm", 25 | ".npm-tmp", 26 | "dist", 27 | "dist*", 28 | "node_modules", 29 | "**/node_modules/*" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /packages/data-core/README.md: -------------------------------------------------------------------------------- 1 | # @stac-manager/data-core 2 | 3 | ## Introduction 4 | Core functionality of the form builder plugin system. 5 | 6 | ## Installation and Usage 7 | See root README.md for instructions on how to install and use the project. 8 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/__snapshots__/plugin-box.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PluginBox renders ErrorBox when editSchema is null 1`] = ` 4 |
8 | Plugin 9 | 12 | TestPlugin 13 | 14 | has no edit schema. 15 |
16 | `; 17 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/error-box.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Flex, FlexProps, forwardRef } from '@chakra-ui/react'; 3 | 4 | export const ErrorBox = forwardRef((props, ref) => ( 5 | 20 | )); 21 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/plugin-box.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { Formik } from 'formik'; 4 | import { ChakraProvider } from '@chakra-ui/react'; 5 | 6 | import { PluginBox } from './plugin-box'; 7 | import { Plugin } from '../plugin-utils/plugin'; 8 | 9 | const mockPlugin = { 10 | name: 'TestPlugin', 11 | editSchema: jest.fn() 12 | }; 13 | 14 | // Custom renderer to add context or providers if needed 15 | const renderWithProviders = ( 16 | ui: React.ReactNode, 17 | { renderOptions = {} } = {} 18 | ) => { 19 | // You can wrap the component with any context providers here 20 | return render(ui, { 21 | wrapper: ({ children }) => ( 22 | 23 | {}}> 24 | {children} 25 | 26 | 27 | ), 28 | ...renderOptions 29 | }); 30 | }; 31 | 32 | describe('PluginBox', () => { 33 | it('renders ErrorBox when editSchema is null', () => { 34 | mockPlugin.editSchema.mockReturnValue(null); 35 | 36 | const { getByTestId } = renderWithProviders( 37 | {}}> 38 | 39 | {({ field }) =>
{field.label}
} 40 |
41 |
42 | ); 43 | 44 | expect(getByTestId('plugin-box-error')).toMatchSnapshot(); 45 | }); 46 | 47 | it('renders nothing when editSchema is Plugin.HIDDEN', () => { 48 | mockPlugin.editSchema.mockReturnValue(Plugin.HIDDEN); 49 | 50 | const { container } = render( 51 | {}}> 52 | 53 | {({ field }) =>
{field.label}
} 54 |
55 |
56 | ); 57 | 58 | expect(container.firstChild).toBeNull(); 59 | }); 60 | 61 | it('renders children when editSchema is valid', () => { 62 | const mockSchema = { type: 'string', label: 'testField' }; 63 | mockPlugin.editSchema.mockReturnValue(mockSchema); 64 | 65 | const { getByText } = renderWithProviders( 66 | {}}> 67 | 68 | {({ field }) =>
{field.label}
} 69 |
70 |
71 | ); 72 | 73 | expect(getByText(/testField/i)).toBeInTheDocument(); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/plugin-box.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Code } from '@chakra-ui/react'; 3 | import { useFormikContext } from 'formik'; 4 | 5 | import { PluginProvider } from '../context/plugin'; 6 | import { Plugin } from '../plugin-utils/plugin'; 7 | import { ErrorBox } from './error-box'; 8 | import { SchemaField } from '../schema/types'; 9 | 10 | interface PluginBoxProps { 11 | plugin: Plugin; 12 | children: ({ field }: { field: SchemaField }) => JSX.Element; 13 | } 14 | 15 | /** 16 | * Prepares the plugin schema and sets up the plugin context. 17 | * Provides the plugin schema to the children function. 18 | * 19 | * @param props.plugin The plugin to render 20 | * @param props.children The children function to render the plugin schema. 21 | */ 22 | export function PluginBox(props: PluginBoxProps) { 23 | const { plugin, children } = props; 24 | 25 | const { values } = useFormikContext(); 26 | const editSchema = plugin.editSchema(values); 27 | 28 | if (!editSchema) { 29 | return ( 30 | 31 | Plugin {plugin.name} has no edit schema. 32 | 33 | ); 34 | } 35 | 36 | if (editSchema === Plugin.HIDDEN) { 37 | return null; 38 | } 39 | 40 | return ( 41 | 42 | {children({ field: editSchema as SchemaField })} 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/types.ts: -------------------------------------------------------------------------------- 1 | import { SchemaField } from '../schema/types'; 2 | 3 | export type WidgetComponent = React.FunctionComponent; 4 | 5 | export interface WidgetProps { 6 | pointer: string; 7 | field: SchemaField; 8 | isRequired?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/widget-renderer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { ChakraProvider } from '@chakra-ui/react'; 4 | 5 | import { WidgetRenderer } from './widget-renderer'; 6 | import { usePluginConfig } from '../context/plugin-config'; 7 | import { SchemaField } from '../schema/types'; 8 | 9 | jest.mock('../context/plugin-config', () => ({ 10 | usePluginConfig: jest.fn() 11 | })); 12 | 13 | const mockPluginConfig = { 14 | 'ui:widget': { 15 | text: ({ pointer }: { pointer: string }) => ( 16 |
Text Widget: {pointer}
17 | ), 18 | number: ({ pointer }: { pointer: string }) => ( 19 |
Number Widget: {pointer}
20 | ), 21 | radio: ({ pointer }: { pointer: string }) => ( 22 |
Radio Widget: {pointer}
23 | ), 24 | broken: () => { 25 | throw new Error('Widget failed'); 26 | } 27 | } 28 | }; 29 | 30 | describe('WidgetRenderer', () => { 31 | beforeEach(() => { 32 | (usePluginConfig as jest.Mock).mockReturnValue(mockPluginConfig); 33 | }); 34 | 35 | it('renders a text widget', () => { 36 | const field: SchemaField = { type: 'string' }; 37 | render(); 38 | expect(screen.getByText('Text Widget: test.pointer')).toBeInTheDocument(); 39 | }); 40 | 41 | it('renders a number widget', () => { 42 | const field: SchemaField = { type: 'number' }; 43 | render(); 44 | expect(screen.getByText('Number Widget: test.pointer')).toBeInTheDocument(); 45 | }); 46 | 47 | it('renders a radio widget for enum strings', () => { 48 | const field: SchemaField = { 49 | type: 'string', 50 | enum: [ 51 | ['option1', 'Option 1'], 52 | ['option2', 'Option 2'] 53 | ] 54 | }; 55 | render(); 56 | expect(screen.getByText('Radio Widget: test.pointer')).toBeInTheDocument(); 57 | }); 58 | 59 | it('renders an error box when widget is not found', () => { 60 | const field: SchemaField = { type: 'string', 'ui:widget': 'custom' }; 61 | render( 62 | 63 | 64 | 65 | ); 66 | expect(screen.getByText('Widget "custom" not found')).toBeInTheDocument(); 67 | }); 68 | 69 | it('renders error boundary when widget throws an error', () => { 70 | // The test will pass but there will be some noise in the output: 71 | // Error: Uncaught [Error: Widget failed] 72 | // So we need to spyOn the console error: 73 | jest.spyOn(console, 'error').mockImplementation(() => null); 74 | 75 | const field: SchemaField = { type: 'string', 'ui:widget': 'broken' }; 76 | render( 77 | 78 | 79 | 80 | ); 81 | expect( 82 | screen.getByText('💔 Error rendering widget (broken)') 83 | ).toBeInTheDocument(); 84 | expect(screen.getByText('Widget failed')).toBeInTheDocument(); 85 | 86 | // Restore the original console.error to avoid affecting other tests. 87 | jest.spyOn(console, 'error').mockRestore(); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/data-core/lib/components/widget-renderer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Text } from '@chakra-ui/react'; 3 | 4 | import { usePluginConfig } from '../context/plugin-config'; 5 | import { ErrorBox } from './error-box'; 6 | import { SchemaField } from '../schema/types'; 7 | 8 | interface WidgetProps { 9 | pointer: string; 10 | field: SchemaField; 11 | isRequired?: boolean; 12 | } 13 | 14 | /** 15 | * Renders the widget for a field. 16 | * @param props.pointer The path to the field in the form data 17 | * @param props.field The field schema to render 18 | * @param props.isRequired Whether the field is required 19 | */ 20 | export function WidgetRenderer(props: WidgetProps) { 21 | const { pointer, field, isRequired } = props; 22 | 23 | const config = usePluginConfig(); 24 | 25 | const renderWidget = (widget: string) => { 26 | const Widget = config['ui:widget'][widget]; 27 | 28 | return ( 29 | 30 | {Widget ? ( 31 | 32 | ) : ( 33 | Widget "{widget}" not found 34 | )} 35 | 36 | ); 37 | }; 38 | 39 | // Explicitly defined widget 40 | if (field['ui:widget']) { 41 | return renderWidget(field['ui:widget']); 42 | } 43 | 44 | if (field.type === 'array') { 45 | if (field.items.type === 'string' && field.items.enum) { 46 | return renderWidget('checkbox'); 47 | } 48 | 49 | if (['string', 'number'].includes(field.items.type)) { 50 | return renderWidget('array:string'); 51 | } 52 | 53 | return renderWidget('array'); 54 | } 55 | 56 | if (field.type === 'root' || field.type === 'object') { 57 | return renderWidget('object'); 58 | } 59 | 60 | if (field.type === 'string' && field.enum) { 61 | return renderWidget('radio'); 62 | } 63 | 64 | if (field.type === 'json') { 65 | return renderWidget('json'); 66 | } 67 | 68 | if (field.type === 'number') { 69 | return renderWidget('number'); 70 | } 71 | 72 | return renderWidget('text'); 73 | } 74 | 75 | interface WidgetErrorBoundaryProps { 76 | children: React.ReactNode; 77 | field: SchemaField; 78 | widgetName: string; 79 | pointer: string; 80 | } 81 | 82 | interface WidgetErrorBoundaryState { 83 | error: Error | null; 84 | } 85 | 86 | class WidgetErrorBoundary extends React.Component< 87 | WidgetErrorBoundaryProps, 88 | WidgetErrorBoundaryState 89 | > { 90 | constructor(props: WidgetErrorBoundaryProps) { 91 | super(props); 92 | this.state = { error: null }; 93 | } 94 | 95 | static getDerivedStateFromError(error: Error): WidgetErrorBoundaryState { 96 | return { error }; 97 | } 98 | 99 | render() { 100 | if (this.state.error) { 101 | return ( 102 | 103 | 104 | 💔 Error rendering widget ({this.props.widgetName}) 105 | 106 | 107 | {this.state.error.message || 'Something is wrong with this widget'} 108 | 109 | 118 | @.{this.props.pointer}
119 | {JSON.stringify(this.props.field, null, 2)} 120 |
121 |
122 | ); 123 | } 124 | 125 | return this.props.children; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /packages/data-core/lib/config/config.test.ts: -------------------------------------------------------------------------------- 1 | import { extendPluginConfig } from './index'; 2 | import { PluginConfig } from './index'; 3 | 4 | describe('extendPluginConfig', () => { 5 | it('should merge multiple configurations', () => { 6 | const config1: Partial = { 7 | collectionPlugins: [{ name: 'plugin1' } as any], 8 | itemPlugins: [{ name: 'itemPlugin1' } as any], 9 | 'ui:widget': { widget1: () => null } 10 | }; 11 | 12 | const config2: Partial = { 13 | collectionPlugins: [{ name: 'plugin2' } as any], 14 | 'ui:widget': { widget2: () => null } 15 | }; 16 | 17 | const result = extendPluginConfig(config1, config2); 18 | 19 | expect(result).toEqual({ 20 | collectionPlugins: [{ name: 'plugin1' }, { name: 'plugin2' }], 21 | itemPlugins: [{ name: 'itemPlugin1' }], 22 | 'ui:widget': { 23 | widget1: expect.any(Function), 24 | widget2: expect.any(Function) 25 | } 26 | }); 27 | }); 28 | 29 | it('should handle empty configurations', () => { 30 | const result = extendPluginConfig(); 31 | expect(result).toEqual({ 32 | collectionPlugins: [], 33 | itemPlugins: [], 34 | 'ui:widget': {} 35 | }); 36 | }); 37 | 38 | it('should override properties with later configurations', () => { 39 | const config1: Partial = { 40 | 'ui:widget': { widget1: () => 'old' } 41 | }; 42 | 43 | const config2: Partial = { 44 | 'ui:widget': { widget1: () => 'new' } 45 | }; 46 | 47 | const result = extendPluginConfig(config1, config2); 48 | 49 | expect(result['ui:widget'].widget1({} as any)).toBe('new'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/data-core/lib/config/index.ts: -------------------------------------------------------------------------------- 1 | import { WidgetComponent } from '../components/types'; 2 | import { PluginConfigItem } from '../plugin-utils/plugin'; 3 | 4 | export interface PluginConfig { 5 | collectionPlugins: PluginConfigItem[]; 6 | itemPlugins: PluginConfigItem[]; 7 | 'ui:widget': Record; 8 | } 9 | 10 | const configStructure: PluginConfig = { 11 | collectionPlugins: [], 12 | itemPlugins: [], 13 | 'ui:widget': {} 14 | }; 15 | 16 | /** 17 | * Successively merges multiple configurations into one. 18 | * The last configuration will override the previous ones. 19 | * The resulting configuration is to be used by the PluginConfigProvider. 20 | * 21 | * @param ...config - The configurations to merge 22 | * @returns The configuration object 23 | */ 24 | export function extendPluginConfig(...config: Partial[]) { 25 | return config.reduce((acc, c) => { 26 | const { 27 | collectionPlugins = [], 28 | itemPlugins = [], 29 | 'ui:widget': uiWidget = {} 30 | } = c; 31 | 32 | return { 33 | collectionPlugins: [...acc.collectionPlugins, ...collectionPlugins], 34 | itemPlugins: [...acc.itemPlugins, ...itemPlugins], 35 | 'ui:widget': { 36 | ...acc['ui:widget'], 37 | ...uiWidget 38 | } 39 | }; 40 | }, configStructure); 41 | } 42 | -------------------------------------------------------------------------------- /packages/data-core/lib/context/plugin-config.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | import { PluginConfig } from '../config'; 4 | 5 | interface PluginConfigContextProps { 6 | config: PluginConfig; 7 | } 8 | 9 | // Create context 10 | const PluginConfigContext = createContext( 11 | null 12 | ); 13 | 14 | /** 15 | * Global context provider for the configuration 16 | * 17 | * @param props.config Configuration object 18 | * @param props.children Child components 19 | */ 20 | export function PluginConfigProvider(props: { 21 | config: PluginConfig; 22 | children: React.ReactNode; 23 | }) { 24 | const { config, children } = props; 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | } 31 | 32 | export const usePluginConfig = () => { 33 | const context = useContext(PluginConfigContext); 34 | 35 | if (!context) { 36 | throw new Error( 37 | 'usePluginConfig must be used within a PluginConfigProvider' 38 | ); 39 | } 40 | 41 | return context.config; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/data-core/lib/context/plugin.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from 'react'; 2 | 3 | import { Plugin } from '../plugin-utils/plugin'; 4 | 5 | // Plugin context provider 6 | const PluginContext = createContext(undefined); 7 | 8 | /** 9 | * Context provider for each plugin configuration 10 | * 11 | * @param props.config Plugin configuration object 12 | * @param props.children Child components 13 | */ 14 | export const PluginProvider = (props: { 15 | plugin: Plugin; 16 | children: React.ReactNode; 17 | }) => { 18 | return ( 19 | 20 | {props.children} 21 | 22 | ); 23 | }; 24 | 25 | export const usePlugin = () => { 26 | const plugin = useContext(PluginContext); 27 | 28 | if (!plugin) { 29 | throw new Error('usePlugin must be used within a PluginProvider'); 30 | } 31 | 32 | return plugin; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/data-core/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './plugin-utils/plugin'; 2 | export * from './plugin-utils/validate'; 3 | export * from './plugin-utils/scroll-to-invalid'; 4 | export * from './plugin-utils/use-plugins-hook'; 5 | export * from './context/plugin-config'; 6 | export * from './config'; 7 | export * from './components/plugin-box'; 8 | export * from './components/error-box'; 9 | export * from './components/widget-renderer'; 10 | 11 | export * from './schema/types'; 12 | export * from './schema'; 13 | export * from './components/types'; 14 | -------------------------------------------------------------------------------- /packages/data-core/lib/plugin-utils/plugin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */ 2 | 3 | import { SchemaField, SchemaFieldObject } from '../schema/types'; 4 | 5 | export type PluginEditSchema = 6 | | (SchemaFieldObject & { type: 'root' }) 7 | | undefined 8 | | symbol; 9 | 10 | export interface PluginHook { 11 | // Name of the target plugin. 12 | name: string; 13 | 14 | // The `onAfterInit` hook is executed after the target plugin's `init` 15 | // function. 16 | onAfterInit?: (pluginInstance: Plugin, data: any) => void; 17 | 18 | // The `onAfterEditSchema` hook is composed with the target plugin's 19 | // `editSchema` function. 20 | onAfterEditSchema?: ( 21 | pluginInstance: Plugin, 22 | formData: any, 23 | schema: PluginEditSchema 24 | ) => PluginEditSchema; 25 | } 26 | 27 | const HIDDEN: unique symbol = Symbol('hidden'); 28 | const HOOKS: unique symbol = Symbol('hooks'); 29 | 30 | export abstract class Plugin { 31 | static readonly HIDDEN: typeof HIDDEN = HIDDEN; 32 | static readonly HOOKS: typeof HOOKS = HOOKS; 33 | 34 | [HOOKS]: PluginHook[] = []; 35 | 36 | name: string = 'Plugin'; 37 | 38 | init(data: any) {} 39 | 40 | editSchema(formData?: any): PluginEditSchema { 41 | return undefined; 42 | } 43 | 44 | enterData(data: any): Record { 45 | throw new Error(`Plugin [${this.name}] must implement enterData`); 46 | } 47 | 48 | exitData(data: any): Record { 49 | throw new Error(`Plugin [${this.name}] must implement exitData`); 50 | } 51 | 52 | /** 53 | * Registers a hook to be applied on a given plugin. 54 | * 55 | * @param targetName - The name of the target plugin to which the hook will be 56 | * applied. 57 | * @param hookName - The name of the hook. 58 | * @param hook - The hook function. 59 | */ 60 | registerHook>( 61 | targetName: string, 62 | hookName: K, 63 | hook: PluginHook[K] 64 | ) { 65 | const hookEntry = this[Plugin.HOOKS].find((h) => h.name === targetName); 66 | 67 | if (hookEntry) { 68 | hookEntry[hookName] = hook; 69 | } else { 70 | this[Plugin.HOOKS].push({ name: targetName, [hookName]: hook }); 71 | } 72 | } 73 | } 74 | 75 | export type PluginConfigResolved = Plugin | Plugin[] | undefined | null; 76 | 77 | export type PluginConfigResolver = (data: any) => PluginConfigResolved; 78 | 79 | export type PluginConfigItem = PluginConfigResolved | PluginConfigResolver; 80 | -------------------------------------------------------------------------------- /packages/data-core/lib/plugin-utils/resolve.test.ts: -------------------------------------------------------------------------------- 1 | import { resolvePlugins, applyHooks } from './resolve'; 2 | import { Plugin } from './plugin'; 3 | 4 | class MockPlugin extends Plugin { 5 | name = 'MockPlugin'; 6 | init = jest.fn(); 7 | editSchema = jest.fn(); 8 | } 9 | 10 | describe('resolvePlugins', () => { 11 | it('should resolve an array of Plugin instances', () => { 12 | const plugin = new MockPlugin(); 13 | const result = resolvePlugins([plugin], {}); 14 | expect(result).toContainEqual(plugin); 15 | }); 16 | 17 | it('should resolve functions returning Plugin instances', () => { 18 | const plugin = new MockPlugin(); 19 | const result = resolvePlugins([() => plugin], {}); 20 | expect(result).toContainEqual(plugin); 21 | }); 22 | 23 | it('should filter out invalid items', () => { 24 | const plugin = new MockPlugin(); 25 | const result = resolvePlugins([plugin, null, undefined], {}); 26 | expect(result).toContainEqual(plugin); 27 | expect(result.length).toBe(1); 28 | }); 29 | }); 30 | 31 | describe('applyHooks', () => { 32 | it('should apply onAfterInit hooks', async () => { 33 | const targetPlugin = new MockPlugin(); 34 | const sourcePlugin = new MockPlugin(); 35 | sourcePlugin[Plugin.HOOKS] = [ 36 | { 37 | name: targetPlugin.name, 38 | onAfterInit: jest.fn() 39 | } 40 | ]; 41 | 42 | // applyHooks creates a copy, so we need the plugins back. 43 | const [newTargetPl, newSourcePl] = applyHooks([targetPlugin, sourcePlugin]); 44 | await newTargetPl.init({}); 45 | expect(newSourcePl[Plugin.HOOKS][0].onAfterInit).toHaveBeenCalled(); 46 | }); 47 | 48 | it('should compose onAfterEditSchema hooks', () => { 49 | const targetPlugin = new MockPlugin(); 50 | const sourcePlugin = new MockPlugin(); 51 | sourcePlugin[Plugin.HOOKS] = [ 52 | { 53 | name: targetPlugin.name, 54 | onAfterEditSchema: jest.fn((_, __, origEditSchema) => origEditSchema) 55 | } 56 | ]; 57 | 58 | // applyHooks creates a copy, so we need the plugins back. 59 | const [newTargetPl, newSourcePl] = applyHooks([targetPlugin, sourcePlugin]); 60 | newTargetPl.editSchema({}); 61 | expect(newSourcePl[Plugin.HOOKS][0].onAfterEditSchema).toHaveBeenCalled(); 62 | }); 63 | 64 | it('should ignore hooks targeting non-existent plugins', () => { 65 | const sourcePlugin = new MockPlugin(); 66 | sourcePlugin[Plugin.HOOKS] = [ 67 | { 68 | name: 'NonExistentPlugin', 69 | onAfterInit: jest.fn() 70 | } 71 | ]; 72 | 73 | const plugins = applyHooks([sourcePlugin]); 74 | expect(plugins).toContainEqual(sourcePlugin); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /packages/data-core/lib/plugin-utils/resolve.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash-es'; 2 | import { Plugin, PluginConfigItem } from './plugin'; 3 | 4 | /** 5 | * Resolves the plugin config. 6 | * 7 | * @param plugins - An array of plugin configuration items which can be 8 | * instances of `Plugin` or functions that return a `Plugin`. 9 | * @param data - The data to be passed to plugin functions if they are not 10 | * instances of `Plugin`. 11 | * @returns An array of processed `Plugin` instances after applying hooks. 12 | */ 13 | export const resolvePlugins = (plugins: PluginConfigItem[], data: any) => { 14 | const p = plugins 15 | .flatMap((pl) => { 16 | if (pl instanceof Plugin) { 17 | return pl; 18 | } else if (typeof pl === 'function') { 19 | return pl(data); 20 | } 21 | return; 22 | }) 23 | .filter((p) => p instanceof Plugin); 24 | 25 | return applyHooks(p); 26 | }; 27 | 28 | /** 29 | * Applies hooks from the provided plugins to their respective targets. 30 | * 31 | * This function iterates over each plugin and applies hooks such as 32 | * `onAfterInit` and `onAfterEditSchema` to the corresponding target plugins. 33 | * The hooks are executed in the context of the source plugin. 34 | * 35 | * @param plugins - List of plugins. All the source and target plugins must be 36 | * on the list. 37 | * 38 | * 39 | * @remarks 40 | * - The `onAfterInit` hook is executed after the target plugin's `init` 41 | * function. 42 | * - The `onAfterEditSchema` hook is composed with the target plugin's 43 | * `editSchema` function. 44 | * 45 | * @example 46 | * ```typescript 47 | * class MyPlugin extends Plugin { 48 | * name = 'MyPlugin'; 49 | * 50 | * [Plugin.HOOKS]: [ 51 | * { 52 | * name: 'pluginA', // Target plugin 53 | * onAfterInit: async (targetInstance, data) => { }, // Executes after pluginA's init function. 54 | * onAfterEditSchema: (targetInstance, formData, origEditSchema) => { } // Composes with pluginA's editSchema function and returns a new one. 55 | * }, 56 | * { 57 | * name: 'pluginB', // Target plugin 58 | * onAfterInit: async (targetInstance, data) => { }, // Executes after pluginB's init function. 59 | * } 60 | * ]; 61 | * } 62 | * 63 | * applyHooks(plugins); 64 | * ``` 65 | */ 66 | export const applyHooks = (plugins: Plugin[]) => { 67 | const pluginsCopy = cloneDeep(plugins); 68 | 69 | for (const plSource of pluginsCopy) { 70 | for (const hook of plSource[Plugin.HOOKS]) { 71 | // Target where to apply the hook 72 | const plTarget = pluginsCopy.find((p) => p.name === hook.name); 73 | if (!plTarget) { 74 | continue; 75 | } 76 | 77 | // The onAfterInit hook is made by executing one function after another. 78 | if (hook.onAfterInit) { 79 | const fn = hook.onAfterInit; 80 | const origInit = plTarget.init; 81 | plTarget.init = async (data: any) => { 82 | await origInit.call(plTarget, data); 83 | await fn.call(plSource, plTarget, data); 84 | }; 85 | } 86 | 87 | // The onAfterEditSchema hook is made by composing functions. 88 | if (hook.onAfterEditSchema) { 89 | const fn = hook.onAfterEditSchema; 90 | const origEditSchema = plTarget.editSchema; 91 | plTarget.editSchema = (formData?: any) => { 92 | return fn.call( 93 | plSource, 94 | plTarget, 95 | formData, 96 | origEditSchema.call(plTarget, formData) 97 | ); 98 | }; 99 | } 100 | } 101 | } 102 | 103 | return pluginsCopy; 104 | }; 105 | -------------------------------------------------------------------------------- /packages/data-core/lib/plugin-utils/scroll-to-invalid.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useFormikContext } from 'formik'; 3 | import { getFirstPath } from './validate'; 4 | 5 | /** 6 | * Scrolls to the first invalid field according to the formik errors. 7 | */ 8 | export function ScrollToInvalidField() { 9 | const { errors, isSubmitting, isValidating } = useFormikContext(); 10 | 11 | useEffect(() => { 12 | // Run on submission after validation took place. 13 | if (!errors || !isSubmitting || isValidating) return; 14 | 15 | const path = getFirstPath(errors); 16 | // Use the parent node because if the element is a hidden input, it won't 17 | // scroll. 18 | const element = document.querySelector(`[name="${path}"]`) 19 | ?.parentNode as HTMLElement; 20 | element?.scrollIntoView({ block: 'center', behavior: 'smooth' }); 21 | }, [errors, isSubmitting, isValidating]); 22 | 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /packages/data-core/lib/plugin-utils/use-plugins-hook.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { defaultsDeep } from 'lodash-es'; 3 | 4 | import { Plugin, PluginConfigItem } from './plugin'; 5 | import { resolvePlugins } from './resolve'; 6 | import { usePluginConfig } from '../context/plugin-config'; 7 | import { schemaToFormDataStructure, FormDataStructure } from '../schema'; 8 | 9 | type UsePluginsHook = 10 | | { 11 | isLoading: true; 12 | plugins: undefined; 13 | formData: undefined; 14 | toOutData: (formData: any) => undefined; 15 | } 16 | | { 17 | isLoading: false; 18 | plugins: Plugin[]; 19 | formData: any; 20 | toOutData: (formData: any) => any; 21 | }; 22 | 23 | type FormDataStructureObject = { 24 | [key: string]: FormDataStructure; 25 | }; 26 | 27 | const usePlugins = (plugins: PluginConfigItem[], data: any): UsePluginsHook => { 28 | const [readyPlugins, setReadyPlugins] = useState(); 29 | 30 | useEffect(() => { 31 | async function load() { 32 | // if (!data) return; 33 | 34 | const resolvedPlugins = resolvePlugins(plugins, data); 35 | await Promise.all(resolvedPlugins.map((pl) => pl.init(data))); 36 | setReadyPlugins(resolvedPlugins); 37 | } 38 | load(); 39 | }, [plugins, data]); 40 | 41 | const formData = useMemo(() => { 42 | if (!readyPlugins) return; 43 | 44 | const emptyStructure = readyPlugins.reduce( 45 | (acc, pl: Plugin) => { 46 | const schema = pl.editSchema(); 47 | if (!schema || typeof schema === 'symbol') return acc; 48 | 49 | return defaultsDeep( 50 | schemaToFormDataStructure(schema) as FormDataStructureObject, 51 | acc 52 | ); 53 | }, 54 | {} 55 | ); 56 | 57 | return readyPlugins.reduce( 58 | (acc: any, pl: Plugin) => defaultsDeep(pl.enterData(data), acc), 59 | emptyStructure 60 | ); 61 | }, [readyPlugins]); 62 | 63 | const toOutData = useCallback( 64 | (formData: any) => 65 | readyPlugins && 66 | readyPlugins.reduce( 67 | (acc: any, pl: Plugin) => ({ 68 | ...acc, 69 | ...pl.exitData(formData) 70 | }), 71 | {} 72 | ), 73 | [readyPlugins] 74 | ); 75 | 76 | if (!readyPlugins) { 77 | return { 78 | isLoading: true, 79 | plugins: undefined, 80 | formData: undefined, 81 | /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ 82 | toOutData: (formData: any) => undefined 83 | }; 84 | } 85 | 86 | return { 87 | isLoading: false, 88 | plugins: readyPlugins, 89 | formData, 90 | toOutData 91 | }; 92 | }; 93 | 94 | export function useCollectionPlugins(data: any) { 95 | const config = usePluginConfig(); 96 | 97 | return usePlugins(config.collectionPlugins, data); 98 | } 99 | 100 | export function useItemPlugins(data: any) { 101 | const config = usePluginConfig(); 102 | 103 | return usePlugins(config.itemPlugins, data); 104 | } 105 | -------------------------------------------------------------------------------- /packages/data-core/lib/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { SchemaField, SchemaFieldObject } from './types'; 2 | 3 | export type FormDataStructure = 4 | | string 5 | | { [key: string]: FormDataStructure } 6 | | FormDataStructure[] 7 | | string[]; 8 | 9 | export function schemaToFormDataStructure( 10 | field: SchemaField 11 | ): FormDataStructure { 12 | if (['root', 'object'].includes(field.type)) { 13 | return Object.entries((field as SchemaFieldObject).properties).reduce<{ 14 | [key: string]: FormDataStructure; 15 | }>((acc, [key, value]) => { 16 | return { 17 | ...acc, 18 | [key]: schemaToFormDataStructure(value) 19 | }; 20 | }, {}); 21 | } 22 | 23 | if (field.type === 'array') { 24 | if ((field.minItems || 0) > 0) { 25 | return Array.from({ length: field.minItems as number }).map(() => 26 | schemaToFormDataStructure(field.items) 27 | ); 28 | } 29 | return []; 30 | } 31 | 32 | if (field.type === 'json') { 33 | return {}; 34 | } 35 | 36 | return ''; 37 | } 38 | -------------------------------------------------------------------------------- /packages/data-core/lib/schema/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { schemaToFormDataStructure } from './index'; 2 | import { SchemaField } from './types'; 3 | 4 | describe('schemaToFormDataStructure', () => { 5 | it('should handle root/object type fields', () => { 6 | const schema: SchemaField = { 7 | type: 'object', 8 | properties: { 9 | name: { type: 'string' }, 10 | age: { type: 'string' } 11 | } 12 | }; 13 | const result = schemaToFormDataStructure(schema); 14 | expect(result).toEqual({ name: '', age: '' }); 15 | }); 16 | 17 | it('should handle array type fields with minItems', () => { 18 | const schema: SchemaField = { 19 | type: 'array', 20 | minItems: 2, 21 | items: { type: 'string' } 22 | }; 23 | const result = schemaToFormDataStructure(schema); 24 | expect(result).toEqual(['', '']); 25 | }); 26 | 27 | it('should handle array type fields without minItems', () => { 28 | const schema: SchemaField = { 29 | type: 'array', 30 | items: { type: 'string' } 31 | }; 32 | const result = schemaToFormDataStructure(schema); 33 | expect(result).toEqual([]); 34 | }); 35 | 36 | it('should handle json type fields', () => { 37 | const schema: SchemaField = { 38 | type: 'json' 39 | }; 40 | const result = schemaToFormDataStructure(schema); 41 | expect(result).toEqual({}); 42 | }); 43 | 44 | it('should handle default case for unsupported types', () => { 45 | const schema: SchemaField = { 46 | type: 'string' 47 | }; 48 | const result = schemaToFormDataStructure(schema); 49 | expect(result).toEqual(''); 50 | }); 51 | 52 | it('should handle deeply nested schemas', () => { 53 | const schema: SchemaField = { 54 | type: 'root', 55 | properties: { 56 | users: { 57 | type: 'array', 58 | minItems: 1, 59 | items: { 60 | type: 'object', 61 | properties: { 62 | name: { type: 'string' }, 63 | age: { type: 'string' }, 64 | accounts: { 65 | type: 'array', 66 | items: { 67 | type: 'object', 68 | properties: { 69 | accountName: { type: 'string' }, 70 | balance: { type: 'string' } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | } 78 | }; 79 | const result = schemaToFormDataStructure(schema); 80 | expect(result).toEqual({ 81 | users: [ 82 | { 83 | name: '', 84 | age: '', 85 | accounts: [] 86 | } 87 | ] 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/data-core/lib/schema/types.ts: -------------------------------------------------------------------------------- 1 | interface FieldBase { 2 | type: T; 3 | 'ui:widget'?: string; 4 | label?: string | string[]; 5 | } 6 | 7 | export type SchemaFieldString = 8 | | (FieldBase<'string'> & { enum?: never; allowOther?: never }) 9 | | (FieldBase<'string'> & { 10 | allowOther?: { 11 | type: 'string'; 12 | }; 13 | enum: [string, string][]; 14 | }); 15 | 16 | export type SchemaFieldNumber = FieldBase<'number'>; 17 | 18 | export type SchemaFieldJson = FieldBase<'json'>; 19 | 20 | export interface SchemaFieldArray 21 | extends FieldBase<'array'> { 22 | minItems?: number; 23 | maxItems?: number; 24 | items: I; 25 | } 26 | 27 | export interface SchemaFieldObject extends FieldBase<'object' | 'root'> { 28 | properties: Record; 29 | additionalProperties?: boolean; 30 | required?: string[]; 31 | } 32 | 33 | export type SchemaField = 34 | | SchemaFieldString 35 | | SchemaFieldArray 36 | | SchemaFieldObject 37 | | SchemaFieldNumber 38 | | SchemaFieldJson; 39 | -------------------------------------------------------------------------------- /packages/data-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stac-manager/data-core", 3 | "description": "> TODO: description", 4 | "version": "1.0.0", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "source": "lib/index.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "build": "rollup -c ../../rollup.config.mjs", 12 | "watch": "rollup -c ../../rollup.config.mjs -w", 13 | "clean": "rm -rf ./dist node_modules", 14 | "lint": "eslint ./lib --no-error-on-unmatched-pattern" 15 | }, 16 | "engines": { 17 | "node": ">=20" 18 | }, 19 | "license": "MIT", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "directories": { 24 | "lib": "lib" 25 | }, 26 | "dependencies": { 27 | "@chakra-ui/react": "^2.8.2", 28 | "@emotion/react": "^11.13.3", 29 | "@emotion/styled": "^11.13.0", 30 | "formik": "^2.4.6", 31 | "framer-motion": "^10.16.5", 32 | "lodash-es": "^4.17.21", 33 | "react": "^18.3.1", 34 | "react-dom": "^18.3.1", 35 | "yup": "^1.5.0" 36 | }, 37 | "devDependencies": { 38 | "@types/lodash-es": "^4.17.12", 39 | "@types/node": "^22.10.2", 40 | "@types/react": "^18.3.12", 41 | "@types/react-dom": "^18.3.1" 42 | }, 43 | "peerDependencies": { 44 | "@chakra-ui/react": "^2.8.2", 45 | "@emotion/react": "^11.13.3", 46 | "@emotion/styled": "^11.13.0", 47 | "formik": "^2.4.6", 48 | "framer-motion": "^10.16.5", 49 | "react": "^18.3.1", 50 | "react-dom": "^18.3.1" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/data-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/data-plugins/README.md: -------------------------------------------------------------------------------- 1 | # @stac-manager/data-plugins 2 | 3 | ## Introduction 4 | Data plugins for the forms. Each plugin defines how a section of the data structure is displayed and edited. 5 | 6 | ## Installation and Usage 7 | See root README.md for instructions on how to install and use the project. 8 | -------------------------------------------------------------------------------- /packages/data-plugins/lib/collections/ext-item-assets.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginEditSchema } from '@stac-manager/data-core'; 2 | import { 3 | addStacExtensionOption, 4 | array2Object, 5 | hasStacExtension, 6 | object2Array 7 | } from '../utils'; 8 | 9 | export class PluginItemAssets extends Plugin { 10 | name = 'Item Assets Extension'; 11 | 12 | constructor() { 13 | super(); 14 | 15 | addStacExtensionOption( 16 | this, 17 | 'Item Assets Definition', 18 | 'https://stac-extensions.github.io/item-assets/v1.0.0/schema.json' 19 | ); 20 | } 21 | 22 | editSchema(data: any): PluginEditSchema { 23 | if (!hasStacExtension(data, 'item-assets')) { 24 | return Plugin.HIDDEN; 25 | } 26 | 27 | return { 28 | type: 'root', 29 | properties: { 30 | item_assets: { 31 | type: 'array', 32 | label: 'Item Assets', 33 | minItems: 1, 34 | items: { 35 | type: 'object', 36 | required: ['id'], 37 | properties: { 38 | id: { 39 | label: 'Item Asset ID', 40 | type: 'string' 41 | }, 42 | type: { 43 | label: 'Type', 44 | type: 'string' 45 | }, 46 | title: { 47 | label: 'Title', 48 | type: 'string' 49 | }, 50 | description: { 51 | label: 'Description', 52 | type: 'string' 53 | }, 54 | roles: { 55 | label: 'Roles', 56 | type: 'array', 57 | 'ui:widget': 'tagger', 58 | items: { 59 | label: 'Role', 60 | type: 'string', 61 | enum: [ 62 | ['thumbnail', 'Thumbnail'], 63 | ['overview', 'Overview'], 64 | ['data', 'Data'], 65 | ['metadata', 'Metadata'] 66 | ] 67 | } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | }; 74 | } 75 | 76 | enterData(data: any = {}) { 77 | return { 78 | item_assets: object2Array(data.item_assets, 'id') 79 | }; 80 | } 81 | 82 | exitData(data: any) { 83 | return { 84 | item_assets: array2Object(data.item_assets, 'id') 85 | }; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/data-plugins/lib/collections/meta.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginEditSchema } from '@stac-manager/data-core'; 2 | 3 | export class PluginMeta extends Plugin { 4 | name = 'CollectionsMeta'; 5 | 6 | // async init(data) { 7 | // await new Promise((resolve) => setTimeout(resolve, 100)); 8 | // } 9 | 10 | editSchema(): PluginEditSchema { 11 | return { 12 | type: 'root', 13 | properties: { 14 | id: { 15 | label: 'Collection ID', 16 | type: 'string' 17 | }, 18 | title: { 19 | label: 'Title', 20 | type: 'string' 21 | }, 22 | description: { 23 | label: 'Description', 24 | type: 'string' 25 | }, 26 | license: { 27 | label: 'license', 28 | type: 'string' 29 | }, 30 | spatial: { 31 | label: 'Spatial Extent', 32 | type: 'array', 33 | items: { 34 | type: 'array', 35 | label: 'Extent', 36 | minItems: 4, 37 | maxItems: 4, 38 | items: { 39 | label: [ 40 | 'Min Longitude', 41 | 'Min Latitude', 42 | 'Max Longitude', 43 | 'Max Latitude' 44 | ], 45 | type: 'string' 46 | } 47 | } 48 | }, 49 | temporal: { 50 | label: 'Temporal Extent', 51 | type: 'array', 52 | items: { 53 | label: 'Item', 54 | type: 'string' 55 | } 56 | } 57 | } 58 | }; 59 | } 60 | 61 | enterData({ title, description, id, extent }: any = {}) { 62 | return { 63 | title, 64 | description, 65 | id, 66 | spatial: extent?.spatial.bbox || [], 67 | temporal: extent?.temporal.bbox || [] 68 | }; 69 | } 70 | 71 | exitData(data: any) { 72 | return { 73 | type: 'Collection', 74 | links: [ 75 | { 76 | rel: 'self', 77 | type: 'application/json', 78 | href: 'http://localhost:8081/' 79 | } 80 | ], 81 | id: data.id, 82 | title: data.title, 83 | description: data.description, 84 | license: data.license, 85 | extent: { 86 | spatial: { 87 | bbox: [data.spatial?.map(({ value }: any) => Number(value))] 88 | }, 89 | temporal: { 90 | interval: [data.temporal?.map(({ value }: any) => value)] 91 | } 92 | } 93 | }; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/data-plugins/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { PluginKitchenSink } from './collections/kitchen-sink'; 2 | export { PluginMeta } from './collections/meta'; 3 | export { PluginCore } from './collections/core'; 4 | export { PluginItemAssets } from './collections/ext-item-assets'; 5 | export { PluginRender } from './collections/ext-render'; 6 | -------------------------------------------------------------------------------- /packages/data-plugins/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@stac-manager/data-plugins", 3 | "description": "> TODO: description", 4 | "version": "1.0.0", 5 | "main": "dist/index.cjs.js", 6 | "module": "dist/index.esm.js", 7 | "types": "dist/index.d.ts", 8 | "source": "lib/index.ts", 9 | "sideEffects": false, 10 | "scripts": { 11 | "build": "rollup -c ../../rollup.config.mjs", 12 | "watch": "rollup -c ../../rollup.config.mjs -w", 13 | "clean": "rm -rf ./dist node_modules", 14 | "lint": "eslint ./lib --no-error-on-unmatched-pattern" 15 | }, 16 | "engines": { 17 | "node": ">=20" 18 | }, 19 | "license": "MIT", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "directories": { 24 | "lib": "lib" 25 | }, 26 | "dependencies": { 27 | "@stac-manager/data-core": "*" 28 | }, 29 | "devDependencies": { 30 | "@types/react": "^18.3.12", 31 | "@types/react-dom": "^18.3.1" 32 | }, 33 | "peerDependencies": { 34 | "@stac-manager/data-core": "*" 35 | } 36 | } -------------------------------------------------------------------------------- /packages/data-plugins/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /packages/data-widgets/README.md: -------------------------------------------------------------------------------- 1 | # @stac-manager/data-widgets 2 | 3 | ## Introduction 4 | Form components to be used by the form builder plugin system, when custom ones are not provided. 5 | 6 | ## Installation and Usage 7 | See root README.md for instructions on how to install and use the project. 8 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/components/elements.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | Flex, 5 | forwardRef, 6 | Heading, 7 | IconButton, 8 | Button, 9 | HeadingProps, 10 | IconButtonProps, 11 | FlexProps, 12 | Text 13 | } from '@chakra-ui/react'; 14 | import { 15 | CollecticonTrashBin, 16 | CollecticonPlusSmall 17 | } from '@devseed-ui/collecticons-chakra'; 18 | 19 | export const Fieldset = forwardRef((props, ref) => { 20 | return ( 21 | 31 | ); 32 | }); 33 | 34 | export const FieldsetHeader = forwardRef((props, ref) => { 35 | return ; 36 | }); 37 | 38 | export const FieldsetBody = forwardRef((props, ref) => { 39 | return ; 40 | }); 41 | 42 | export const FieldsetFooter = forwardRef((props, ref) => { 43 | return ; 44 | }); 45 | 46 | export const FieldLabel = forwardRef((props, ref) => { 47 | return ( 48 | 67 | ); 68 | }); 69 | 70 | export const FieldIconBtn = forwardRef( 71 | (props, ref) => { 72 | return ( 73 | 80 | ); 81 | } 82 | ); 83 | 84 | export const FieldsetDeleteBtn = forwardRef( 85 | (props, ref) => { 86 | return ( 87 | } 91 | {...props} 92 | /> 93 | ); 94 | } 95 | ); 96 | 97 | interface ArrayFieldsetProps { 98 | label?: React.ReactNode; 99 | isRequired?: boolean; 100 | children: React.ReactNode; 101 | onRemove?: () => void; 102 | onAdd?: () => void; 103 | addDisabled?: boolean; 104 | removeDisabled?: boolean; 105 | } 106 | 107 | export function ArrayFieldset(props: ArrayFieldsetProps) { 108 | const { 109 | label, 110 | isRequired, 111 | children, 112 | onRemove, 113 | onAdd, 114 | addDisabled, 115 | removeDisabled 116 | } = props; 117 | 118 | return ( 119 |
120 | {(label || onRemove) && ( 121 | 122 | {label && ( 123 | 124 | 125 | {label} 126 | {isRequired && ( 127 | 135 | )} 136 | 137 | 138 | )} 139 | {onRemove && ( 140 | 141 | 146 | 147 | )} 148 | 149 | )} 150 | {children} 151 | {onAdd && ( 152 | 153 | 163 | 164 | )} 165 |
166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/components/icons/indent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createCollecticon } from '@devseed-ui/collecticons-chakra'; 3 | 4 | export const CollecticonIndent = createCollecticon( 5 | (props) => ( 6 | <> 7 | {props.title && {props.title}} 8 | 9 | 10 | ), 11 | { 12 | viewBox: '0 0 512 512' 13 | } 14 | ); 15 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/components/json-jsoneditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { Box } from '@chakra-ui/react'; 3 | import JSONEditor from 'jsoneditor'; 4 | import 'jsoneditor/dist/jsoneditor.css'; 5 | 6 | export default function JsonEditor(props: { 7 | value: any; 8 | onChange: (value: any) => void; 9 | editorRef: React.MutableRefObject; 10 | onLoad?: () => void; 11 | }) { 12 | const { value, onChange, onLoad, editorRef } = props; 13 | const element = useRef(null); 14 | 15 | useEffect(() => { 16 | if (element.current) { 17 | const editor = new JSONEditor( 18 | element.current, 19 | { 20 | mode: 'code', 21 | mainMenuBar: false, 22 | statusBar: false, 23 | onChange: () => { 24 | try { 25 | onChange(editor.get()); 26 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 27 | } catch (error) { 28 | // no-op 29 | } 30 | } 31 | }, 32 | value || '' 33 | ); 34 | 35 | editor.aceEditor.setOptions({ 36 | tooltipFollowsMouse: false 37 | }); 38 | 39 | editorRef.current = editor; 40 | onLoad?.(); 41 | 42 | return () => { 43 | editor.destroy(); 44 | editorRef.current = null; 45 | }; 46 | } 47 | }, []); 48 | 49 | useEffect(() => { 50 | if (editorRef.current && value) { 51 | try { 52 | const currentValue = JSON.stringify(editorRef.current.get()); 53 | const newValue = JSON.stringify(value); 54 | if (currentValue !== newValue) { 55 | editorRef.current.set(value); 56 | } 57 | } catch (error) { 58 | // eslint-disable-next-line no-console 59 | console.error('Invalid incoming JSON value {value, error}', { 60 | value, 61 | error 62 | }); 63 | } 64 | } 65 | }, [value]); 66 | 67 | return ( 68 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/components/object-property.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | inferFieldType, 3 | getFieldSchema, 4 | replaceObjectKeyAt 5 | } from './object-property'; 6 | 7 | describe('Utility Functions', () => { 8 | describe('inferFieldType', () => { 9 | it('infers "number" for numeric values', () => { 10 | expect(inferFieldType(42)).toBe('number'); 11 | }); 12 | 13 | it('infers "string[]" for arrays of strings', () => { 14 | expect(inferFieldType(['a', 'b', 'c'])).toBe('string[]'); 15 | }); 16 | 17 | it('infers "number[]" for arrays of numbers', () => { 18 | expect(inferFieldType([1, 2, 3])).toBe('number[]'); 19 | }); 20 | 21 | it('infers "json" for arrays with mixed types', () => { 22 | expect(inferFieldType([1, 'a', true])).toBe('json'); 23 | }); 24 | 25 | it('infers "json" for objects', () => { 26 | expect(inferFieldType({ key: 'value' })).toBe('json'); 27 | }); 28 | 29 | it('infers "string" for other types', () => { 30 | expect(inferFieldType('hello')).toBe('string'); 31 | }); 32 | }); 33 | 34 | describe('getFieldSchema', () => { 35 | it('returns schema for "string"', () => { 36 | expect(getFieldSchema('string')).toEqual({ 37 | type: 'string', 38 | label: 'Value' 39 | }); 40 | }); 41 | 42 | it('returns schema for "number"', () => { 43 | expect(getFieldSchema('number')).toEqual({ 44 | type: 'number', 45 | label: 'Value' 46 | }); 47 | }); 48 | 49 | it('returns schema for "string[]"', () => { 50 | expect(getFieldSchema('string[]')).toEqual({ 51 | type: 'array', 52 | label: 'Value', 53 | minItems: 1, 54 | items: { type: 'string' } 55 | }); 56 | }); 57 | 58 | it('returns schema for "number[]"', () => { 59 | expect(getFieldSchema('number[]')).toEqual({ 60 | type: 'array', 61 | label: 'Value', 62 | minItems: 1, 63 | items: { type: 'number' } 64 | }); 65 | }); 66 | 67 | it('returns schema for "json"', () => { 68 | expect(getFieldSchema('json')).toEqual({ type: 'json', label: 'Value' }); 69 | }); 70 | 71 | it('returns null for unknown types', () => { 72 | expect(getFieldSchema('unknown' as any)).toBeNull(); 73 | }); 74 | }); 75 | 76 | describe('replaceObjectKeyAt', () => { 77 | it('replaces a key at the root level', () => { 78 | const obj = { oldKey: 'value' }; 79 | const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); 80 | expect(result).toEqual({ newKey: 'value' }); 81 | }); 82 | 83 | it('replaces a key at a nested path', () => { 84 | const obj = { nested: { oldKey: 'value' } }; 85 | const result = replaceObjectKeyAt(obj, 'nested.oldKey', 'newKey'); 86 | expect(result).toEqual({ nested: { newKey: 'value' } }); 87 | }); 88 | 89 | it('preserves other keys in the object', () => { 90 | const obj = { oldKey: 'value', anotherKey: 'anotherValue' }; 91 | const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); 92 | expect(result).toEqual({ newKey: 'value', anotherKey: 'anotherValue' }); 93 | }); 94 | 95 | it('does not mutate the original object', () => { 96 | const obj = { oldKey: 'value' }; 97 | const result = replaceObjectKeyAt(obj, 'oldKey', 'newKey'); 98 | expect(obj).toEqual({ oldKey: 'value' }); 99 | expect(result).not.toBe(obj); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/config/index.ts: -------------------------------------------------------------------------------- 1 | import { extendPluginConfig } from '@stac-manager/data-core'; 2 | 3 | import { WidgetText } from '../widgets/text'; 4 | import { WidgetNumber } from '../widgets/number'; 5 | import { WidgetRadio } from '../widgets/radio'; 6 | import { WidgetCheckbox } from '../widgets/checkbox'; 7 | import { WidgetObject } from '../widgets/object'; 8 | import { WidgetObjectFieldset } from '../widgets/object-fieldset'; 9 | import { WidgetArray } from '../widgets/array'; 10 | import { WidgetArrayInput } from '../widgets/array-input'; 11 | import { WidgetSelect } from '../widgets/select'; 12 | import { WidgetJSON } from '../widgets/json'; 13 | import { WidgetTagger } from '../widgets/tagger'; 14 | 15 | export const defaultPluginWidgetConfig = extendPluginConfig({ 16 | 'ui:widget': { 17 | object: WidgetObject, 18 | 'object:fieldset': WidgetObjectFieldset, 19 | text: WidgetText, 20 | number: WidgetNumber, 21 | radio: WidgetRadio, 22 | checkbox: WidgetCheckbox, 23 | select: WidgetSelect, 24 | tagger: WidgetTagger, 25 | array: WidgetArray, 26 | 'array:string': WidgetArrayInput, 27 | json: WidgetJSON 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './widgets/text'; 2 | export * from './widgets/number'; 3 | export * from './widgets/radio'; 4 | export * from './widgets/checkbox'; 5 | export * from './widgets/object'; 6 | export * from './widgets/select'; 7 | export * from './widgets/array'; 8 | export * from './widgets/array-input'; 9 | export * from './widgets/json'; 10 | 11 | export * from './config'; 12 | export * from './utils'; 13 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/utils/__snapshots__/utils.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getArrayLabel should return Item if label is undefined 1`] = ` 4 | 5 | 8 | Item 9 | 10 | 11 | 14 | 01 15 | 16 | 17 | `; 18 | 19 | exports[`getArrayLabel should return a label with a number suffix for string labels 1`] = ` 20 | 21 | 24 | Label 25 | 26 | 27 | 30 | 10 31 | 32 | 33 | `; 34 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/utils/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Text } from '@chakra-ui/react'; 3 | import { SchemaField } from '@stac-manager/data-core'; 4 | 5 | /** 6 | * Calculates the integer remainder of a division of a by n, handling negative 7 | * modulo in the mathematically expected way. 8 | * 9 | * This is very helpful for cycling array indexes. 10 | * If the current index is the first, the last is returned, and vice-versa. 11 | * 12 | * Given an index if we want to know the previous: 13 | * @example 14 | * const arr = [1, 2, 3]; 15 | * const arrIdx = 0; 16 | * const newIdx = mod(arrIdx - 1, arr.length); // 2 17 | * 18 | * @param {number} a Dividend 19 | * @param {number} n Divisor 20 | */ 21 | export function mod(a: number, n: number) { 22 | return ((a % n) + n) % n; 23 | } 24 | 25 | /** 26 | * Generates a label for an array item based on the provided field and index. If 27 | * the provided label is a single string, the label is returned with a number 28 | * suffix. (Eg: Label 01) 29 | * If the label is an array of strings, the label is cycled based on the index. 30 | * 31 | * @param {SchemaField} field - The schema field containing label information. 32 | * @param {number} index - The index of the array item. 33 | * @returns {object | null} An object containing the label, number, and 34 | * formatted JSX element, or null if the label is null. 35 | */ 36 | export function getArrayLabel(field: SchemaField, index: number) { 37 | const label = field.label === undefined ? 'Item' : field.label; 38 | if (label === null) { 39 | return null; 40 | } 41 | 42 | if (typeof label === 'string') { 43 | const n = index + 1; 44 | return { 45 | label: label, 46 | num: n, 47 | formatted: ( 48 | <> 49 | {label}{' '} 50 | {n <= 9 ? `0${n}` : n} 51 | 52 | ) 53 | }; 54 | } 55 | 56 | const l = label[mod(index, label.length)]; 57 | return { 58 | label: l, 59 | num: null, 60 | formatted: l 61 | }; 62 | } 63 | 64 | /** 65 | * Converts a given value to a number. 66 | * 67 | * @param v - The value to convert. 68 | * @returns The numeric representation of the value, or null if the value cannot 69 | * be converted to a number. 70 | */ 71 | export function toNumber(v: any) { 72 | const n = Number(v); 73 | return isNaN(n) ? null : n; 74 | } 75 | 76 | /** 77 | * Ensures that the provided value is returned as an array. 78 | * If the value is already an array, it is returned as-is. 79 | * Otherwise, the value is wrapped in an array. 80 | * 81 | * @param {T | T[]} value - The value to be cast to an array. 82 | * @returns {T[]} The value as an array. 83 | */ 84 | export function castArray(value: T | T[]): T[] { 85 | return Array.isArray(value) ? value : [value]; 86 | } 87 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/utils/use-render-key.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useDeepCompareEffect } from 'react-use'; 3 | 4 | /** 5 | * Creates a new key on deep props change. 6 | * The key won't change between the first render and after mount. 7 | * 8 | * @param props Props to deep check. 9 | * @returns component key 10 | */ 11 | export const useRenderKey = (props: any[]) => { 12 | const [renderKey, setSenderKey] = useState(-1); 13 | useDeepCompareEffect(() => { 14 | setSenderKey((v) => v + 1); 15 | }, props); 16 | 17 | return `key-${Math.max(0, renderKey)}`; 18 | }; 19 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/utils/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { getArrayLabel } from './index'; 2 | import { SchemaField } from '@stac-manager/data-core'; 3 | 4 | describe('getArrayLabel', () => { 5 | it('should return a label with a number suffix for string labels', () => { 6 | const field: SchemaField = { label: 'Label' } as any; 7 | const result = getArrayLabel(field, 9); 8 | 9 | expect(result).toEqual({ 10 | label: 'Label', 11 | num: 10, // 1-based index 12 | formatted: expect.anything() 13 | }); 14 | expect(result?.formatted).toMatchSnapshot(); 15 | }); 16 | 17 | it('should cycle through array labels based on index', () => { 18 | const field: SchemaField = { label: ['One', 'Two', 'Three'] } as any; 19 | expect(getArrayLabel(field, 0)?.label).toBe('One'); 20 | expect(getArrayLabel(field, 3)?.label).toBe('One'); 21 | expect(getArrayLabel(field, 4)?.label).toBe('Two'); 22 | }); 23 | 24 | it('should return null if label is null', () => { 25 | const field: SchemaField = { label: null } as any; 26 | expect(getArrayLabel(field, 0)).toBeNull(); 27 | }); 28 | 29 | it('should return Item if label is undefined', () => { 30 | const field: SchemaField = { label: undefined } as any; 31 | const result = getArrayLabel(field, 0); 32 | 33 | expect(result).toEqual({ 34 | label: 'Item', 35 | num: 1, 36 | formatted: expect.anything() 37 | }); 38 | expect(result?.formatted).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/array-input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, ListItem } from '@chakra-ui/react'; 3 | import { 4 | SchemaFieldArray, 5 | schemaToFormDataStructure, 6 | WidgetProps 7 | } from '@stac-manager/data-core'; 8 | import { FieldArray, useFormikContext } from 'formik'; 9 | import { get } from 'lodash-es'; 10 | 11 | import { getArrayLabel } from '../utils'; 12 | import { ArrayFieldset } from '../components/elements'; 13 | import { WidgetInput } from './input'; 14 | 15 | export function WidgetArrayInput(props: WidgetProps) { 16 | const { pointer, isRequired } = props; 17 | const field = props.field as SchemaFieldArray; 18 | 19 | const { values } = useFormikContext(); 20 | const fields: any[] = get(values, pointer) || []; 21 | 22 | const minItems = field.minItems || 0; 23 | const maxItems = field.maxItems || Infinity; 24 | const isFixed = minItems === maxItems; 25 | 26 | return ( 27 | ( 30 | { 37 | push(schemaToFormDataStructure(field.items)); 38 | } 39 | } 40 | addDisabled={fields.length >= maxItems} 41 | > 42 | {fields.length ? ( 43 | 44 | {fields.map((_, index) => ( 45 | 50 | { 57 | remove(index); 58 | }} 59 | transformValue={(v) => { 60 | if (field.items.type === 'number') { 61 | const n = Number(v); 62 | return isNaN(n) ? null : n; 63 | } 64 | return v; 65 | }} 66 | isDeleteDisabled={fields.length <= minItems} 67 | /> 68 | 69 | ))} 70 | 71 | ) : ( 72 |

No items

73 | )} 74 |
75 | )} 76 | /> 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/array.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, ListItem } from '@chakra-ui/react'; 3 | import { 4 | WidgetRenderer, 5 | SchemaFieldArray, 6 | WidgetProps, 7 | schemaToFormDataStructure 8 | } from '@stac-manager/data-core'; 9 | import { FieldArray, useFormikContext } from 'formik'; 10 | import { get } from 'lodash-es'; 11 | import { getArrayLabel } from '../utils'; 12 | import { ArrayFieldset } from '../components/elements'; 13 | 14 | export function WidgetArray(props: WidgetProps) { 15 | const { pointer, isRequired } = props; 16 | const field = props.field as SchemaFieldArray; 17 | 18 | return ( 19 | 25 | ); 26 | } 27 | 28 | interface ArrayItemProps { 29 | label: React.ReactNode; 30 | field: SchemaFieldArray; 31 | pointer: string; 32 | isRequired?: boolean; 33 | onRemove?: () => void; 34 | removeDisabled?: boolean; 35 | } 36 | 37 | function ArrayItem(props: ArrayItemProps) { 38 | const { label, field, pointer, onRemove, removeDisabled, isRequired } = props; 39 | 40 | const { values } = useFormikContext(); 41 | const fields: any[] = get(values, pointer) || []; 42 | 43 | const items = field.items; 44 | const minItems = field.minItems || 0; 45 | const maxItems = field.maxItems || Infinity; 46 | const isFixedCount = minItems === maxItems; 47 | 48 | // Check if the nested array is special like an array of strings (Checkboxes) 49 | // for example. Same logic as widget renderer. 50 | const isRegularNestedArray = 51 | items.type === 'array' && !['string', 'number'].includes(items.items.type); 52 | 53 | return ( 54 | ( 57 | { 65 | push(schemaToFormDataStructure(items)); 66 | } 67 | } 68 | addDisabled={fields.length >= maxItems} 69 | removeDisabled={removeDisabled} 70 | > 71 | {fields.length ? ( 72 | 73 | {fields.map((_, index) => ( 74 | 77 | {isRegularNestedArray ? ( 78 | { 86 | remove(index); 87 | } 88 | } 89 | removeDisabled={fields.length <= minItems} 90 | /> 91 | ) : ( 92 | { 98 | remove(index); 99 | } 100 | } 101 | removeDisabled={fields.length <= minItems} 102 | > 103 | 108 | 109 | )} 110 | 111 | ))} 112 | 113 | ) : ( 114 |

No items

115 | )} 116 |
117 | )} 118 | /> 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Checkbox, 4 | CheckboxGroup, 5 | Flex, 6 | FormControl, 7 | FormErrorMessage, 8 | FormLabel 9 | } from '@chakra-ui/react'; 10 | import { FastField, FastFieldProps } from 'formik'; 11 | import { 12 | SchemaFieldArray, 13 | SchemaFieldString, 14 | WidgetProps 15 | } from '@stac-manager/data-core'; 16 | 17 | import { FieldLabel } from '../components/elements'; 18 | 19 | export function WidgetCheckbox(props: WidgetProps) { 20 | const { pointer, isRequired } = props; 21 | const field = props.field as SchemaFieldArray; 22 | 23 | const options = field.items.enum; 24 | 25 | if (field.items.allowOther) { 26 | throw new Error( 27 | "WidgetCheckbox: allowOther is not supported. Use widget 'tagger' instead" 28 | ); 29 | } 30 | 31 | if (!options?.length) { 32 | throw new Error('WidgetCheckbox: items.enum is required'); 33 | } 34 | 35 | return ( 36 | 37 | {({ 38 | field: { value }, 39 | meta, 40 | form: { setFieldValue, setFieldTouched } 41 | }: FastFieldProps) => ( 42 | 46 | {field.label && ( 47 | 48 | {field.label} 49 | 50 | )} 51 | 52 | { 55 | setFieldValue(pointer, v); 56 | setFieldTouched(pointer, true); 57 | }} 58 | > 59 | {options.map(([checkboxVal, label]) => ( 60 | 61 | {label} 62 | 63 | ))} 64 | 65 | 66 | {meta.error} 67 | 68 | )} 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/input.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Flex, 4 | FormControl, 5 | FormErrorMessage, 6 | FormLabel, 7 | Input 8 | } from '@chakra-ui/react'; 9 | import { FastField, FastFieldProps } from 'formik'; 10 | import { SchemaFieldString, WidgetProps } from '@stac-manager/data-core'; 11 | import { CollecticonTrashBin } from '@devseed-ui/collecticons-chakra'; 12 | 13 | import { FieldIconBtn, FieldLabel } from '../components/elements'; 14 | 15 | interface WidgetInputProps extends WidgetProps { 16 | label?: React.ReactNode; 17 | isDeletable?: boolean; 18 | type?: string; 19 | transformValue?: (value: any) => any; 20 | onDeleteClick?: () => void; 21 | isDeleteDisabled?: boolean; 22 | } 23 | 24 | const identity = (v: any) => v; 25 | 26 | export function WidgetInput(props: WidgetInputProps) { 27 | const { 28 | label, 29 | isDeletable, 30 | onDeleteClick, 31 | isDeleteDisabled, 32 | pointer, 33 | isRequired, 34 | type, 35 | transformValue = identity 36 | } = props; 37 | const field = props.field as SchemaFieldString; 38 | 39 | const fieldLabel = label || field.label; 40 | 41 | return ( 42 | 43 | {({ 44 | field: { value, onBlur }, 45 | meta, 46 | form: { setFieldValue } 47 | }: FastFieldProps) => ( 48 | 52 | 53 | {fieldLabel && ( 54 | 55 | {fieldLabel} 56 | 57 | )} 58 | 59 | {isDeletable && ( 60 | } 64 | isDisabled={isDeleteDisabled} 65 | /> 66 | )} 67 | 68 | 69 | { 79 | setFieldValue(pointer, transformValue(e.target.value)); 80 | }} 81 | /> 82 | {meta.error} 83 | 84 | )} 85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/json.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, useRef, useState } from 'react'; 2 | import { SchemaFieldJson, WidgetProps } from '@stac-manager/data-core'; 3 | import { 4 | CircularProgress, 5 | Flex, 6 | FormControl, 7 | FormLabel 8 | } from '@chakra-ui/react'; 9 | import { FastField, FastFieldProps } from 'formik'; 10 | import { 11 | CollecticonArrowSemiSpinCcw, 12 | CollecticonArrowSemiSpinCw, 13 | CollecticonWrench, 14 | CollecticonTextBlock 15 | } from '@devseed-ui/collecticons-chakra'; 16 | import type JSONEditor from 'jsoneditor'; 17 | 18 | import { FieldIconBtn, FieldLabel } from '../components/elements'; 19 | import { CollecticonIndent } from '../components/icons/indent'; 20 | 21 | const JsonEditor = React.lazy(() => import('../components/json-jsoneditor')); 22 | 23 | // Extend to have access to internal methods provided by the textmode. 24 | interface JSONEditorCodeMode extends JSONEditor { 25 | compact: () => void; 26 | format: () => void; 27 | repair: () => void; 28 | _onChange: () => void; 29 | } 30 | 31 | export function WidgetJSON(props: WidgetProps) { 32 | const field = props.field as SchemaFieldJson; 33 | 34 | const editorRef = useRef(null); 35 | const [isLoaded, setIsLoaded] = useState(false); 36 | 37 | return ( 38 | 39 | 40 | {field.label && ( 41 | 42 | {field.label} 43 | 44 | )} 45 | {isLoaded && } 46 | 47 | 48 | }> 49 | 50 | {({ field: { value }, form: { setFieldValue } }: FastFieldProps) => ( 51 | setFieldValue(props.pointer, v)} 54 | editorRef={editorRef} 55 | onLoad={() => setIsLoaded(true)} 56 | /> 57 | )} 58 | 59 | 60 | 61 | ); 62 | } 63 | 64 | function Loading() { 65 | return ( 66 | 67 | {' '} 68 | Loading json editor... 69 | 70 | ); 71 | } 72 | 73 | function ControlBar(props: { editor: JSONEditorCodeMode }) { 74 | const { editor } = props; 75 | 76 | const undoManager = editor.aceEditor.getSession().getUndoManager(); 77 | 78 | return ( 79 | 80 | { 83 | editor.repair?.(); 84 | editor._onChange?.(); 85 | }} 86 | icon={} 87 | /> 88 | { 91 | editor.compact?.(); 92 | }} 93 | icon={} 94 | /> 95 | { 98 | editor.format?.(); 99 | }} 100 | icon={} 101 | /> 102 | { 106 | undoManager?.undo(); 107 | }} 108 | icon={} 109 | /> 110 | { 114 | undoManager?.redo(); 115 | }} 116 | icon={} 117 | /> 118 | 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/number.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { WidgetProps } from '@stac-manager/data-core'; 3 | 4 | import { WidgetInput } from './input'; 5 | import { toNumber } from '../utils'; 6 | 7 | export function WidgetNumber(props: WidgetProps) { 8 | return ; 9 | } 10 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/object-fieldset.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { WidgetProps } from '@stac-manager/data-core'; 3 | 4 | import { WidgetObject } from './object'; 5 | import { ArrayFieldset } from '../components/elements'; 6 | 7 | export function WidgetObjectFieldset(props: WidgetProps) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/object.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | WidgetRenderer, 4 | SchemaFieldObject, 5 | WidgetProps 6 | } from '@stac-manager/data-core'; 7 | import { useFormikContext } from 'formik'; 8 | import { Button } from '@chakra-ui/react'; 9 | import { CollecticonPlusSmall } from '@devseed-ui/collecticons-chakra'; 10 | import get from 'lodash-es/get'; 11 | 12 | import { ObjectProperty } from '../components/object-property'; 13 | 14 | /** 15 | * Finds the next unique value by appending an incrementing number to the given 16 | * value. 17 | * 18 | * @param value - The base value to which the incrementing number will be 19 | * appended. 20 | * @param valueKeys - An array of existing values to check against for 21 | * uniqueness. 22 | * @returns The next unique value that is not present in the valueKeys array. 23 | */ 24 | const findNextValue = (value: string, valueKeys: string[]): string => { 25 | let i = 0; 26 | while (true) { 27 | const v = `${value}-${++i}`; 28 | if (!valueKeys.includes(v)) return v; 29 | } 30 | }; 31 | 32 | /***************************************************************************** 33 | * C O M P O N E N T * 34 | *****************************************************************************/ 35 | export function WidgetObject(props: WidgetProps) { 36 | const { pointer } = props; 37 | const field = props.field as SchemaFieldObject; 38 | 39 | const ctx = useFormikContext(); 40 | const values = pointer ? get(ctx.values, pointer) : ctx.values; 41 | 42 | const schemaKeys = Object.keys(field.properties); 43 | const valueKeys = Object.keys(values || {}); 44 | const unlistedKeys = valueKeys.filter((key) => !schemaKeys.includes(key)); 45 | 46 | return ( 47 | <> 48 | {Object.entries(field.properties).map(([key, item]) => ( 49 | 55 | ))} 56 | {field.additionalProperties && ( 57 | <> 58 | {unlistedKeys.map((key) => ( 59 | k !== key)} 63 | pointer={pointer ? `${pointer}.${key}` : key} 64 | /> 65 | ))} 66 | 81 | 82 | )} 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/radio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | FormControl, 4 | FormErrorMessage, 5 | FormLabel, 6 | Radio, 7 | RadioGroup 8 | } from '@chakra-ui/react'; 9 | import { SchemaFieldString, WidgetProps } from '@stac-manager/data-core'; 10 | import { FastField, FastFieldProps } from 'formik'; 11 | 12 | import { FieldLabel } from '../components/elements'; 13 | 14 | export function WidgetRadio(props: WidgetProps) { 15 | const { pointer, isRequired } = props; 16 | const field = props.field as SchemaFieldString; 17 | 18 | const options = field.enum; 19 | 20 | if (field.allowOther) { 21 | throw new Error( 22 | "WidgetCheckbox: allowOther is not supported. Use widget 'tagger' instead" 23 | ); 24 | } 25 | 26 | if (!options?.length) { 27 | throw new Error('WidgetRadio: enum is required'); 28 | } 29 | 30 | return ( 31 | 32 | {({ 33 | field: { value }, 34 | meta, 35 | form: { setFieldValue, setFieldTouched } 36 | }: FastFieldProps) => ( 37 | 41 | {field.label && ( 42 | 43 | {field.label} 44 | 45 | )} 46 | { 52 | setFieldValue(pointer, v); 53 | setFieldTouched(pointer, true); 54 | }} 55 | > 56 | {options.map(([radioValue, label]) => ( 57 | 58 | {label} 59 | 60 | ))} 61 | 62 | {meta.error} 63 | 64 | )} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /packages/data-widgets/lib/widgets/select.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { FormControl, FormErrorMessage, FormLabel } from '@chakra-ui/react'; 3 | import { FastField, FastFieldProps } from 'formik'; 4 | import ReactSelect from 'react-select'; 5 | import { 6 | SchemaFieldArray, 7 | SchemaFieldString, 8 | WidgetProps 9 | } from '@stac-manager/data-core'; 10 | 11 | import { FieldLabel } from '../components/elements'; 12 | import { castArray } from '../utils'; 13 | import { useRenderKey } from '../utils/use-render-key'; 14 | 15 | interface Option { 16 | readonly label: string; 17 | readonly value: string; 18 | } 19 | 20 | export function WidgetSelect(props: WidgetProps) { 21 | const { pointer, isRequired, field } = props; 22 | 23 | const isMulti = field.type === 'array'; 24 | 25 | if ( 26 | isMulti 27 | ? !(field as SchemaFieldArray).items?.enum?.length 28 | : !(field as SchemaFieldString).enum?.length 29 | ) { 30 | throw new Error('WidgetSelect: enum is required'); 31 | } 32 | 33 | if ( 34 | isMulti 35 | ? (field as SchemaFieldArray).items.allowOther 36 | : (field as SchemaFieldString).allowOther 37 | ) { 38 | throw new Error( 39 | "WidgetSelect: allowOther is not supported. Use widget 'tagger' instead" 40 | ); 41 | } 42 | 43 | const key = useRenderKey([pointer, isRequired, isMulti, field]); 44 | 45 | const options = useMemo(() => { 46 | const enums = isMulti 47 | ? (field as SchemaFieldArray).items?.enum 48 | : (field as SchemaFieldString).enum; 49 | 50 | return enums!.map