diff --git a/.dockerfiles/www/default.conf b/.dockerfiles/www/default.conf new file mode 100644 index 000000000..6c7199296 --- /dev/null +++ b/.dockerfiles/www/default.conf @@ -0,0 +1,19 @@ +server { + listen 80; + listen [::]:80; + server_name localhost; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + #error_page 404 /404.html; + + # redirect server error pages to the static page /50x.html + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eeb05336f..65e5cdecc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,17 +1,22 @@ name: Build + on: - schedule: - - cron: '0 0 * * *' # Every day at midnight - push: pull_request: - paths: - - '.github/workflows/build.yml' - # Build when a fundamental package change has occured - - 'package.json' + branches: + - main + - develop + push: + branches: + - main + - develop + tags: + - v* + schedule: + - cron: '0 16 * * *' # Every day at 16:00 UTC (~09:00 PT) jobs: build: - name: Build the frontend releasable package + name: Build releasable package runs-on: ubuntu-latest steps: @@ -22,11 +27,8 @@ jobs: with: node-version: '15.x' - - name: Install dependencies - run: npm install --legacy-peer-deps - - - name: Compile build - run: npm run build -- --env=houston=relative + - name: Install dependencies and compile + run: ./scripts/build.npm.sh - name: Package the build run: | @@ -39,3 +41,142 @@ jobs: path: codex-frontend.tar.gz # default: # retention-days: 90 + + deploy: + name: Docker image build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + if: github.event_name == 'schedule' + with: + ref: develop + + - uses: actions/checkout@v2 + if: github.event_name != 'schedule' + + - uses: docker/setup-qemu-action@v1 + name: Set up QEMU + id: qemu + with: + image: tonistiigi/binfmt:latest + platforms: all + + - uses: docker/setup-buildx-action@v1 + name: Set up Docker Buildx + id: buildx + + - name: Available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + # Build images + - name: Build Codex + run: | + ./scripts/buildx.docker.sh + + # Log into container registries + - name: Login to DockerHub + uses: docker/login-action@v1 + with: + username: wildmebot + password: ${{ secrets.WBIA_WILDMEBOT_DOCKER_HUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_PAT }} + + # Push tagged image (version tag + latest) to registries + - name: Tagged Docker Hub + if: ${{ github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') }} + run: | + VERSION=$(echo ${GITHUB_REF} | sed 's#.*/v##') + ./scripts/buildx.docker.sh -t ${VERSION} + ./scripts/buildx.docker.sh -t latest + + - name: Tagged GHCR + if: ${{ github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') }} + run: | + VERSION=$(echo ${GITHUB_REF} | sed 's#.*/v##') + ./scripts/buildx.docker.sh -t ${VERSION} -r ghcr.io/wildmeorg/codex-frontend + ./scripts/buildx.docker.sh -t latest -r ghcr.io/wildmeorg/codex-frontend + + # Push stable image (main tag) to registries + - name: Stable Docker Hub + if: github.ref == 'refs/heads/main' + run: | + ./scripts/buildx.docker.sh -t main + + - name: Stable GHCR + if: github.ref == 'refs/heads/main' + run: | + ./scripts/buildx.docker.sh -t main -r ghcr.io/wildmeorg/codex-frontend + + # Push bleeding-edge image (develop tag) to registries + - name: Bleeding Edge Docker Hub + if: github.ref == 'refs/heads/develop' + run: | + ./scripts/buildx.docker.sh -t develop + + - name: Bleeding Edge GHCR + if: github.ref == 'refs/heads/develop' + run: | + ./scripts/buildx.docker.sh -t develop -r ghcr.io/wildmeorg/codex-frontend + + # Push nightly image (nightly tag) to registries + - name: Nightly Docker Hub + if: github.event_name == 'schedule' + run: | + ./scripts/buildx.docker.sh -t nightly + + - name: Nightly GHCR + if: github.event_name == 'schedule' + run: | + ./scripts/buildx.docker.sh -t nightly -r ghcr.io/wildmeorg/codex-frontend + + # Notify status in Slack + - name: Slack Notification + if: ${{ failure() && github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v') }} + uses: rtCamp/action-slack-notify@master + env: + SLACK_CHANNEL: dev-houston + SLACK_COLOR: '#FF0000' + SLACK_ICON: https://avatars.slack-edge.com/2020-03-02/965719891842_db87aa21ccb61076f236_44.png + SLACK_MESSAGE: 'Tagged / Latest Docker build of Codex Frontend failed :sob:' + SLACK_USERNAME: "GitHub CI" + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + - name: Slack Notification + if: ${{ failure() && github.ref == 'refs/heads/main' }} + uses: rtCamp/action-slack-notify@master + env: + SLACK_CHANNEL: dev-houston + SLACK_COLOR: '#FF0000' + SLACK_ICON: https://avatars.slack-edge.com/2020-03-02/965719891842_db87aa21ccb61076f236_44.png + SLACK_MESSAGE: 'Stable Docker build of Codex Frontend failed :sob:' + SLACK_USERNAME: "GitHub CI" + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + - name: Slack Notification + if: ${{ failure() && github.ref == 'refs/heads/develop' }} + uses: rtCamp/action-slack-notify@master + env: + SLACK_CHANNEL: dev-houston + SLACK_COLOR: '#FF0000' + SLACK_ICON: https://avatars.slack-edge.com/2020-03-02/965719891842_db87aa21ccb61076f236_44.png + SLACK_MESSAGE: 'Bleeding Edge Docker build of Codex Frontend failed :sob:' + SLACK_USERNAME: "GitHub CI" + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} + + - name: Slack Notification + if: ${{ failure() && github.event_name == 'schedule' }} + uses: rtCamp/action-slack-notify@master + env: + SLACK_CHANNEL: dev-houston + SLACK_COLOR: '#FF0000' + SLACK_ICON: https://avatars.slack-edge.com/2020-03-02/965719891842_db87aa21ccb61076f236_44.png + SLACK_MESSAGE: 'Nightly Docker build of Codex Frontend failed :sob:' + SLACK_USERNAME: "GitHub CI" + SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index df6b81e2d..000000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Nightly -on: - schedule: - - cron: '0 16 * * *' # Every day at 16:00 UTC (~09:00 PT) - push: - paths: - - '.github/workflows/nightly.yml' - - 'prod/**' - pull_request: - paths: - - '.github/workflows/nightly.yml' - - 'prod/**' - -jobs: - build-image: - name: DevOps nightly image build - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - if: github.event_name == 'schedule' - with: - ref: develop - - - uses: actions/checkout@v2 - if: github.event_name != 'schedule' - - # Log into image registries - - name: Log into Docker Hub - run: echo "${{ secrets.WBIA_WILDMEBOT_DOCKER_HUB_TOKEN }}" | docker login -u wildmebot --password-stdin - # - name: Log into GitHub Packages - # run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin - - # Build images - - name: Build docker image - run: ./build-n-push.sh --tag dev - working-directory: ./prod - - # Notify status in Slack - - name: Slack Notification - if: failure() && github.event_name == 'schedule' - uses: rtCamp/action-slack-notify@master - env: - SLACK_CHANNEL: dev-houston - SLACK_COLOR: '#FF0000' - SLACK_ICON: https://avatars.slack-edge.com/2020-03-02/965719891842_db87aa21ccb61076f236_44.png - SLACK_MESSAGE: 'nightly build of the *codex-frontend image* failed :sob:' - SLACK_USERNAME: "Nightly" - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..f00c2ddd5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:lts as org.wildme.codex.frontend.build + +COPY . /code + +WORKDIR /code + +RUN set -ex \ + && ./scripts/build.npm.sh + +########################################################################################## +FROM nginx:alpine as org.wildme.codex.frontend.deploy + +COPY --from=org.wildme.codex.frontend.build /code/dist /usr/share/nginx/html + +COPY ./.dockerfiles/www/default.conf /etc/nginx/conf.d/default.conf diff --git a/docs/contribution-guide.md b/docs/contribution-guide.md index f74bdeb68..88a935833 100644 --- a/docs/contribution-guide.md +++ b/docs/contribution-guide.md @@ -4,33 +4,37 @@ This document covers the conventions and paradigms used in this codebase. We gen ## Components -We rely heavily on Material UI. When a component exists in Material UI, use it instead of creating one by hand. +We rely heavily on Material UI. When a component exists in Material UI, use it instead of creating one by hand. -## Styles +## Styles -- Currently we use inline styles. The plan is to continue doing so until performance issues arise. +- Currently we use inline styles. The plan is to continue doing so until performance issues arise. - We use a 4px grid system. All padding, margins, and widths should be divisible by 4. - When using the main theme colors (white, black, or purple), please use Material UI's useTheme hook. -## Global state +## Global state There are a few things stored in context, but mostly pages fetch their own data. -## Translations +## Translations -All displayed text must support translation - for this we use `react-intl`. Translation keys are verbatum English abbreviations of the displayed text in all caps. You can see some examples in `/locale/en.json`. +All displayed text must support translation - for this we use `react-intl`. Translation keys are verbatum English abbreviations of the displayed text in all caps. You can see some examples in `/locale/en.json`. If you want to help translate the project, that is very much appreciated and needed, but please don't do it by manually editing files in `/locale`. Your changes will wind up getting overwritten by Lokalise. -## Conventions +## Conventions - Any file with a React component should have the suffix `.jsx` - Data fetching goes in `/src/models` - Code specific to a certain page goes in `/src/pages` - Components that are reused widely go in `/src/components` -- Post any questions in Github issues or send an email to ben@wildme.org +- Post any questions in Github issues or send an email to ben@wildme.org - Thanks for contributing =) +## Release procedure + +Please refer to [this document](https://github.com/WildMeOrg/codex-frontend/blob/develop/docs/release-procedure.md) for guidelines on making a new release. + ## Making a hotfix -Likely you wont need to make a hotfix unless you are a Wild Me employee, but if the need arises please follow the [hotfix procedures](https://github.com/WildMeOrg/codex-frontend/blob/develop/docs/hotfix-procedure.md). \ No newline at end of file +Likely you won't need to make a hotfix unless you are a Wild Me employee, but if the need arises please follow the [hotfix procedures](https://github.com/WildMeOrg/codex-frontend/blob/develop/docs/hotfix-procedure.md). diff --git a/docs/release-procedure.md b/docs/release-procedure.md new file mode 100644 index 000000000..a55a18318 --- /dev/null +++ b/docs/release-procedure.md @@ -0,0 +1,33 @@ +# Release procedure + +## Alert the team + +A release PR should not happen while there are remaining work items in QA. + +Consult the front-end team if there are still items in QA, or if other previous merges need to be omitted from the release. + +The develop branch should be "frozen" during the release procedure; please be sure that everyone on the team is notified of the freeze and when it is completed. + +## Merge and create the PR + +First, resolve any potential merge conflicts with main on the develop branch + +1. `git checkout develop` +2. `git pull origin develop` +3. `git checkout main` +4. `git pull origin main` +5. `git merge develop` (there should be no merge conflicts) +6. Update the version in package.json. + +Then, issue a pull request for merging the develop branch into the main branch. Await approval and then merge using the "squash and merge" method. + +Note: if you encounter merge conflicts when merging develop into main, something went wrong. Investigate what happened thoroughly before continuing. + +## Draft a new release + +After completion of the previous steps, draft a new release of the main branch on GitHub: + +1. Navigate to [https://github.com/WildMeOrg/codex-frontend/releases/new](https://github.com/WildMeOrg/codex-frontend/releases/new). +2. Designate the target as the main branch. +3. Create a new tag following the SemVer pattern outlined [here](https://semver.org/) (vX.Y.Z). Note that this exact pattern (no period between v and the first name, for instance) must be followed explicitly. The release title should be the same as the tag. You can leave the release description blank. +4. Publish the release. diff --git a/locale/en.json b/locale/en.json index dc2dee3b3..f283d0a5d 100644 --- a/locale/en.json +++ b/locale/en.json @@ -12,6 +12,8 @@ "LABEL": "Label", "TYPE": "Type", "FOREST": "Forest", + "STAGE": "Stage", + "NO_PENDING_PUBLIC_SIGHTINGS": "There are no pending public sightings", "DATA_UNAVAILABLE": "Data unavailable", "DATA_MANAGER": "Data Manager", "ANNOTATION_CLASS": "Annotation class", @@ -727,7 +729,7 @@ "LOCATION_DESCRIPTION": "The precise decimal latitude and longitude where the sighting took place.", "WEBSITE": "Website", "REVIEW_MATCH_CANDIDATES": "Review match candidates", - "SERVER_STATUS": "Server Status", + "SITE_STATUS_PAGE_TITLE": "Site Status", "YOUR_NAME": "Your name", "CHOOSE_PHOTO": "Choose photo", "EMAIL_ADDRESS": "Email address", @@ -836,10 +838,10 @@ "SEND_INVITATION": "Send invitation", "SEX": "Sex", "MEMBERS": "Members", - "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_OCCURRENCE_LABEL": "Custom sighting fields", - "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_OCCURRENCE_DESCRIPTION": "Submitters will enter these fields once for each sighting they report.", - "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_MARKEDINDIVIDUAL_LABEL": "Custom individual fields", - "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_MARKEDINDIVIDUAL_DESCRIPTION": "Users can view and edit these fields on each individual's profile page.", + "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_SIGHTING_LABEL": "Custom sighting fields", + "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_SIGHTING_DESCRIPTION": "Submitters will enter these fields once for each sighting they report.", + "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_INDIVIDUAL_LABEL": "Custom individual fields", + "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_INDIVIDUAL_DESCRIPTION": "Users can view and edit these fields on each individual's profile page.", "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_ENCOUNTER_LABEL": "Custom encounter fields", "CONFIGURATION_SITE_CUSTOM_CUSTOMFIELDS_ENCOUNTER_DESCRIPTION": "Submitters will enter these fields for every individual in the sightings they report.", "CONFIGURATION_SITE_CUSTOM_REGIONS_LABEL": "Regions", @@ -974,6 +976,8 @@ "SERVER_TOOLTIP_JOBCOUNTER_LABEL": "Jobcounter:", "SERVER_TOOLTIP_STATUS_LABEL": "Status:", "SERVER_TOOLTIP_DATE_RECEIVED_LABEL": "Received on:", + "SERVER_TOOLTIP_SUMMARY": "Jobcounter: {jobcounter}; Status: {status}; Received on: {dateReceived}", + "SERVER_COLOR_LEGEND_BOX": "{color} legend box", "PRINTABLE_PHOTOBOOK": "Printable Photobook", "NO_IMAGES": "There are no images for this individual.", "TASKS": "Tasks", @@ -1126,6 +1130,7 @@ "COLLABORATION_RESTORE_ERROR": "Error restoring the collaboration", "COLLAB_RESTORE_ERROR_SUPPLEMENTAL": "Collaboration was not successfully restored.", "PENDING": "pending", + "PENDING_CITIZEN_SCIENCE_SIGHTINGS": "Pending citizen science sightings", "CANDIDATE_ANNOTATIONS": "Candidate annotations", "SIGHTING_DELETE_VULNERABLE_INDIVIDUAL_MESSAGE": "Deleting this sighting would result in assigned individuals being deleted. Are you sure you want to continue?", "REQUEST_REQUIRES_ADDITIONAL_CONFIRMATION": "Request requires additional confirmation", @@ -1135,5 +1140,13 @@ "PAGINATION_LAST_PAGE": "last page", "PAGINATION_PREVIOUS_PAGE": "previous page", "PAGINATION_NEXT_PAGE": "next page", + "SOCIAL_GROUPS": "Social groups", + "CONFIGURATION_SOCIAL_GROUP_ROLES_DESCRIPTION": "These roles populate the dropdown menu for any social groups made. It is recommended to include a generic role, such as, \"Member\"", + "CONFIGURATION_SOCIAL_GROUP_ROLES_LABEL": "Social group roles", + "NEW_SOCIAL_GROUP_ROLE": "Add role", + "ALLOW_MULTIPLE_OF_THIS_ROLE": "Allow multiple of this role", + "ONE_OR_MORE_ROLES_MISSING_LABELS": "One or more roles are missing labels", + "TWO_OR_MORE_ROLES_SAME_LABEL": "Two or more roles have the same label. Make sure each label is different", + "ROLE_GUID_MISSING": "Role is missing ID", "UNFINISHED_OPTIONS": "Options must have valid values and labels and unique values" } diff --git a/package.json b/package.json index c02dc265f..c73292333 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-frontend", - "version": "1.0.1", + "version": "1.0.2", "description": "", "main": "index.js", "scripts": { diff --git a/prod/Dockerfile b/prod/Dockerfile deleted file mode 100644 index 242aafd27..000000000 --- a/prod/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:lts as dist-build -RUN set -x \ - && cd /tmp \ - && git clone https://github.com/WildMeOrg/codex-frontend.git -# Install dependencies & build distribution -RUN set -ex \ - && cd /tmp/codex-frontend \ - && npm install \ - && npm run build -- --env=houston=relative - - -FROM nginx:alpine as main -COPY default.conf /etc/nginx/conf.d/default.conf -COPY --from=dist-build /tmp/codex-frontend/dist /usr/share/nginx/html diff --git a/prod/README.md b/prod/README.md deleted file mode 100644 index 1fd1a357a..000000000 --- a/prod/README.md +++ /dev/null @@ -1 +0,0 @@ -This `Dockerfile` is used to make a static build of the frontend code that is then served by nginx. This is intended to be a temporary solution. We would ideally like to mount the built code into the basic nginx image for serving. This method however requires less configuration, which is fine for a temporary solution. diff --git a/prod/default.conf b/prod/default.conf deleted file mode 100644 index f6b2f650f..000000000 --- a/prod/default.conf +++ /dev/null @@ -1,92 +0,0 @@ -server { - listen 80; - listen [::]:80; - server_name localhost; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html; - } - - #error_page 404 /404.html; - - # redirect server error pages to the static page /50x.html - # - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } - -} - - -# https://stackoverflow.com/a/49204467/176882 -# server { - -# listen 80; -# server_name www.xyz.io; -# root /opt/xyz/www; - -# # index -# index index.html; - -# # $uri, index.html -# location / { -# try_files $uri $uri/ /index.html; -# } - -# location /api { -# proxy_pass http://127.0.0.1:8200; -# proxy_http_version 1.1; -# proxy_set_header Upgrade $http_upgrade; -# proxy_set_header Connection "Upgrade"; -# } - -# } - -# original default -# server { -# listen 80; -# listen [::]:80; -# server_name localhost; - -# #access_log /var/log/nginx/host.access.log main; - -# location / { -# root /usr/share/nginx/html; -# index index.html index.htm; -# } - -# #error_page 404 /404.html; - -# # redirect server error pages to the static page /50x.html -# # -# error_page 500 502 503 504 /50x.html; -# location = /50x.html { -# root /usr/share/nginx/html; -# } - -# # proxy the PHP scripts to Apache listening on 127.0.0.1:80 -# # -# #location ~ \.php$ { -# # proxy_pass http://127.0.0.1; -# #} - -# # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 -# # -# #location ~ \.php$ { -# # root html; -# # fastcgi_pass 127.0.0.1:9000; -# # fastcgi_index index.php; -# # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; -# # include fastcgi_params; -# #} - -# # deny access to .htaccess files, if Apache's document root -# # concurs with nginx's one -# # -# #location ~ /\.ht { -# # deny all; -# #} -# } diff --git a/scripts/build.npm.sh b/scripts/build.npm.sh new file mode 100755 index 000000000..b6f3f969c --- /dev/null +++ b/scripts/build.npm.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +npm install --legacy-peer-deps +npm run build -- --env=houston=relative diff --git a/scripts/buildx.docker.sh b/scripts/buildx.docker.sh new file mode 100755 index 000000000..dba5e24a4 --- /dev/null +++ b/scripts/buildx.docker.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -e + +export DOCKER_BUILDKIT=1 + +export DOCKER_CLI_EXPERIMENTAL=enabled + +usage () { + echo "Usage: $0 [-t ] [-r ] [ ...]"; +} + +if [ ! -d ".git/" ]; then + echo "Cannot build Docker image when this repo is a submodule within a larger repo." + exit 1; +fi + +# Parse commandline options +while getopts ":t:r:" option; do + case ${option} in + t ) TAG=${OPTARG};; + r ) REGISTRY=${OPTARG};; + \? ) usage; exit 1;; + esac +done +shift $((OPTIND - 1)) + +# Assign variables +TAG=${TAG:-universal} +REGISTRY=${REGISTRY:-} +IMAGES=${@:-houston-frontend codex-frontend} +# Set the image prefix +if [ -n "$REGISTRY" ]; then + IMG_PREFIX="${REGISTRY}/" +else + IMG_PREFIX="wildme/" +fi + +# docker buildx create --name multi-arch-builder --use + +for IMG in $IMAGES; do + echo "Building and Pushing Multi-platform ${IMG_PREFIX}${IMG}:${TAG}" + if [ "$TAG" == "universal" ]; then + docker buildx build \ + --no-cache \ + -t ${IMG_PREFIX}${IMG}:${TAG} \ + --platform linux/amd64,linux/arm64 \ + . + else + docker buildx build \ + --no-cache \ + -t ${IMG_PREFIX}${IMG}:${TAG} \ + --platform linux/amd64,linux/arm64 \ + --push \ + . + fi +done diff --git a/src/AuthenticatedSwitch.jsx b/src/AuthenticatedSwitch.jsx index 1e20d649c..2b645ddb3 100644 --- a/src/AuthenticatedSwitch.jsx +++ b/src/AuthenticatedSwitch.jsx @@ -6,7 +6,7 @@ import { TransitionGroup } from 'react-transition-group'; import AuthenticatedAppHeader from './components/AuthenticatedAppHeader'; import SaveCustomField from './pages/fieldManagement/settings/saveField/SaveField'; import GeneralSettings from './pages/generalSettings/GeneralSettings'; -import ServerStatus from './pages/serverStatus/ServerStatus'; +import SiteStatus from './pages/siteStatus/SiteStatus'; import SplashSettings from './pages/splashSettings/SplashSettings'; import FieldManagement from './pages/fieldManagement/FieldManagement'; import UserManagement from './pages/userManagement/UserManagement'; @@ -20,6 +20,7 @@ import Sighting from './pages/sighting/Sighting'; import AssetGroupSighting from './pages/sighting/AssetGroupSighting'; import Splash from './pages/splash/Splash'; import AssetGroup from './pages/assetGroup/AssetGroup'; +import PendingCitizenScienceSightings from './pages/pendingCitizenScienceSightings/PendingCitizenScienceSightings'; import User from './pages/user/User'; import Users from './pages/user/Users'; import MergeIndividuals from './pages/merge/MergeIndividuals'; @@ -100,7 +101,7 @@ export default function AuthenticatedSwitch({ - + @@ -150,6 +151,9 @@ export default function AuthenticatedSwitch({ + + + diff --git a/src/components/AuthenticatedAppHeader/ActionsPane.jsx b/src/components/AuthenticatedAppHeader/ActionsPane.jsx index fc954a469..c944a9aca 100644 --- a/src/components/AuthenticatedAppHeader/ActionsPane.jsx +++ b/src/components/AuthenticatedAppHeader/ActionsPane.jsx @@ -8,6 +8,7 @@ import MenuItem from '@material-ui/core/MenuItem'; import MenuList from '@material-ui/core/MenuList'; import Divider from '@material-ui/core/Divider'; import SettingsIcon from '@material-ui/icons/Settings'; +import PublicIcon from '@material-ui/icons/SupervisedUserCircle'; import ControlPanelIcon from '@material-ui/icons/PermDataSetting'; import BulkImportIcon from '@material-ui/icons/PostAdd'; import LogoutIcon from '@material-ui/icons/ExitToApp'; @@ -23,6 +24,14 @@ const actions = [ messageId: 'BULK_IMPORT', icon: BulkImportIcon, }, + { + id: 'pending-citizen-science-sightings', + href: '/pending-citizen-science-sightings', + permissionsTest: userData => + userData?.is_admin || userData?.is_data_manager, + messageId: 'PENDING_CITIZEN_SCIENCE_SIGHTINGS', + icon: PublicIcon, + }, { id: 'settings', href: '/settings', @@ -32,7 +41,8 @@ const actions = [ { id: 'control-panel', href: '/admin', - adminOrUserManagerOnly: true, + permissionsTest: userData => + userData?.is_admin || userData?.is_user_manager, messageId: 'CONTROL_PANEL', icon: ControlPanelIcon, }, @@ -46,8 +56,6 @@ export default function NotificationsPane({ const theme = useTheme(); const closePopover = () => setAnchorEl(null); - const isAdministrator = get(userData, 'is_admin', false); - const isUserManager = get(userData, 'is_user_manager', false); const name = get(userData, 'full_name') || 'Unnamed user'; const profileSrc = get(userData, ['profile_fileupload', 'src']); @@ -84,10 +92,10 @@ export default function NotificationsPane({ {actions.map(action => { - const elevatedPermissions = - isAdministrator || isUserManager; - if (action.adminOrUserManagerOnly && !elevatedPermissions) - return null; + if (action.permissionsTest) { + const visible = action.permissionsTest(userData); + if (!visible) return null; + } return ( ; } diff --git a/src/components/cards/Card.jsx b/src/components/cards/Card.jsx index 012064de3..78ab7c8fb 100644 --- a/src/components/cards/Card.jsx +++ b/src/components/cards/Card.jsx @@ -7,7 +7,6 @@ export default function Card({ title, titleId, htmlId = null, - maxHeight = 360, overflow = 'auto', overflowX = 'auto', renderActions, @@ -35,9 +34,7 @@ export default function Card({ {renderActions} -
- {children} -
+
{children}
); diff --git a/src/components/cards/CollaborationsCard.jsx b/src/components/cards/CollaborationsCard.jsx index b0cbd498e..76dec05b5 100644 --- a/src/components/cards/CollaborationsCard.jsx +++ b/src/components/cards/CollaborationsCard.jsx @@ -15,8 +15,9 @@ export default function CollaborationsCard({ htmlId = null, }) { const intl = useIntl(); - const [activeCollaboration, setActiveCollaboration] = - useState(null); + const [activeCollaboration, setActiveCollaboration] = useState( + null, + ); const [ collabDialogButtonClickLoading, setCollabDialogButtonClickLoading, @@ -24,9 +25,12 @@ export default function CollaborationsCard({ const { data, loading } = useGetMe(); - useEffect(() => { - setCollabDialogButtonClickLoading(false); - }, [data]); + useEffect( + () => { + setCollabDialogButtonClickLoading(false); + }, + [data], + ); const collaborations = get(data, ['collaborations'], []); const tableData = collaborations.map(collaboration => { @@ -139,6 +143,7 @@ export default function CollaborationsCard({ columns={columns} data={tableData} idKey="guid" + tableContainerStyles={{ maxHeight: 600 }} /> ); diff --git a/src/components/cards/CooccurrenceCard.jsx b/src/components/cards/CooccurrenceCard.jsx index f61d197e9..bfd7f8e3d 100644 --- a/src/components/cards/CooccurrenceCard.jsx +++ b/src/components/cards/CooccurrenceCard.jsx @@ -34,6 +34,7 @@ export default function CooccurrenceCard({ }, ]} data={data} + tableContainerStyles={{ maxHeight: 600 }} /> ); diff --git a/src/components/cards/EncountersCard.jsx b/src/components/cards/EncountersCard.jsx index 38ec9e138..fba5fae78 100644 --- a/src/components/cards/EncountersCard.jsx +++ b/src/components/cards/EncountersCard.jsx @@ -34,20 +34,23 @@ export default function EncountersCard({ const mapModeClicked = () => setShowMapView(true); const listModeClicked = () => setShowMapView(false); - const encountersWithLocationData = useMemo(() => { - // hotfix // - if (!encounters) return []; - // hotfix // + const encountersWithLocationData = useMemo( + () => { + // hotfix // + if (!encounters) return []; + // hotfix // - return encounters.map(encounter => ({ - ...encounter, - formattedLocation: formatLocationFromSighting( - encounter, - regionOptions, - intl, - ), - })); - }, [get(encounters, 'length')]); + return encounters.map(encounter => ({ + ...encounter, + formattedLocation: formatLocationFromSighting( + encounter, + regionOptions, + intl, + ), + })); + }, + [get(encounters, 'length')], + ); const tooFewEncounters = encounters.length <= 1; @@ -162,6 +165,7 @@ export default function EncountersCard({ columns={filteredColumns} data={encountersWithLocationData} tableStyles={{ tableLayout: 'fixed' }} + tableContainerStyles={{ maxHeight: 600 }} /> )} {!noEncounters && showMapView &&
Map goes here
} diff --git a/src/components/cards/RelationshipsCard.jsx b/src/components/cards/RelationshipsCard.jsx index d4fc58be2..a2aa7b6bb 100644 --- a/src/components/cards/RelationshipsCard.jsx +++ b/src/components/cards/RelationshipsCard.jsx @@ -341,7 +341,7 @@ export default function RelationshipsCard({ , - + {/* // renderActions={ //
// setShowMapView(true); const listModeClicked = () => setShowMapView(false); - const sightingsWithLocationData = useMemo(() => { - // hotfix // - if (!sightings) return []; - // hotfix // + const sightingsWithLocationData = useMemo( + () => { + // hotfix // + if (!sightings) return []; + // hotfix // - return sightings.map(sighting => ({ - ...sighting, - formattedLocation: formatLocationFromSighting( - sighting, - regionOptions, - intl, - ), - })); - }, [get(sightings, 'length')]); + return sightings.map(sighting => ({ + ...sighting, + formattedLocation: formatLocationFromSighting( + sighting, + regionOptions, + intl, + ), + })); + }, + [get(sightings, 'length')], + ); const allColumns = [ { @@ -149,6 +152,7 @@ export default function SightingsCard({ data={sightings} loading={loading} noResultsTextId={noSightingsMsg} + tableContainerStyles={{ maxHeight: 400 }} /> )} {!noSightings && showMapView && ( diff --git a/src/components/dataDisplays/DataDisplay.jsx b/src/components/dataDisplays/DataDisplay.jsx index 6519d9c7c..f829f7218 100644 --- a/src/components/dataDisplays/DataDisplay.jsx +++ b/src/components/dataDisplays/DataDisplay.jsx @@ -61,9 +61,10 @@ export default function DataDisplay({ sortExternally, searchParams, setSearchParams, - paperStyles = {}, tableStyles = {}, cellStyles = {}, + stickyHeader = true, + tableContainerStyles = {}, ...rest }) { const intl = useIntl(); @@ -80,8 +81,9 @@ export default function DataDisplay({ ); const [filter, setFilter] = useState(''); const [internalSortColumn, setInternalSortColumn] = useState(null); - const [internalSortDirection, setInternalSortDirection] = - useState(null); + const [internalSortDirection, setInternalSortDirection] = useState( + null, + ); const [anchorEl, setAnchorEl] = useState(null); const filterPopperOpen = Boolean(anchorEl); @@ -142,6 +144,7 @@ export default function DataDisplay({ anchorEl={anchorEl} placement="bottom-end" transition + style={{ zIndex: theme.zIndex.appBar - 1 }} > {({ TransitionProps }) => ( @@ -258,9 +261,10 @@ export default function DataDisplay({ ( +
+ + {onDelete && ( + onDelete(value)} + /> + )} +
+ ), + }, + }, + ]; + + const filteredColumns = allColumns?.filter(c => + columns.includes(c.reference), + ); + + return ( + + ); +} diff --git a/src/components/dataDisplays/RelationshipDisplay.jsx b/src/components/dataDisplays/RelationshipDisplay.jsx index c3200b5a5..1fdceb6af 100644 --- a/src/components/dataDisplays/RelationshipDisplay.jsx +++ b/src/components/dataDisplays/RelationshipDisplay.jsx @@ -47,6 +47,7 @@ export default function RelationshipDisplay({ columns={relationshipCols} data={data} loading={loading} + tableContainerStyles={{ maxHeight: 400 }} /> ); } diff --git a/src/constants/queryKeys.js b/src/constants/queryKeys.js index 4260c7159..a0db809d8 100644 --- a/src/constants/queryKeys.js +++ b/src/constants/queryKeys.js @@ -12,6 +12,7 @@ export default { unreadNotifications: 'unreadNotifications', twitterBotTestResults: 'twitterBotTestResults', publicData: 'publicData', + publicAssetGroupSightings: 'publicAssetGroupSightings', sageJobs: ['sage', 'jobs'], }; diff --git a/src/hooks/useOptions.js b/src/hooks/useOptions.js index 139c6b499..9c39c6618 100644 --- a/src/hooks/useOptions.js +++ b/src/hooks/useOptions.js @@ -9,7 +9,8 @@ export default function useOptions() { useSiteSettings(); return useMemo(() => { - if (loading || error) return {}; + if (loading || error) + return { regionOptions: [], speciesOptions: [] }; const backendRegionOptions = get( data, diff --git a/src/models/assetGroupSighting/usePublicAGS.js b/src/models/assetGroupSighting/usePublicAGS.js new file mode 100644 index 000000000..0a1613d77 --- /dev/null +++ b/src/models/assetGroupSighting/usePublicAGS.js @@ -0,0 +1,9 @@ +import queryKeys from '../../constants/queryKeys'; +import useFetch from '../../hooks/useFetch'; + +export default function usePublicAGS() { + return useFetch({ + queryKey: queryKeys.publicAssetGroupSightings, + url: '/asset_groups/sighting/pending/public', + }); +} diff --git a/src/models/individual/useIndividualFieldSchemas.js b/src/models/individual/useIndividualFieldSchemas.js index e0f410609..4a2d518aa 100644 --- a/src/models/individual/useIndividualFieldSchemas.js +++ b/src/models/individual/useIndividualFieldSchemas.js @@ -20,11 +20,7 @@ export default function useIndividualFieldSchemas() { const customFields = get( data, - [ - 'site.custom.customFields.MarkedIndividual', - 'value', - 'definitions', - ], + ['site.custom.customFields.Individual', 'value', 'definitions'], [], ); const customFieldSchemas = customFields.map( diff --git a/src/models/sighting/useSightingFieldSchemas.js b/src/models/sighting/useSightingFieldSchemas.js index bb18564f5..3b42cb269 100644 --- a/src/models/sighting/useSightingFieldSchemas.js +++ b/src/models/sighting/useSightingFieldSchemas.js @@ -74,7 +74,7 @@ export default function useSightingFieldSchemas() { const customFields = get( data, - ['site.custom.customFields.Occurrence', 'value', 'definitions'], + ['site.custom.customFields.Sighting', 'value', 'definitions'], [], ); const customFieldSchemas = customFields.map( diff --git a/src/pages/assetGroup/AGSTable.jsx b/src/pages/assetGroup/AGSTable.jsx index 7653a2abf..47f8fd690 100644 --- a/src/pages/assetGroup/AGSTable.jsx +++ b/src/pages/assetGroup/AGSTable.jsx @@ -73,6 +73,7 @@ export default function AGSTable({ assetGroupSightings }) { data={transformedData} columns={columns} style={{ marginTop: 20 }} + tableContainerStyles={{ maxHeight: 600 }} /> ); } diff --git a/src/pages/assetGroup/AssetGroup.jsx b/src/pages/assetGroup/AssetGroup.jsx index 5fff211e7..8b59946de 100644 --- a/src/pages/assetGroup/AssetGroup.jsx +++ b/src/pages/assetGroup/AssetGroup.jsx @@ -26,18 +26,25 @@ import AGSTable from './AGSTable'; const POLLING_INTERVAL = 5000; // 5 seconds +function isProgressSettled(pipelineStatus) { + const { skipped, failed, complete } = pipelineStatus || {}; + + return skipped || complete || failed; +} + function deriveRefetchInterval(resultData, query) { - const { complete, status } = - resultData?.data?.progress_preparation || {}; + const pipelineStatus = get( + resultData, + 'data.pipeline_status.preparation', + {}, + ); - const error = query.state?.error && { ...query.state.error }; + const isSettled = isProgressSettled(pipelineStatus); - const isProgressSettled = - complete || ['failed', 'cancelled'].includes(status) || error; + const isError = Boolean(query.state?.error); - const refetchInterval = isProgressSettled - ? false - : POLLING_INTERVAL; + const refetchInterval = + isSettled || isError ? false : POLLING_INTERVAL; return refetchInterval; } @@ -86,15 +93,17 @@ export default function AssetGroup() { intl.formatMessage({ id: 'UNNAMED_USER' }); const creatorUrl = `/users/${sightingCreator?.guid}`; - const { - complete: isPreparationProgressComplete, - failed: isPreparationProgressFailed, - cancelled: isPreparationProgressCancelled, - } = get(data, 'progress_preparation', {}); - const showPreparationErrorAlert = - isPreparationProgressFailed || isPreparationProgressCancelled; - const showPreparationInProgressAlert = - !showPreparationErrorAlert && !isPreparationProgressComplete; + const pipelineStatusPreparation = get( + data, + 'pipeline_status.preparation', + {}, + ); + const isPreparationFailed = pipelineStatusPreparation.failed; + + const showPreparationInProgressAlert = !( + isPreparationFailed || + isProgressSettled(pipelineStatusPreparation) + ); return ( @@ -146,7 +155,7 @@ export default function AssetGroup() { )} - {showPreparationErrorAlert && ( + {isPreparationFailed && ( diff --git a/src/pages/fieldManagement/FieldManagement.jsx b/src/pages/fieldManagement/FieldManagement.jsx index 1bd505b9e..b572fad9e 100644 --- a/src/pages/fieldManagement/FieldManagement.jsx +++ b/src/pages/fieldManagement/FieldManagement.jsx @@ -41,11 +41,11 @@ export default function FieldManagement() { ); const customIndividualFields = getCustomFields( siteSettings, - 'MarkedIndividual', + 'Individual', ); const customSightingFields = getCustomFields( siteSettings, - 'Occurrence', + 'Sighting', ); if (loading || error) return null; @@ -87,7 +87,7 @@ export default function FieldManagement() { fields={customIndividualFields} titleId="CUSTOM_INDIVIDUAL_FIELDS" descriptionId="CUSTOM_INDIVIDUAL_FIELDS_DESCRIPTION" - settingName="site.custom.customFields.MarkedIndividual" + settingName="site.custom.customFields.Individual" noFieldsTextId="NO_CUSTOM_INDIVIDUAL_FIELDS_MESSAGE" /> ); diff --git a/src/pages/fieldManagement/settings/CustomFieldTable.jsx b/src/pages/fieldManagement/settings/CustomFieldTable.jsx index 5eff750bd..2ea09b98a 100644 --- a/src/pages/fieldManagement/settings/CustomFieldTable.jsx +++ b/src/pages/fieldManagement/settings/CustomFieldTable.jsx @@ -47,8 +47,9 @@ export default function CustomFieldTable({ const intl = useIntl(); const [deleteField, setDeleteField] = useState(null); const [previewField, setPreviewField] = useState(null); - const [previewInitialValue, setPreviewInitialValue] = - useState(null); + const [previewInitialValue, setPreviewInitialValue] = useState( + null, + ); const { removeCustomField, needsForce, @@ -101,7 +102,9 @@ export default function CustomFieldTable({ /> ); diff --git a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx index dd56235ad..e1a63baf7 100644 --- a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx +++ b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx @@ -12,6 +12,7 @@ import Text from '../../../components/Text'; import categoryTypes from '../../../constants/categoryTypes'; import { RegionEditor } from './defaultFieldComponents/Editors'; import RelationshipEditor from './defaultFieldComponents/RelationshipEditor'; +import SocialGroupsEditor from './defaultFieldComponents/SocialGroupsEditor'; import SpeciesEditor from './defaultFieldComponents/SpeciesEditor'; import { cellRendererTypes } from '../../../components/dataDisplays/cellRenderers'; @@ -37,18 +38,34 @@ const configurableFields = [ type: categoryTypes.individual, Editor: RelationshipEditor, }, + { + id: 'socialGroups', + backendPath: 'social_group_roles', + labelId: 'SOCIAL_GROUPS', + type: categoryTypes.individual, + Editor: SocialGroupsEditor, + }, ]; function getInitialFormState(siteSettings) { - const regions = get(siteSettings, ['site.custom.regions', 'value']); + const regions = get( + siteSettings, + ['site.custom.regions', 'value'], + [], + ); const species = get(siteSettings, ['site.species', 'value'], []); const relationships = get( siteSettings, ['relationship_type_roles', 'value'], [], ); + const socialGroups = get( + siteSettings, + ['social_group_roles', 'value'], + [], + ); - return { regions, species, relationships }; + return { regions, species, relationships, socialGroups }; } export default function DefaultFieldTable({ @@ -138,6 +155,13 @@ export default function DefaultFieldTable({ }); if (response?.status === 200) onCloseEditor(); } + if (editField?.id === 'socialGroups') { + const response = await putSiteSetting({ + property: editField.backendPath, + data: formSettings.socialGroups, + }); + if (response?.status === 200) onCloseEditor(); + } }} > {error ? ( @@ -174,6 +198,7 @@ export default function DefaultFieldTable({ variant="secondary" columns={tableColumns} data={configurableFields} + tableContainerStyles={{ maxHeight: 300 }} /> ); diff --git a/src/pages/fieldManagement/settings/constants/customFieldTypes.js b/src/pages/fieldManagement/settings/constants/customFieldTypes.js index f1750eb2f..e2c4974d7 100644 --- a/src/pages/fieldManagement/settings/constants/customFieldTypes.js +++ b/src/pages/fieldManagement/settings/constants/customFieldTypes.js @@ -1,11 +1,11 @@ export default { individual: { name: 'individual', - backendPath: 'site.custom.customFields.MarkedIndividual', + backendPath: 'site.custom.customFields.Individual', }, sighting: { name: 'sighting', - backendPath: 'site.custom.customFields.Occurrence', + backendPath: 'site.custom.customFields.Sighting', }, encounter: { name: 'encounter', diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/RelationshipEditor.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/RelationshipEditor.jsx index 7614227e2..0f0636720 100644 --- a/src/pages/fieldManagement/settings/defaultFieldComponents/RelationshipEditor.jsx +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/RelationshipEditor.jsx @@ -3,8 +3,8 @@ import { get, uniq } from 'lodash-es'; import { v4 as uuid } from 'uuid'; import ConfigureDefaultField from './ConfigureDefaultField'; -import Button from '../../../../components/Button'; import Text from '../../../../components/Text'; +import Button from '../../../../components/Button'; import Alert from '../../../../components/Alert'; import Type from './relationshipComponents/Type'; diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx new file mode 100644 index 000000000..6b3bd7e73 --- /dev/null +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; + +import { get, filter } from 'lodash-es'; +import InputAdornment from '@material-ui/core/InputAdornment'; +import Switch from '@material-ui/core/Switch'; +import FormGroup from '@material-ui/core/FormGroup'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; + +import TextInput from '../../../../../components/inputs/TextInput'; +import Alert from '../../../../../components/Alert'; +import Text from '../../../../../components/Text'; +import DeleteButton from '../../../../../components/DeleteButton'; + +function deleteRole(roles, roleGuid) { + return filter(roles, role => role?.guid !== roleGuid); +} + +function updateRoleLabel(roles, roleGuid, newRoleLabel) { + const modifiedRoles = roles.map(role => { + if (role?.guid === roleGuid) + return { ...role, label: newRoleLabel }; + return role; + }); + return modifiedRoles; +} + +function updateRoleMultipleInGroupStatus(roles, roleGuid, newStatus) { + const modifiedRoles = roles.map(role => { + if (role?.guid === roleGuid) + return { ...role, multipleInGroup: newStatus }; + return role; + }); + return modifiedRoles; +} + +export default function SocialGroupRole({ + roles, + currentRole, + onChange, +}) { + const intl = useIntl(); + const roleGuid = currentRole?.guid; + const roleLabel = currentRole?.label; + + const checked = get(currentRole, 'multipleInGroup', false); + + if (!roleGuid) + return ( + + + + ); + return ( +
+ + { + onChange( + updateRoleMultipleInGroupStatus( + roles, + roleGuid, + e.target.checked, + ), + ); + }} + /> + } + label={intl.formatMessage({ + id: 'ALLOW_MULTIPLE_OF_THIS_ROLE', + })} + /> + + { + onChange(updateRoleLabel(roles, roleGuid, newLabel)); + }} + value={roleLabel} + autoFocus + InputProps={{ + endAdornment: ( + + { + onChange(deleteRole(roles, roleGuid)); + }} + /> + + ), + }} + /> +
+ ); +} diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx new file mode 100644 index 000000000..13ff1d2d7 --- /dev/null +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { useIntl } from 'react-intl'; + +import { v4 as uuid } from 'uuid'; +import { get, uniq, map, some, filter } from 'lodash-es'; + +import ConfigureDefaultField from './ConfigureDefaultField'; +import Text from '../../../../components/Text'; +import Button from '../../../../components/Button'; +import SocialGroupRole from './SocialGroupComponents/SocialGroupRole'; + +function createRole(roles) { + const newRoleGuid = uuid(); + return [ + ...roles, + { guid: newRoleGuid, label: '', multipleInGroup: true }, + ]; +} + +function validateSocialGroups(roles, intl) { + const errors = []; + const roleLabels = roles.map(role => role?.label); + if (some(roleLabels, roleLabel => !roleLabel)) { + errors.push( + intl.formatMessage({ + id: 'ONE_OR_MORE_ROLES_MISSING_LABELS', + }), + ); + } + const uniqueRoleLabels = uniq(roleLabels); + if (uniqueRoleLabels.length !== roleLabels.length) + errors.push( + intl.formatMessage({ + id: 'TWO_OR_MORE_ROLES_SAME_LABEL', + }), + ); + return errors.length > 0 ? errors : null; +} + +export default function SocialGroupsEditor({ + onClose, + onSubmit, + formSettings, + setFormSettings, +}) { + const intl = useIntl(); + const [formErrors, setFormErrors] = useState(null); + function setRoles(roles) { + setFormSettings({ ...formSettings, socialGroups: roles }); + } + + const roles = get(formSettings, 'socialGroups', []); + const safeRoles = filter(roles, role => Boolean(role?.guid)); + + return ( + { + const errors = validateSocialGroups(safeRoles, intl); + setFormErrors(errors); + if (!errors) onSubmit(); + }} + open + error={formErrors?.map(error => ( + + {error} + + ))} + > +
+ +
+ +
+ {map(safeRoles, role => ( + + ))} +
+
+ ); +} diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/SpeciesEditor.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/SpeciesEditor.jsx index 19d224b0e..c5cca2529 100644 --- a/src/pages/fieldManagement/settings/defaultFieldComponents/SpeciesEditor.jsx +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/SpeciesEditor.jsx @@ -167,13 +167,13 @@ export default function SpeciesEditor({ )} + ); } diff --git a/src/pages/match/QueryAnnotationsTable.jsx b/src/pages/match/QueryAnnotationsTable.jsx index 625da4be2..c005dde04 100644 --- a/src/pages/match/QueryAnnotationsTable.jsx +++ b/src/pages/match/QueryAnnotationsTable.jsx @@ -55,6 +55,7 @@ export default function QueryAnnotationsTable({ setSelectedQueryAnnotation(nextSelection); } }} + tableContainerStyles={{ maxHeight: 300 }} /> ); } diff --git a/src/pages/pendingCitizenScienceSightings/PendingCitizenScienceSightings.jsx b/src/pages/pendingCitizenScienceSightings/PendingCitizenScienceSightings.jsx new file mode 100644 index 000000000..506e741e7 --- /dev/null +++ b/src/pages/pendingCitizenScienceSightings/PendingCitizenScienceSightings.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useIntl } from 'react-intl'; + +import usePublicAGS from '../../models/assetGroupSighting/usePublicAGS'; +import useDocumentTitle from '../../hooks/useDocumentTitle'; + +import MainColumn from '../../components/MainColumn'; +import LoadingScreen from '../../components/LoadingScreen'; +import SadScreen from '../../components/SadScreen'; +import EntityHeader from '../../components/EntityHeader'; +import HoustonSightingsDisplay from '../../components/dataDisplays/HoustonSightingsDisplay'; + +const columns = [ + 'date', + 'location', + 'submissionTime', + 'stage', + 'actions', +]; + +export default function PendingCitizenScienceSightings() { + const intl = useIntl(); + + const { data, loading, error, statusCode } = usePublicAGS(); + + useDocumentTitle('PENDING_CITIZEN_SCIENCE_SIGHTINGS'); + + if (error) return ; + if (loading) return ; + + return ( + + + + + ); +} diff --git a/src/pages/settings/Settings.jsx b/src/pages/settings/Settings.jsx index 3b810ddad..4ebb865c3 100644 --- a/src/pages/settings/Settings.jsx +++ b/src/pages/settings/Settings.jsx @@ -44,8 +44,12 @@ export default function Settings() { const [formValues, setFormValues] = useState({}); useEffect(() => { const initialValues = getInitialFormValues(schemas, data); - setFormValues({ ...initialValues, ...formValues }); - }, [formValues, schemas, data]); + + setFormValues(prevFormValues => ({ + ...initialValues, + ...prevFormValues, + })); + }, [schemas, data]); const backendValues = useMemo( () => getInitialFormValues(schemas, data), diff --git a/src/pages/sighting/SearchSightings.jsx b/src/pages/sighting/SearchSightings.jsx index 69615f6fb..16a680634 100644 --- a/src/pages/sighting/SearchSightings.jsx +++ b/src/pages/sighting/SearchSightings.jsx @@ -5,7 +5,7 @@ import useFilterSightings from '../../models/sighting/useFilterSightings'; import SearchPage from '../../components/SearchPage'; import FilterPanel from '../../components/FilterPanel'; import SearchFilterList from '../../components/SearchFilterList'; -import SightingsDisplay from '../../components/dataDisplays/SightingsDisplay'; +import ElasticsearchSightingsDisplay from '../../components/dataDisplays/ElasticsearchSightingsDisplay'; import Paginator from '../../components/dataDisplays/Paginator'; const rowsPerPage = 100; @@ -47,7 +47,7 @@ export default function SearchSightings() { /> } > - + - +
{usersError ? ( {collaborationError ? (