diff --git a/.github/workflows/build_codex_fe.yml b/.github/workflows/build_codex_fe.yml index 099d5307c..a794937b2 100644 --- a/.github/workflows/build_codex_fe.yml +++ b/.github/workflows/build_codex_fe.yml @@ -49,6 +49,7 @@ jobs: deploy: name: Docker image build + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest strategy: fail-fast: false @@ -142,47 +143,3 @@ jobs: run: | ./scripts/buildx.docker.sh -t nightly -r ghcr.io/wildmeorg/codex-frontend -i 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/build_houston_fe.yml b/.github/workflows/build_houston_fe.yml index 307786f12..147ab6c17 100644 --- a/.github/workflows/build_houston_fe.yml +++ b/.github/workflows/build_houston_fe.yml @@ -49,6 +49,7 @@ jobs: deploy: name: Docker image build + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest strategy: fail-fast: false @@ -141,48 +142,4 @@ jobs: if: github.event_name == 'schedule' run: | ./scripts/buildx.docker.sh -t nightly -r ghcr.io/wildmeorg/codex-frontend -i houston-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/README.md b/README.md index 683210978..c774f3614 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,35 @@ # codex-frontend -The frontend for Codex, written in React with Material UI components. This project is not in production yet, but it is under active development. +The frontend for Codex, written in React with Material UI components. ## Contributions Found a bug? Submit a report [here](https://github.com/WildMeOrg/codex-frontend/issues/new). -Developer contributions are very much appreciated. Refer to the [contribution guide](https://github.com/WildMeOrg/codex-frontend/blob/develop/docs/contribution-guide.md). If you are a Python dev looking to help with our project, take a look at the [backend](https://github.com/WildMeOrg/houston). If you are a data scientist looking to help, take a look at [WBIA](https://github.com/WildMeOrg/wildbook-ia). +Developer contributions are very much appreciated. Refer to the [contribution guide](https://github.com/WildMeOrg/codex-frontend/blob/develop/docs/contribution-guide.md) or reach out to us on the [Wild Me discord](https://discord.gg/zw4tr3RE4R). -We are also looking for help from designers and 3D modelers! Please send an email to ben@wildme.org if you are interested. +## Setup and Installation +To install a working codex-frontend dev environment, you'll need install the backend by following the instructions in the [Houston](https://github.com/WildMeOrg/houston). -## Development - -Just run +Instructions assume that you are signed into your GitHub account, have admin access to your OS's terminal, and have Git, [Docker](https://docs.docker.com/get-docker/), and docker-compose installed. Instructions are written for linux with limited support for other OSs. -```js -npm install -npm start +1. From your browser in the top right corner of the [codex-frontend repo](https://github.com/WildMeOrg/codex-frontend), click the **Fork** button. Confirm the be redirected to your own fork (check the url for your USERNAME in the namespace). +1. In your terminal, enter the command `git clone https://github.com/USERNAME/codex-frontend` +1. Once you have both houston and codex-frontend repos available, move to your codex-frontend repo with `cd codex-frontend` +1. Add a reference to the original codex-frontend repo to denote it as an upstream repo. ``` - -The development environment is mostly used on OSX but should work on Windows as well. Use Powershell or edit the `npm start:win32` command to set `NODE_ENV` using the appropriate syntax for your preferred shell. - -If you are doing development, you should set up [husky](https://github.com/typicode/husky) so that the linter runs before you commit. All you need to do is run the command `npm run prepare`. - -Unfortunately, the frontend isn't very useful without a backend. To run the frontend in its proper context, you need to [install Docker](https://docs.docker.com/get-docker/), clone [Houston](https://github.com/WildMeOrg/houston), and edit `docker-compose.codex.yml`. Modify the `dev-frontend` image to point to your local copy of the code in the following manner: - +git remote add upstream https://github.com/WildMeOrg/codex-frontend +git fetch upstream ``` -dev-frontend: - ... - volumes: - - ./dev-frontend/docker-entrypoint.sh:/docker-entrypoint.sh - - ../../_frontend:/code <---- delete this line! - - /location/of/frontend/repository:/code <----- add this line! -``` - -After that you should be able to run the following commands: - +1. Set up (husky)[https://github.com/typicode/husky] so that the linter runs before you commit. + 1. Run the command `npm install husky -D` + 1. `npm run prepare` +1. In the `houston` project, edit the `docker-compose.codex.yml` file to redirect to your local copy of the dev-frontend code. + 1. In the file, find `dev-frontend:` and navigate to `volumes:` + 1. Delete the line `- ../../_frontend:/code` + 1. In its place, enter `- LOCATION/codex-frontend:/code` where LOCATION is the relative path to your local copy. + 1. Save your changes. +1. Back in the terminal, `cd` to your local `houston` copy and run the following commands, noting that the docker-compose steps may take a long time to run the first time: ``` ./scripts/codex/activate.sh ./scripts/codex/build.frontend.sh @@ -43,9 +37,40 @@ docker-compose pull docker-compose build docker-compose up -d ``` +1. In your browser, go to `https://localhost:84`. If you see a welcome screen with Codex Initialized, you're ready to get started! + 1. If you see a blank screen or a 502 nginx error, run `docker-compose down` to bring down the instance. + 1. Allocate more memory to Docker. + * If you're on linux, enter the command `sudo sysctl -w vm.max_map_count=262144`. This will need to be done each time you restart your system. Instead, add `vm.max_map_count = 262144` to your system file `/etc/sysctl.conf` + * If you're leveraging Docker Desktop, go to Settings > Resources and adjust the Memory limit. + 1. Run `docker-compose up` again. + +### App Setup +1. At http://localhost:84, work through the admin initial setup. +1. Navigate to Set Settings > Custom Fields +1. Add Species +1. Add Regions -Note: `docker-compose pull` takes a very long time to finish the first time around! But when it's all done you should be able to see the frontend on `localhost:80`. If you see a 502 nginx error instead, you may need to increase the amount of memory available to Docker. 6GB memory and 2GB swap works for my system. +## Development +### Create Local Branch +1. Verify you are on the main branch. The branch you have checked out will be used as the base for your new branch, so you typically want to start from main. +`git checkout main` +1. Create your feature branch (FEATUREBRANCHNAME). It can be helpful to include the issue number (ISSUENUMBER) you are working to address. +`git branch ISSUENUMBER-FEATUREBRANCHNAME` +1. Change to your feature branch so your changes are grouped together. +`git checkout ISSUENUMBER-FEATUREBRANCHNAME` +1. Update your branch (this is not needed if you just created a new branch, but is a good habit to get into). +`git pull upstream` + +### Git Commands +As you make the code changes necessary for the issue you're working on, you may find the following git commands useful. +* `git log`: latest commits of current branch +* `git status`: current staged and unstaged modifications +* `git diff --staged`: the differences between the staging area and the last commit +* `git add `: add files that have changes to staging in preparation for commit +* `git commit`: commits the staged files, opens a text editor for you to write a commit log + +### Docker Commands The following commands are helpful when developing in this manner: - `docker-compose up -d`: Run all containers in daemon mode, so you don't see all the logs running together. @@ -63,6 +88,26 @@ npm run build -- --env=houston=http://localhost:9999 npm run build -- --env=houston=relative // use relative file paths for API requests ``` +## Submit PR +Up to this point, all changes have been done to your local copy of codex-frontend. You need to push the new commits to a remote branch to start the PR process. +Note: Now is the time to clean up your PR if you choose to squash commits. If you're looking for more information on these practices, see this [pull request tutorial](https://yangsu.github.io/pull-request-tutorial). + +1. Push to the remote version of your branch `git push ISSUENUMBER-FEATUREBRANCHNAME` If you want to push upstream directly, use `git push origin ISSUENUMBER-FEATUREBRANCHNAME` +1. When prompted, provide your username and GitHub Personal Access Token. If you do not have a GitHub Personal Access Token, or do not have one with the correct permissions for your newly forked repository, you will need to [create a Personal Access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token). +1. Check the fork's page on GitHub to verify that you see a new branch with your added commits. You should see a line saying "This branch is X commits ahead" and a **Pull request** link. +1. Click the **Pull request** link to open a form that says "Able to merge". (If it says there are merge conflicts, go to the [Wild Me Development Discord](https://discord.gg/zw4tr3RE4R) for help). +1. Use an explicit title for the PR and provide details in the comment area. Details can include text, files, or images, and should provide details as to what was done and why design decisions were made. +1. Click ** Create a pull request**. + +### Respond to Feedback +At this point, it's on us to get you feedback on your submission. Someone from the Wild ME team will review the project and provide any feedback that may be necessary. If changes are recommended, you'll need to checkout the branch you're working from, update the branch, and make these changes locally. + +1. `git checkout ISSUENUMBER-FEATUREBRANCHNAME` +1. `git pull upstream main` +1. Make required changes +1. `git add ` for all files impacted by changes +1. `git commit` + ## Thanks - Thanks to [Lokalise](https://lokalise.com/) for providing translation management services. diff --git a/locale/en.json b/locale/en.json index d21b17c66..1e39ab043 100644 --- a/locale/en.json +++ b/locale/en.json @@ -11,6 +11,7 @@ "SOCIAL_GROUP_SUBHEADER": "Social group created on {dateCreated}", "ALL_SOCIAL_GROUPS": "All social groups", "BULK_IMPORT": "Bulk import", + "DATA_PAGE" : "Data page", "LIME": "Lime", "LABEL": "Label", "TYPE": "Type", @@ -86,7 +87,9 @@ "COLLABORATION_VIEW_REQUEST_BRIEF": "{userName} has requested to collaborate with you", "COLLABORATION_VIEW_REQUEST_DESCRIPTION": "{userName} has requested to collaborate with you. If you grant access to {userName}, you will be able to view each other's data.", "COLLABORATION_EDIT_REQUEST_BRIEF": "{userName} has requested an edit collaboration with you", + "COLLABORATION_EXPORT_REQUEST_BRIEF": "{userName} has requested an export collaboration with you", "COLLABORATION_EDIT_REQUEST_DESCRIPTION": "{userName} has requested an edit collaboration with you. If you grant access to {userName}, you will be able to edit each other's data.", + "COLLABORATION_EXPORT_REQUEST_DESCRIPTION": "{userName} has requested an export collaboration with you. If you grant access to {userName}, you will be able to export each other's data.", "VIEW_USER_PROFILE": "View user profile", "SEARCH_RADIUS_LABEL": "Search radius (kilometers)", "SEARCH_CENTER_POINT": "Search center point", @@ -200,6 +203,7 @@ "SEARCH_ITIS_SPECIES": "Search ITIS species", "SEARCH_INDIVIDUALS_INSTRUCTION": "Search for an individual by name or guid", "SEARCH_SIGHTINGS_INSTRUCTION": "Search for a sighting by location, owner or guid", + "SEARCH_ANIMALS_INSTRUCTION": "Search for an animal by location or owner", "SEARCH_USER_INSTRUCTION": "Search for a user by name or email", "PRAIRIE": "Prairie", "EDIT_USER_METADATA": "Edit user metadata", @@ -547,6 +551,8 @@ "SPECIAL_INPUTS": "SPECIAL FIELDS", "SIGHTING_SEARCH_RESULT_PRIMARY_TEXT": "{date} sighting in {region}", "SIGHTING_SEARCH_RESULT_SECONDARY_TEXT": "Reported by {name} on {date}", + "ANIMAL_SEARCH_RESULT_PRIMARY_TEXT": "{date} sighting in {region}", + "ANIMAL_SEARCH_RESULT_SECONDARY_TEXT": "Reported by {name} on {date}", "SELECTED_QUERY_ANNOTATION": "Selected query annotation", "IDENTIFICATION_FINISHED_TIME": "Identification finished on {time}.", "SELECTED_MATCH_CANDIDATE": "Selected match candidate", @@ -637,6 +643,7 @@ "SPECIES:": "Species:", "EXPLORE_SIGHTINGS_CAPITALIZED": "Explore Sightings", "EXPLORE_SIGHTINGS": "Explore sightings", + "EXPLORE_ANIMALS": "Explore animals", "ATTRIBUTES": "Attributes", "RELATIONSHIPS": "Relationships", "RELATIONSHIPS_DESCRIPTION": "Known or observed social and familial relationships.", @@ -986,6 +993,7 @@ "COMPONENT_COMMIT_HASH": "{component} commit hash: ", "INDIVIDUAL_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any individuals.", "SIGHTING_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any sightings.", + "ENCOUNTER_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any sightings.", "POTENTIAL_COLLABORATOR_SEARCH_NO_RESULTS": "Your search \"{searchTerm}\" did not match any potential collaborators.", "SEARCH_SERVER_ERROR": "A server error occurred while attempting to search.", "CONFIGURATION_SITE_NAME_LABEL": "Site name", @@ -1043,7 +1051,9 @@ "COLLABORATION_APPROVED_TITLE": "Collaboration approved", "COLLABORATION_REVOKE_BRIEF": "{userName} revoked your collaboration request", "EDIT_COLLABORATION_APPROVED": "{userName} approved your collaboration edit request", + "EXPORT_COLLABORATION_APPROVED": "{userName} approved your collaboration export request", "EDIT_COLLABORATION_REVOKED": "{userName} revoked edit privileges for your collaboration with them", + "EXPORT_COLLABORATION_REVOKED": "{userName} revoked export privileges for your collaboration with them", "COLLABORATION_REVOKED_BY_MANAGER": "A collaboration was revoked by a user manager.", "INDIVIDUAL_MERGE_REQUEST_MESSAGE_DETAILED": "{userName} requested that {yourIndividualName} be merged with {theirIndividualName}. You have until {formattedDeadline} to respond.", "INDIVIDUAL_MERGE_REQUEST_MESSAGE": "{userName} wants to merge {yourIndividualName} with {theirIndividualName}", @@ -1054,8 +1064,11 @@ "COLLABORATION_ESTABLISHED_BY_USER_MANAGER": "Collaboration established by a user manager", "COLLABORATION_REVOKE_TITLE": "Revoke collaboration", "COLLABORATION_EDIT_REQUEST_TITLE": "Edit collaboration request", + "COLLABORATION_EXPORT_REQUEST_TITLE": "Export collaboration request", "COLLABORATION_EDIT_APPROVED_TITLE": "Collaboration edit approved", + "COLLABORATION_EXPORT_APPROVED_TITLE": "Collaboration export approved", "COLLABORATION_EDIT_REVOKE_TITLE": "Revoke edit privileges", + "COLLABORATION_EXPORT_REVOKE_TITLE": "Revoke export privileges", "COLLABORATION_MANAGER_REVOKE_TITLE": "Revoke manager-assigned collaboration", "INDIVIDUAL_MERGE_REQUEST_TITLE": "Individual merge request", "INDIVIDUAL_MERGE_COMPLETE_TITLE": "Individual merge completed", @@ -1139,8 +1152,8 @@ "ROLE_GUID_MISSING": "Role is missing ID", "UNFINISHED_OPTIONS": "Options must have valid values and labels and unique values", "PROGRESS_STATISTICS_UNKNOWN_PROGRESS": "unknown progress", - "PROGRESS_STATISTICS_UNKNOWN_ETA_&_UNKNOWN_QUEUE": "Unknown time remaining. Queued behind an unknown number of jobs.", - "PROGRESS_STATISTICS_UNKNOWN_ETA_&_QUEUE": "Unknown time remaining. Queued behind {ahead, number} {ahead, plural, one {job} other {jobs}}.", + "PROGRESS_STATISTICS_UNKNOWN_ETA_&_UNKNOWN_QUEUE": "Estimated time to complete failed to calculate. If processing does not complete within a day, re-run the job. Queued behind an unknown number of jobs.", + "PROGRESS_STATISTICS_UNKNOWN_ETA_&_QUEUE": "Estimated time to complete failed to calculate. If processing does not complete within a day, re-run the job. Queued behind {ahead, number} {ahead, plural, one {job} other {jobs}}.", "PROGRESS_STATISTICS_WRAPPING_ETA_&_UNKNOWN_QUEUE": "Wrapping up... Queued behind an unknown number of jobs.", "PROGRESS_STATISTICS_WRAPPING_ETA_&_QUEUE": "Wrapping up... Queued behind {ahead, number} {ahead, plural, one {job} other {jobs}}.", "PROGRESS_STATISTICS_ETA_&_UNKNOWN_QUEUE": "{timeRemaining} left. Queued behind an unknown number of jobs.", @@ -1200,15 +1213,23 @@ "ADD_COLLABORATION": "Add collaboration", "EMAIL": "Email", "EDIT_COLLABORATION_REVOKED_BY_USER_MANAGER": "Edit collaboration revoked by a user manager.", + "EXPORT_COLLABORATION_REVOKED_BY_USER_MANAGER": "Export collaboration revoked by a user manager.", "EDIT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER": "An edit-level collaboration with {otherUserNameForManagerNotifications} was revoked by a user manager {managerName}.", + "EXPORT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER": "An export-level collaboration with {otherUserNameForManagerNotifications} was revoked by a user manager {managerName}.", "COLLABORATION_EDIT_DENIED": "Collaboration edit denied", + "COLLABORATION_EXPORT_DENIED": "Collaboration export denied", "EDIT_COLLABORATION_DENIED_MESSAGE": "{userName} denied your collaboration edit request", + "EXPORT_COLLABORATION_DENIED_MESSAGE": "{userName} denied your collaboration export request", "COLLABORATION_DENIED_BY_USER_MANAGER": "Collaboration denied by a user manager", "COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE": "Your collaboration with {otherUserNameForManagerNotifications} was denied by a user manager {managerName}.", "EDIT_COLLABORATION_DENIED_BY_USER_MANAGER": "Edit-level collaboration denied by a user manager", + "EXPORT_COLLABORATION_DENIED_BY_USER_MANAGER": "Export-level collaboration denied by a user manager", "EDIT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE": "An edit-level collaboration with {otherUserNameForManagerNotifications} was denied by a user manager {managerName}.", + "EXPORT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE": "An export-level collaboration with {otherUserNameForManagerNotifications} was denied by a user manager {managerName}.", "EDIT_COLLABORATION_APPROVED_BY_USER_MANAGER": "Edit collaboration approved by a user manager", + "EXPORT_COLLABORATION_APPROVED_BY_USER_MANAGER": "Export collaboration approved by a user manager", "EDIT_COLLABORATION_WAS_APPROVED_BY_A_USER_MANAGER": "An edit-level collaboration with {otherUserNameForManagerNotifications} was approved by a user manager {managerName}.", + "EXPORT_COLLABORATION_WAS_APPROVED_BY_A_USER_MANAGER": "An export-level collaboration with {otherUserNameForManagerNotifications} was approved by a user manager {managerName}.", "UNNAMED_MANAGER": "Unnamed manager", "STILL_GENERATING_LIST": "Still generating list; this can take up to one minute.", "SEARCH_TIMED_OUT_WHILE_TRYING_TO_CONNECT_TO_ITIS": "Search timed out while trying to connect to ITIS database. Try again later.", @@ -1271,5 +1292,22 @@ "CONFIRM_NO_MATCH" : "Confirm no match", "NUMBER_OF_INDIVIDUALS" : "Number of individuals", "NUMBER_OF_ENCOUNTERS" : "Number of animals", - "ASSIGN" : "Assign" + "NUMBER_OF_ANNOTATIONS" : "Number of annotations", + "ASSIGN" : "Assign", + "STATE" : "State", + "ENCOUNTER_TIME" : "Time of encounter", + "LATITUDE" : "Latitude", + "LONGTITUDE" : "Longtitude", + "MY_PENDING_SIGHTINGS" : "My pending sightings", + "MY_SIGHTINGS" : "My sightings", + "MY_UNAPPROVED_SIGHTINGS" : "My unapproved sightings", + "TotalAccount" : "Total Account: {totalAccount}", + "MANAGE_REGIONS" : "Manage Regions", + "EXPORT_RESULT" : "Export result", + "EXPORT_ACCESS_RESTRICTED_WARNING" : "No results meet export criteria. Consider adjusting your search or requesting export collaborations.", + "OK" : "Ok", + "EXPORT" : "Export", + "ACCESS" : "Access", + "COLLABORATE" : "Collaborate", + "COLLABORATION_STATE_EXPORT" : "Export" } diff --git a/src/AuthenticatedSwitch.jsx b/src/AuthenticatedSwitch.jsx index ec70c46fc..eff39216b 100644 --- a/src/AuthenticatedSwitch.jsx +++ b/src/AuthenticatedSwitch.jsx @@ -7,7 +7,6 @@ import AuthenticatedAppHeader from './components/AuthenticatedAppHeader'; import SaveCustomField from './pages/fieldManagement/settings/saveField/SaveField'; import GeneralSettings from './pages/generalSettings/GeneralSettings'; import SiteStatus from './pages/siteStatus/SiteStatus'; -import SplashSettings from './pages/splashSettings/SplashSettings'; import FieldManagement from './pages/fieldManagement/FieldManagement'; import UserManagement from './pages/userManagement/UserManagement'; import AdminActions from './pages/adminActions/AdminActions'; @@ -36,19 +35,21 @@ import FourOhFour from './pages/fourohfour/FourOhFour'; import useSiteSettings from './models/site/useSiteSettings'; import SearchIndividuals from './pages/individual/SearchIndividuals'; import SearchSightings from './pages/sighting/SearchSightings'; +import SearchAnimals from './pages/sighting/encounters/SearchAnimals'; import SiteSetup from './pages/setup/SiteSetup'; import MatchSighting from './pages/match/MatchSighting'; import AuditLog from './pages/devTools/AuditLog'; import Welcome from './pages/auth/Welcome'; import EmailVerified from './pages/auth/EmailVerified'; import Home from './pages/home/Home'; -import Preferences from './pages/preferences/Preferences'; import ResendVerificationEmail from './pages/auth/ResendVerificationEmail'; import Footer from './components/Footer'; import { defaultCrossfadeDuration } from './constants/defaults'; import Requests from './pages/setup/Requests'; import SpeciesManagement from './pages/fieldManagement/SpeciesManagement'; +import RegionManagement from './pages/fieldManagement/RegionManagement'; import ChangeLog from './pages/changeLog/ChangeLog'; +import DataPage from './pages/dataPage/DataPage'; export default function AuthenticatedSwitch({ emailNeedsVerification, @@ -100,9 +101,6 @@ export default function AuthenticatedSwitch({ ) : ( - - - @@ -118,6 +116,9 @@ export default function AuthenticatedSwitch({ + + + @@ -130,9 +131,6 @@ export default function AuthenticatedSwitch({ - - - @@ -166,6 +164,9 @@ export default function AuthenticatedSwitch({ + + + @@ -184,6 +185,9 @@ export default function AuthenticatedSwitch({ + + + @@ -214,9 +218,12 @@ export default function AuthenticatedSwitch({ - + + + + diff --git a/src/components/AuthenticatedAppHeader/ActionsPane.jsx b/src/components/AuthenticatedAppHeader/ActionsPane.jsx index 518b73238..cb084c4a7 100644 --- a/src/components/AuthenticatedAppHeader/ActionsPane.jsx +++ b/src/components/AuthenticatedAppHeader/ActionsPane.jsx @@ -10,6 +10,7 @@ import Divider from '@material-ui/core/Divider'; import PublicIcon from '@material-ui/icons/SupervisedUserCircle'; import ControlPanelIcon from '@material-ui/icons/PermDataSetting'; import BulkImportIcon from '@material-ui/icons/PostAdd'; +import InsertChartOutlinedOutlinedIcon from '@material-ui/icons/InsertChartOutlined'; import LogoutIcon from '@material-ui/icons/ExitToApp'; import Link from '../Link'; @@ -17,6 +18,12 @@ import Text from '../Text'; import defaultProfilePhoto from '../../assets/defaultProfile.jpg'; const actions = [ + { + id: 'data-page', + href: '/data-page', + messageId: 'DATA_PAGE', + icon: InsertChartOutlinedOutlinedIcon, + }, { id: 'bulk-import', href: '/bulk-import', @@ -87,7 +94,7 @@ export default function NotificationsPane({ onClose={closePopover} > - + + {closePopover => ( + <> + {showDivider && } + {resultsCurrent && noResults && ( + + )} + {resultsCurrent && error && ( + + )} + + {mappableSearchResults.map(encounter => { + const encounterGuid = encounter?.guid; + const sightingGuid = encounter?.sighting_guid; + const ownerName = get( + encounter, + ['owner', 'full_name'], + 'Unknown User', + ); + const regionLabel = encounter?.locationId_value; + const createdDate = formatDate( + encounter?.created, + true, + ); + const encounterDate = formatSpecifiedTime( + encounter?.time, + encounter?.timeSpecificity, + ); + const avatarLetter = ownerName[0].toUpperCase(); + + return ( + + } + secondaryText={ + + } + /> + ); + })} + + + )} + + ); +} diff --git a/src/components/AuthenticatedAppHeader/NotificationsPane.jsx b/src/components/AuthenticatedAppHeader/NotificationsPane.jsx index cfb378b4f..b5cadf099 100644 --- a/src/components/AuthenticatedAppHeader/NotificationsPane.jsx +++ b/src/components/AuthenticatedAppHeader/NotificationsPane.jsx @@ -101,7 +101,7 @@ export default function NotificationsPane({ const defaultButtonPath = get( currentNotificationSchema, 'buttonPath', - '/#collab-card', + '/user-profile/#collab-card', ); const buttonPath = deriveButtonPath ? deriveButtonPath(mergedIndividualGuid) diff --git a/src/components/AuthenticatedAppHeader/index.js b/src/components/AuthenticatedAppHeader/index.js index 0aed4a728..c9775ac03 100644 --- a/src/components/AuthenticatedAppHeader/index.js +++ b/src/components/AuthenticatedAppHeader/index.js @@ -24,6 +24,7 @@ import NotificationsPane from './NotificationsPane'; import ActionsPane from './ActionsPane'; import IndividualsButton from './IndividualsButton'; import SightingsButton from './SightingsButton'; +import AnimalsButton from './AnimalsButton'; import queryKeys from '../../constants/queryKeys'; export default function AppHeader() { @@ -99,6 +100,7 @@ export default function AppHeader() { + )} diff --git a/src/components/EditUserMetadata.jsx b/src/components/EditUserMetadata.jsx index 05b52190d..ba60cee72 100644 --- a/src/components/EditUserMetadata.jsx +++ b/src/components/EditUserMetadata.jsx @@ -4,6 +4,15 @@ import { get, map, omit, find } from 'lodash-es'; import DialogContent from '@material-ui/core/DialogContent'; import DialogActions from '@material-ui/core/DialogActions'; +import Typography from '@material-ui/core/Typography'; +import { useTheme } from '@material-ui/core/styles'; +import Chip from '@material-ui/core/Chip'; +import { MailOutline } from '@material-ui/icons'; +import AccountCircleOutlinedIcon from '@material-ui/icons/AccountCircleOutlined'; +import ForumOutlinedIcon from '@material-ui/icons/ForumOutlined'; +import AccountBalanceOutlined from '@material-ui/icons/AccountBalanceOutlined'; +import PlaceOutlined from '@material-ui/icons/PlaceOutlined'; +import { FormattedMessage } from 'react-intl'; import CustomAlert from './Alert'; import { useReplaceUserProperties } from '../models/users/usePatchUser'; import { sanitizeTwitterHandle } from '../utils/formatters'; @@ -12,6 +21,10 @@ import InputRow from './fields/edit/InputRow'; import Button from './Button'; import PasswordVerificationAlert from './PasswordVerificationAlert'; import StandardDialog from './StandardDialog'; +import EntityHeader from './EntityHeader'; +import BigAvatar from './profilePhotos/BigAvatar'; +import Text from './Text'; +import UserProfileMetadataWrap from './UserProfileMetadataWrap'; function getInitialFormValues(schema) { return schema.reduce((memo, field) => { @@ -31,6 +44,14 @@ const twitterMetadataKey = twitterSchema?.userMetadataKey; export default function EditUserMetadata({ open, userId, + imageGuid, + imageSrc, + name, + refreshUserData, + userDataLoading, + communityUsername, + dateCreated, + highestRoleLabelId, metadata, onClose, }) { @@ -41,6 +62,8 @@ export default function EditUserMetadata({ clearError, } = useReplaceUserProperties(); + const theme = useTheme(); + const [fieldValues, setFieldValues] = useState({}); const [passwordRequired, setPasswordRequired] = useState(false); const [password, setPassword] = useState(''); @@ -51,36 +74,134 @@ export default function EditUserMetadata({ return ( + + } + > +
+
+ {communityUsername && ( + <> + + {`@${communityUsername}`} + +
+ + )} + + +
+
+ } + style={{ + marginTop: 14, + color: theme.palette.common.black, + backgroundColor: theme.palette.primary.main + '26', + }} + /> + + {metadata.map(field => { if (!field.editable) return null; if (!field.editComponent) return null; // temporary stopgap const value = get(fieldValues, field.name, ''); const fieldProps = field.editComponentProps || {}; + const labelId = get(field, 'labelId'); return ( - - { - const newFormValues = { - ...fieldValues, - [field.name]: newValue, - }; - setFieldValues(newFormValues); - }} - /> - +
+ {labelId === 'FULL_NAME' && ( + + + + )} + {labelId === 'PROFILE_LABEL_EMAIL' && ( + + + + )} + {labelId === 'PROFILE_LABEL_FORUM_ID' && ( + + + + )} + {labelId === 'PROFILE_LABEL_AFFILIATION' && ( + + + + )} + {labelId === 'PROFILE_LABEL_LOCATION' && ( + + + + )} + + + { + const newFormValues = { + ...fieldValues, + [field.name]: newValue, + }; + setFieldValues(newFormValues); + }} + /> + +
); })} diff --git a/src/components/EntityHeader.jsx b/src/components/EntityHeader.jsx index a87d0f206..831e93569 100644 --- a/src/components/EntityHeader.jsx +++ b/src/components/EntityHeader.jsx @@ -13,6 +13,7 @@ export default function EntityHeader({ renderAvatar, renderTabs, codexID, + noDivider=false, }) { const theme = useTheme(); return ( @@ -81,7 +82,7 @@ export default function EntityHeader({ - + {!noDivider &&} ); } \ No newline at end of file diff --git a/src/pages/preferences/Preferences.jsx b/src/components/PreferenceModal.jsx similarity index 50% rename from src/pages/preferences/Preferences.jsx rename to src/components/PreferenceModal.jsx index feb52080b..c9d96a00e 100644 --- a/src/pages/preferences/Preferences.jsx +++ b/src/components/PreferenceModal.jsx @@ -1,21 +1,24 @@ import React, { useEffect, useState, useMemo } from 'react'; import { get, has } from 'lodash-es'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; - -import useDocumentTitle from '../../hooks/useDocumentTitle'; -import MainColumn from '../../components/MainColumn'; -import UserDeleteDialog from '../../components/dialogs/UserDeleteDialog'; -import Button from '../../components/Button'; -import SettingsBreadcrumbs from '../../components/SettingsBreadcrumbs'; -import InputRow from '../../components/fields/edit/InputRow'; -import Text from '../../components/Text'; -import ErrorDialog from '../../components/dialogs/ErrorDialog'; -import useGetMe from '../../models/users/useGetMe'; -import { useReplaceUserProperty } from '../../models/users/usePatchUser'; -import { useNotificationSettingsSchemas } from './useUserSettingsSchemas'; -import { deriveNotificationPreferences } from './deriveNotificationPreferences'; +import DialogContent from '@material-ui/core/DialogContent'; +import MailOutline from '@material-ui/icons/MailOutline'; +import EditOutline from '@material-ui/icons/EditOutlined'; +import GroupAdd from '@material-ui/icons/GroupAdd'; +import CallMerge from '@material-ui/icons/CallMerge'; +import useDocumentTitle from '../hooks/useDocumentTitle'; +import MainColumn from './MainColumn'; +import UserDeleteDialog from './dialogs/UserDeleteDialog'; +import Button from './Button'; +import InputRow from './fields/edit/InputRow'; +import Text from './Text'; +import ErrorDialog from './dialogs/ErrorDialog'; +import useGetMe from '../models/users/useGetMe'; +import { useReplaceUserProperty } from '../models/users/usePatchUser'; +import { useNotificationSettingsSchemas } from '../pages/preferences/useUserSettingsSchemas'; +import { deriveNotificationPreferences } from '../pages/preferences/deriveNotificationPreferences'; +import StandardDialog from './StandardDialog'; +import UserProfileMetadataWrap from './UserProfileMetadataWrap'; function getInitialFormValues(schemas, data) { return schemas.reduce((memo, field) => { @@ -26,7 +29,7 @@ function getInitialFormValues(schemas, data) { }, {}); } -export default function Preferences() { +export default function Preferences({ open, onClose }) { useDocumentTitle('PREFERENCES'); const { data } = useGetMe(); @@ -58,57 +61,69 @@ export default function Preferences() { return ( - { - clearError(); - }} - errorMessage={error} - /> - {deactivating && ( - setDeactivating(false)} - userData={data} - deactivatingSelf - /> - )} - - - - + + { + clearError(); + }} + errorMessage={error} + /> + {deactivating && ( + setDeactivating(false)} + userData={data} + deactivatingSelf + /> + )} + - - {schemas.map(notificationField => { - const fieldKey = get(notificationField, 'name'); - const fieldValue = get(formValues, fieldKey); - const backendValue = get(backendValues, fieldKey); - const valueHasChanged = fieldValue !== backendValue; - - return ( + + {schemas.map(notificationField => { + const fieldKey = get(notificationField, 'name'); + const fieldValue = get(formValues, fieldKey); + const backendValue = get(backendValues, fieldKey); + const valueHasChanged = fieldValue !== backendValue; + const labelId = get(notificationField, 'labelId'); + + return ( +
+ {labelId === 'ALL_NOTIFICATION_EMAILS' && ( + + + + )} + {labelId === 'COLLABORATION_REQUESTS' && ( + + + + )} + {labelId === 'COLLABORATION_EDIT_REQUESTS' && ( + + + + )} + {labelId === 'MERGE_OF_INDIVIDUAL' && ( + + + + )} + + {valueHasChanged && (
+ ); + })} + - - - + )} + {!someoneElse && ( + + )} +
+
+ } + style={{ + marginTop: 14, + color: theme.palette.common.black, + backgroundColor: theme.palette.primary.main + '26', + }} + /> +
+ + + + + {children}
- - setEditingProfile(true) // ? - } - metadata={metadata} - /> + +
+ {/* } projects={[ @@ -125,60 +214,26 @@ export default function UserProfile({ ]} /> */} - - - - {!someoneElse && ( - - - - )} - {someoneElse && viewerIsUserManager && ( - - - - )} + + + + {!someoneElse && ( + + + + )} + {someoneElse && viewerIsUserManager && ( + + + + )} + +
diff --git a/src/components/UserProfileMetaDataDisplay.jsx b/src/components/UserProfileMetaDataDisplay.jsx new file mode 100644 index 000000000..08894874d --- /dev/null +++ b/src/components/UserProfileMetaDataDisplay.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { + MailOutline, + PlaceOutlined, + AccountBalanceOutlined, +} from '@material-ui/icons'; +import UserProfileMetadataWrap from './UserProfileMetadataWrap'; + +export default function UserProfileMetaDataDisplay({ + email, + location, + affiliation, +}) { + return ( +
+ + + + + + + + + + + +
+ ); +} diff --git a/src/components/UserProfileMetadataWrap.jsx b/src/components/UserProfileMetadataWrap.jsx new file mode 100644 index 000000000..a7617c071 --- /dev/null +++ b/src/components/UserProfileMetadataWrap.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useTheme } from '@material-ui/core/styles'; +import Text from './Text'; + +export default function UserProfileMetadataWrap({ + id, + value, + children, +}) { + const theme = useTheme(); + return ( +
+
+ {children} +
+
+ + +
+
+ ); +} diff --git a/src/components/cards/MyCollaborationsCard.jsx b/src/components/cards/MyCollaborationsCard.jsx index b7dce9d05..d4c40d679 100644 --- a/src/components/cards/MyCollaborationsCard.jsx +++ b/src/components/cards/MyCollaborationsCard.jsx @@ -74,6 +74,7 @@ export default function MyCollaborationsCard({ userData }) { const otherUserData = get(otherUserDataArray, '0', {}); let teamViewState = 'No access'; + let teamExportState = 'No access'; let teamEditState = 'No access'; if ( thisUserData.viewState === 'pending' || @@ -81,6 +82,12 @@ export default function MyCollaborationsCard({ userData }) { ) { teamViewState = 'Pending'; } + if ( + thisUserData.exportState === 'pending' || + otherUserData.exportState === 'pending' + ) { + teamExportState = 'Pending'; + } if ( thisUserData.editState === 'pending' || otherUserData.editState === 'pending' @@ -93,6 +100,12 @@ export default function MyCollaborationsCard({ userData }) { ) { teamViewState = 'Access granted'; } + if ( + thisUserData.exportState === 'approved' && + otherUserData.exportState === 'approved' + ) { + teamExportState = 'Access granted'; + } if ( thisUserData.editState === 'approved' && otherUserData.editState === 'approved' @@ -104,6 +117,7 @@ export default function MyCollaborationsCard({ userData }) { created: collaboration.created, guid: collaboration.guid, teamViewState, + teamExportState, teamEditState, thisUserData, otherUserData, @@ -129,6 +143,10 @@ export default function MyCollaborationsCard({ userData }) { name: 'teamViewState', label: intl.formatMessage({ id: 'VIEW' }), }, + { + name: 'teamExportState', + label: intl.formatMessage({ id: 'EXPORT' }), + }, { name: 'teamEditState', label: intl.formatMessage({ id: 'EDIT' }), diff --git a/src/components/cards/SightingsCard.jsx b/src/components/cards/SightingsCard.jsx index 9db060131..52984fcfd 100644 --- a/src/components/cards/SightingsCard.jsx +++ b/src/components/cards/SightingsCard.jsx @@ -24,6 +24,8 @@ export default function SightingsCard({ linkPath = 'sightings', noSightingsMsg = 'NO_SIGHTINGS', loading, + searchParams, + setSearchParams, }) { const [showMapView, setShowMapView] = useState(false); const theme = useTheme(); @@ -33,23 +35,20 @@ export default function SightingsCard({ const mapModeClicked = () => 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 = [ { @@ -81,12 +80,29 @@ export default function SightingsCard({ name: 'locationId_value', labelId: 'LOCATION', }, + { + reference: 'match_state', + name: 'match_state', + labelId: 'STATE', + }, + { + reference: 'numberAnnotations', + name: 'numberAnnotations', + labelId: 'NUMBER_OF_ANNOTATIONS', + }, + { + reference: 'numberEncounters', + name: 'numberEncounters', + labelId: 'NUMBER_OF_ENCOUNTERS', + }, { reference: 'actions', name: 'guid', labelId: 'ACTIONS', options: { - customBodyRender: value => ( + customBodyRender: ( + value, // eslint-disable-line + ) => (
onDelete(value)} + // onClick={() => { + // setDeleteDialogOpen(true); + // }} /> )}
@@ -152,7 +171,10 @@ export default function SightingsCard({ data={sightings} loading={loading} noResultsTextId={noSightingsMsg} - tableContainerStyles={{ maxHeight: 400 }} + tableContainerStyles={{ maxHeight: 800 }} + searchParams={searchParams} + setSearchParams={setSearchParams} + dataCount /> )} {!noSightings && showMapView && ( diff --git a/src/components/cards/collaborations/CollaborationsDialog.jsx b/src/components/cards/collaborations/CollaborationsDialog.jsx index 2397310bd..9b405a654 100644 --- a/src/components/cards/collaborations/CollaborationsDialog.jsx +++ b/src/components/cards/collaborations/CollaborationsDialog.jsx @@ -5,12 +5,14 @@ import DialogActions from '@material-ui/core/DialogActions'; import usePatchCollaboration from '../../../models/collaboration/usePatchCollaboration'; import useRequestEditAccess from '../../../models/collaboration/useRequestEditAccess'; +import useRequestExportAccess from '../../../models/collaboration/useRequestExportAccess'; import Alert from '../../Alert'; import Button from '../../Button'; import Text from '../../Text'; import StandardDialog from '../../StandardDialog'; import PermissionBlock from './PermissionBlock'; import collaborationStates from './collaborationStates'; +// import { convertRowsPropToState } from '@material-ui/data-grid'; const collaborationSchemas = Object.values(collaborationStates); @@ -22,32 +24,46 @@ export default function CollaborationsDialog({ }) { const { mutate: requestEditAccess, - loading: requestLoading, - error: requestError, - clearError: clearRequestError, + loading: editRequestLoading, + error: editRequestError, + clearError: clearEditRequestError, } = useRequestEditAccess(); + const { + mutate: requestExportAccess, + loading: exportRequestLoading, + error: exportRequestError, + clearError: clearexportRequestError, + } = useRequestExportAccess(); + const { mutate: patchCollaboration, isLoading: patchLoading, error: patchError, } = usePatchCollaboration(); - const loading = requestLoading || patchLoading; - const error = requestError || patchError; + const loading = + editRequestLoading || patchLoading || exportRequestLoading; + const error = editRequestError || exportRequestError || patchError; const [request, setRequest] = useState(null); const viewStateSchema = collaborationSchemas.find(c => c.test('viewState', activeCollaboration), ); + + const exportStateSchema = collaborationSchemas.find(c => + c.test('exportState', activeCollaboration), + ); + const editStateSchema = collaborationSchemas.find(c => c.test('editState', activeCollaboration), ); function cleanupAndClose() { setRequest(null); - clearRequestError(); + clearEditRequestError(); + clearexportRequestError(); onClose(); } @@ -79,14 +95,25 @@ export default function CollaborationsDialog({ schema={viewStateSchema} setRequest={setRequest} /> + @@ -115,6 +142,11 @@ export default function CollaborationsDialog({ collaborationGuid: activeCollaboration.guid, }); requestSuccessful = response?.status === 200; + } else if (request.sendExportRequest) { + const response = await requestExportAccess({ + collaborationGuid: activeCollaboration.guid, + }); + requestSuccessful = response?.status === 200; } else { const response = await patchCollaboration({ collaborationGuid: activeCollaboration.guid, diff --git a/src/components/cards/collaborations/collaborationStates.js b/src/components/cards/collaborations/collaborationStates.js index c8a9c9bd6..992887129 100644 --- a/src/components/cards/collaborations/collaborationStates.js +++ b/src/components/cards/collaborations/collaborationStates.js @@ -1,8 +1,13 @@ import { get } from 'lodash-es'; function buildCollaborationPatch(testKey, value) { - const path = - testKey === 'viewState' ? '/view_permission' : '/edit_permission'; + const permissions = { + viewState: '/view_permission', + exportState: '/export_permission', + editState: '/edit_permission', + }; + const path = permissions[testKey]; + return [ { op: 'replace', @@ -14,14 +19,16 @@ function buildCollaborationPatch(testKey, value) { export default { pending: { - viewDisablesEdit: true, + viewDisablesExport: true, + exportDisablesEdit: true, test: (testKey, collaboration) => get(collaboration, ['otherUserData', testKey]) === 'pending', currentStateMessage: 'Access requested. Waiting for your request to be approved.', }, waiting: { - viewDisablesEdit: true, + viewDisablesExport: true, + exportDisablesEdit: true, test: (testKey, collaboration) => get(collaboration, ['thisUserData', testKey]) === 'pending', currentStateMessage: 'Data access requested.', @@ -37,7 +44,8 @@ export default { buildCollaborationPatch(testKey, 'denied'), }, blocked: { - viewDisablesEdit: true, + viewDisablesExport: true, + exportDisablesEdit: true, test: (testKey, collaboration) => ['denied', 'revoked'].includes( get(collaboration, ['otherUserData', testKey]), @@ -46,7 +54,8 @@ export default { 'Access revoked. Only the collaboration partner or a user manager can restore access.', }, blocking: { - viewDisablesEdit: true, + viewDisablesExport: true, + exportDisablesEdit: true, test: (testKey, collaboration) => ['denied', 'revoked'].includes( get(collaboration, ['thisUserData', testKey]), @@ -59,7 +68,8 @@ export default { buildCollaborationPatch(testKey, 'approved'), }, granted: { - viewDisablesEdit: false, + viewDisablesExport: false, + exportDisablesEdit: false, test: (testKey, collaboration) => get(collaboration, ['otherUserData', testKey]) === 'approved', currentStateMessage: 'Access granted.', @@ -69,11 +79,48 @@ export default { getActionPatch: testKey => buildCollaborationPatch(testKey, 'revoked'), }, - untouched: { - viewDisablesEdit: true, + exportRequest: { + viewDisablesExport: false, + exportDisablesEdit: true, test: (testKey, collaboration) => + testKey === 'exportState' && get(collaboration, ['otherUserData', testKey]) === - 'not_initiated', + 'not_initiated' && + get(collaboration, ['otherUserData', 'viewState']) === + 'approved', + currentStateMessage: 'Access has not been requested.', + actionMessage: 'Request access', + actionVerificationMessage: + 'Are you sure you want to request access? Your data will also become available to your collaboration partner.', + getActionPatch: Function.prototype, + sendEditRequest: false, + sendExportRequest: true, + }, + exportDisabled: { + viewDisablesExport: true, + exportDisablesEdit: true, + test: (testKey, collaboration) => + testKey === 'exportState' && + get(collaboration, ['otherUserData', testKey]) === + 'not_initiated' && + get(collaboration, ['otherUserData', 'viewState']) !== + 'approved', + currentStateMessage: 'Access has not been requested.', + actionMessage: 'Request access', + actionVerificationMessage: + 'Are you sure you want to request access? Your data will also become available to your collaboration partner.', + getActionPatch: Function.prototype, + sendEditRequest: false, + sendExportRequest: false, + }, + editRequest: { + exportDisablesEdit: false, + test: (testKey, collaboration) => + testKey === 'editState' && + get(collaboration, ['otherUserData', testKey]) === + 'not_initiated' && + get(collaboration, ['otherUserData', 'exportState']) === + 'approved', currentStateMessage: 'Access has not been requested.', actionMessage: 'Request access', actionVerificationMessage: @@ -81,6 +128,22 @@ export default { getActionPatch: Function.prototype, sendEditRequest: true, }, + editDisabled: { + exportDisablesEdit: true, + test: (testKey, collaboration) => + testKey === 'editState' && + get(collaboration, ['otherUserData', testKey]) === + 'not_initiated' && + get(collaboration, ['otherUserData', 'exportState']) !== + 'approved', + currentStateMessage: 'Access has not been requested.', + actionMessage: 'Request access', + actionVerificationMessage: + 'Are you sure you want to request access? Your data will also become available to your collaboration partner.', + getActionPatch: Function.prototype, + sendEditRequest: false, + sendExportRequest: false, + }, confused: { test: () => true, currentStateMessage: diff --git a/src/components/dataDisplays/DataDisplay.jsx b/src/components/dataDisplays/DataDisplay.jsx index 4f539d33a..c89a05d45 100644 --- a/src/components/dataDisplays/DataDisplay.jsx +++ b/src/components/dataDisplays/DataDisplay.jsx @@ -24,15 +24,19 @@ import FilterList from '@material-ui/icons/FilterList'; import CloudDownload from '@material-ui/icons/CloudDownload'; import axios from 'axios'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; import BaoDetective from '../svg/BaoDetective'; import FilterBar from '../FilterBar'; import Text from '../Text'; import CollapsibleRow from './CollapsibleRow'; import sendCsv, { downloadFileFromBackend } from './sendCsv'; -import useGetMe from '../../models/users/useGetMe'; import buildSightingsQuery from './buildSightingsQuery'; import buildIndividualsQuery from './buildIndividualsQuery'; +import buildEncountersQuery from './buildEncountersQuery'; +import StandardDialog from '../StandardDialog'; +import Button from '../Button'; function getCellAlignment(cellIndex, columnDefinition) { if (columnDefinition.align) return columnDefinition.align; @@ -77,11 +81,6 @@ export default function DataDisplay({ const theme = useTheme(); const themeColor = theme.palette.primary.main; - const { data: currentUserData } = useGetMe(); - - const isAdmin = get(currentUserData, 'is_admin', false); - const isExporter = get(currentUserData, 'is_exporter', false); - const initialColumnNames = columns .filter(c => get(c, 'options.display', true)) .map(c => c.name); @@ -96,6 +95,7 @@ export default function DataDisplay({ useState(null); const [anchorEl, setAnchorEl] = useState(null); const filterPopperOpen = Boolean(anchorEl); + const [dialogOpen, setDialogOpen] = useState(false); const selectedRow = selectionControlled ? selectedRowFromProps @@ -149,6 +149,33 @@ export default function DataDisplay({ return (
+ setDialogOpen(false)} + fullWidth + maxWidth="md" + > + + + + +
+ } + > + {Array.isArray(node.locationID) + ? node.locationID.map((node) => renderItem(node, searchText)) + : null} + + ) + } + + + return ( +
+ } + defaultExpandIcon={} + > + {(_.isNil(showData) || !_.isArray(showData) || _.isEmpty(showData)) + ? <> + : showData.map(node => renderItem(node))} + +
+ ); +}; + +export default TreeViewComponent; \ No newline at end of file diff --git a/src/components/filterFields/useBuildFilter.js b/src/components/filterFields/useBuildFilter.js index 245e0fb29..09ed5fa2a 100644 --- a/src/components/filterFields/useBuildFilter.js +++ b/src/components/filterFields/useBuildFilter.js @@ -18,7 +18,11 @@ export default function useBuildFilter(fields, component) { const { booleanChoices } = useOptions(); let queryTerm; let queryTerms; - if (component === 'sightings' || component === 'individuals') { + if ( + component === 'sightings' || + component === 'individuals' || + !component + ) { queryTerm = 'customFields'; queryTerms = 'customFields'; } else if (component === 'encounters') { diff --git a/src/components/profilePhotos/BigAvatar.jsx b/src/components/profilePhotos/BigAvatar.jsx index 72c61c3c2..551b4aec8 100644 --- a/src/components/profilePhotos/BigAvatar.jsx +++ b/src/components/profilePhotos/BigAvatar.jsx @@ -1,13 +1,13 @@ import React, { useState } from 'react'; -import { FormattedMessage } from 'react-intl'; import { get } from 'lodash-es'; import { useTheme } from '@material-ui/core/styles'; import Chip from '@material-ui/core/Chip'; -import SvgText from '../SvgText'; import EditAvatar from './EditAvatar'; import defaultProfilePhoto from '../../assets/defaultProfile.jpg'; +import { EditOutlined } from '@material-ui/icons'; + export default function BigAvatar({ imageSrc, @@ -22,7 +22,7 @@ export default function BigAvatar({ chipLabel, }) { const theme = useTheme(); - const [avatarHovered, setAvatarHovered] = useState(false); + const [backgroundColor, setBackgroundColor] = useState(theme.palette.primary.main); const [editingAvatar, setEditingAvatar] = useState(false); return ( @@ -141,53 +141,30 @@ export default function BigAvatar({ ))} )} - {editable && ( - setAvatarHovered(true)} - onMouseLeave={() => setAvatarHovered(false)} - onClick={() => setEditingAvatar(true)} + right: 1, + bottom: 1, + width: 40, + height: 40, + borderRadius: '50%', + opacity: 1, + zIndex: 2, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: backgroundColor, + }} + // onMouseEnter={() => setBackgroundColor(theme.palette.primary.main)} + // onMouseLeave={() => setBackgroundColor(theme.palette.primary.main+'26')} + onClick={() => setEditingAvatar(true)} > - - - - - - {square ? ( - - ) : ( - - )} - - - - + +
+ )} diff --git a/src/constants/notificationSchema.js b/src/constants/notificationSchema.js index 3e88a412b..e8bb562f9 100644 --- a/src/constants/notificationSchema.js +++ b/src/constants/notificationSchema.js @@ -4,6 +4,9 @@ export const notificationTypeNames = { collaboration_approved: 'collaboration_approved', collaboration_revoke: 'collaboration_revoke', collaboration_denied: 'collaboration_denied', + collaboration_export_request: 'collaboration_export_request', + collaboration_export_approved: 'collaboration_export_approved', + collaboration_export_revoke: 'collaboration_export_revoke', collaboration_edit_request: 'collaboration_edit_request', collaboration_edit_approved: 'collaboration_edit_approved', collaboration_edit_revoke: 'collaboration_edit_revoke', @@ -14,12 +17,19 @@ export const notificationTypeNames = { collaboration_manager_edit_approved: 'collaboration_manager_edit_approved', + collaboration_manager_export_approved: + 'collaboration_manager_export_approved', collaboration_manager_edit_revoke: 'collaboration_manager_edit_revoke', + collaboration_manager_export_revoke: + 'collaboration_manager_export_revoke', collaboration_edit_denied: 'collaboration_edit_denied', + collaboration_export_denied: 'collaboration_export_denied', collaboration_manager_denied: 'collaboration_manager_denied', collaboration_manager_edit_denied: 'collaboration_manager_edit_denied', + collaboration_manager_export_denied: + 'collaboration_manager_export_denied', }; const notificationSchemaPlaceholder = {}; @@ -33,7 +43,19 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'EDIT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', +}; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_manager_export_denied +] = { + titleId: 'EXPORT_COLLABORATION_DENIED_BY_USER_MANAGER', + notificationMessage: + 'EXPORT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE', + moreDetailedDescription: + 'EXPORT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE', + showNotificationDialog: false, + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ @@ -45,8 +67,21 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'EDIT_COLLABORATION_WAS_APPROVED_BY_A_USER_MANAGER', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', +}; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_manager_export_approved +] = { + titleId: 'EXPORT_COLLABORATION_APPROVED_BY_USER_MANAGER', + notificationMessage: + 'EXPORT_COLLABORATION_WAS_APPROVED_BY_A_USER_MANAGER', + moreDetailedDescription: + 'EXPORT_COLLABORATION_WAS_APPROVED_BY_A_USER_MANAGER', + showNotificationDialog: false, + buttonPath: '/user-profile/#collab-card', }; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_manager_denied ] = { @@ -55,7 +90,7 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.collaboration_edit_denied @@ -64,8 +99,19 @@ notificationSchemaPlaceholder[ notificationMessage: 'EDIT_COLLABORATION_DENIED_MESSAGE', moreDetailedDescription: 'EDIT_COLLABORATION_DENIED_MESSAGE', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', +}; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_export_denied +] = { + titleId: 'COLLABORATION_EXPORT_DENIED', + notificationMessage: 'EXPORT_COLLABORATION_DENIED_MESSAGE', + moreDetailedDescription: 'EXPORT_COLLABORATION_DENIED_MESSAGE', + showNotificationDialog: false, + buttonPath: '/user-profile/#collab-card', }; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_manager_edit_revoke ] = { @@ -75,8 +121,21 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'EDIT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_manager_export_revoke +] = { + titleId: 'EXPORT_COLLABORATION_REVOKED_BY_USER_MANAGER', + notificationMessage: + 'EXPORT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER', + moreDetailedDescription: + 'EXPORT_COLLABORATION_WAS_REVOKED_BY_A_USER_MANAGER', + showNotificationDialog: false, + buttonPath: '/user-profile/#collab-card', +}; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_manager_create ] = { @@ -85,7 +144,7 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'A_COLLABORATION_WAS_CREATED_ON_YOUR_BEHALF_MORE_DETAILED', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.collaboration_request @@ -104,7 +163,7 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'COLLABORATION_APPROVED', showNotificationDialog: false, path: '/view_permission', - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.collaboration_revoke @@ -113,7 +172,7 @@ notificationSchemaPlaceholder[ notificationMessage: 'COLLABORATION_REVOKE_BRIEF', moreDetailedDescription: 'COLLABORATION_REVOKE_BRIEF', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.collaboration_denied @@ -122,7 +181,7 @@ notificationSchemaPlaceholder[ notificationMessage: 'COLLABORATION_DENIED_BRIEF', moreDetailedDescription: 'COLLABORATION_DENIED_BRIEF', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.collaboration_edit_request @@ -133,6 +192,17 @@ notificationSchemaPlaceholder[ showNotificationDialog: true, path: '/edit_permission', }; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_export_request +] = { + titleId: 'COLLABORATION_EXPORT_REQUEST_TITLE', + notificationMessage: 'COLLABORATION_EXPORT_REQUEST_BRIEF', + moreDetailedDescription: 'COLLABORATION_EXPORT_REQUEST_DESCRIPTION', + showNotificationDialog: true, + path: '/export_permission', +}; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_edit_approved ] = { @@ -141,8 +211,20 @@ notificationSchemaPlaceholder[ moreDetailedDescription: 'EDIT_COLLABORATION_APPROVED', showNotificationDialog: false, path: '/edit_permission', - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_export_approved +] = { + titleId: 'COLLABORATION_EXPORT_APPROVED_TITLE', + notificationMessage: 'EXPORT_COLLABORATION_APPROVED', + moreDetailedDescription: 'EXPORT_COLLABORATION_APPROVED', + showNotificationDialog: false, + path: '/export_permission', + buttonPath: '/user-profile/#collab-card', +}; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_edit_revoke ] = { @@ -150,8 +232,19 @@ notificationSchemaPlaceholder[ notificationMessage: 'EDIT_COLLABORATION_REVOKED', moreDetailedDescription: 'EDIT_COLLABORATION_REVOKED', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; + +notificationSchemaPlaceholder[ + notificationTypeNames.collaboration_export_revoke +] = { + titleId: 'COLLABORATION_EXPORT_REVOKE_TITLE', + notificationMessage: 'EXPORT_COLLABORATION_REVOKED', + moreDetailedDescription: 'EXPORT_COLLABORATION_REVOKED', + showNotificationDialog: false, + buttonPath: '/user-profile/#collab-card', +}; + notificationSchemaPlaceholder[ notificationTypeNames.collaboration_manager_revoke ] = { @@ -159,7 +252,7 @@ notificationSchemaPlaceholder[ notificationMessage: 'COLLABORATION_REVOKED_BY_MANAGER', moreDetailedDescription: 'COLLABORATION_REVOKED_BY_MANAGER', showNotificationDialog: false, - buttonPath: '/#collab-card', + buttonPath: '/user-profile/#collab-card', }; notificationSchemaPlaceholder[ notificationTypeNames.individual_merge_request diff --git a/src/constants/queryKeys.js b/src/constants/queryKeys.js index 61643782d..9debc8571 100644 --- a/src/constants/queryKeys.js +++ b/src/constants/queryKeys.js @@ -52,12 +52,12 @@ export function getUserQueryKey(guid) { return ['user', guid]; } -export function getUserSightingsQueryKey(guid) { - return ['userSightings', guid]; +export function getUserSightingsQueryKey(guid, query, params) { + return ['userSightings', guid, query, params]; } -export function getUserAgsQueryKey(guid) { - return ['userAgs', guid]; +export function getUserAgsQueryKey(guid, params) { + return ['userAgs', guid, params]; } export function getAssetGroupQueryKey(guid) { @@ -80,6 +80,10 @@ export function getSightingTermQueryKey(searchTerm) { return ['sightingQuickSearch', searchTerm]; } +export function getEncounterTermQueryKey(searchTerm) { + return ['encounterQuickSearch', searchTerm]; +} + export function getSocialGroupQueryKey(guid) { return ['socialGroup', guid]; } @@ -100,6 +104,14 @@ export function getSightingFilterQueryKey( return ['sightingFilterSearch', filters, page, rowsPerPage]; } +export function getEncounterFilterQueryKey( + filters, + page, + rowsPerPage, +) { + return ['encounterFilterSearch', filters, page, rowsPerPage]; +} + export function getAuditLogQueryKey(filters, page, rowsPerPage) { return ['auditLogFilterSearch', filters, page, rowsPerPage]; } diff --git a/src/hooks/useFetch.js b/src/hooks/useFetch.js index a77a1ea32..6b466177b 100644 --- a/src/hooks/useFetch.js +++ b/src/hooks/useFetch.js @@ -34,7 +34,6 @@ export default function useFetch({ !queryOptions.disabled, // should this use enabled instead of disabled? I couldn't find anything in the react-query documentation about disabled. // agreed, I think it should be enabled ); - const [statusCode, setStatusCode] = useState(null); const apiUrl = prependHoustonApiUrl ? `${__houston_url__}/api/v1${url}` @@ -50,7 +49,6 @@ export default function useFetch({ responseType, }); const status = response?.status; - setStatusCode(status); if (status === 200) onSuccess(response); return response; }, @@ -68,22 +66,15 @@ export default function useFetch({ if (result?.status === 'loading') { setDisplayedLoading(true); } else { - if (statusCode !== statusCodeFromError) - setStatusCode(statusCodeFromError); if (displayedError !== error) setDisplayedError(error); setDisplayedLoading(false); } - }, [ - error, - result?.status, - statusCodeFromError, - statusCode, - displayedError, - ]); + }, [error, result?.status, statusCodeFromError, displayedError]); return { ...result, - statusCode, + statusCode: + result?.data?.status || result?.error?.response?.status, data: dataAccessor(result), isLoading: displayedLoading, loading: displayedLoading, diff --git a/src/hooks/useOptions.js b/src/hooks/useOptions.js index 9f2aa7e8d..5973aac13 100644 --- a/src/hooks/useOptions.js +++ b/src/hooks/useOptions.js @@ -41,17 +41,26 @@ export default function useOptions() { })) .filter(o => o); - const pipelineStateOptions = [{label:"preparation", value: "preparation"}, - {label: "detection", value: "detection"}, - {label: "curation", value: "curation"}, - {label: "identification", value: "identification"}, + const pipelineStateOptions = [ + { label: 'preparation', value: 'preparation' }, + { label: 'detection', value: 'detection' }, + { label: 'curation', value: 'curation' }, + { label: 'identification', value: 'identification' }, ]; - const stageOptions = [{label:"un_reviewed", value: "un_reviewed"}, - {label: "processed", value: "processed"}, - {label: "failed", value: "failed"}, - {label: "identification", value: "identification"}, - ]; + const stageOptions = [ + { label: 'un_reviewed', value: 'un_reviewed' }, + { label: 'processed', value: 'processed' }, + { label: 'failed', value: 'failed' }, + { label: 'identification', value: 'identification' }, + ]; + + const stateOptions = [ + { label: 'unreviewed', value: 'unreviewed' }, + { label: 'in progress', value: 'in_progress' }, + { label: 'reviewed', value: 'reviewed' }, + { label: 'unidentifiable', value: 'unidentifiable' }, + ]; const booleanChoices = [ { @@ -71,21 +80,30 @@ export default function useOptions() { }, ]; - const socialGroupRolesOptions = data['social_group_roles'].value.map(o => { - return { - label: o.label, - value: o.guid - } - }); - - const relationshipOptions = Object.values(data['relationship_type_roles'].value).map(o => { - return { + const socialGroupRolesOptions = data.social_group_roles.value.map( + o => ({ label: o.label, value: o.guid, - roles: o.roles - } - }); + }), + ); + + const relationshipOptions = Object.values( + data.relationship_type_roles.value, + ).map(o => ({ + label: o.label, + value: o.guid, + roles: o.roles, + })); - return { regionOptions, speciesOptions, pipelineStateOptions, stageOptions, booleanChoices, socialGroupRolesOptions, relationshipOptions }; + return { + regionOptions, + speciesOptions, + pipelineStateOptions, + stageOptions, + booleanChoices, + socialGroupRolesOptions, + relationshipOptions, + stateOptions, + }; }, [loading, error, data]); } diff --git a/src/models/collaboration/useRequestExportAccess.js b/src/models/collaboration/useRequestExportAccess.js new file mode 100644 index 000000000..2fd18965b --- /dev/null +++ b/src/models/collaboration/useRequestExportAccess.js @@ -0,0 +1,10 @@ +import { usePost } from '../../hooks/useMutate'; +import queryKeys from '../../constants/queryKeys'; + +export default function useRequestEditAccess() { + return usePost({ + deriveUrl: ({ collaborationGuid }) => + `/collaborations/export_request/${collaborationGuid}`, + fetchKeys: [queryKeys.me], + }); +} diff --git a/src/models/encounter/useEncounterSearchSchemas.js b/src/models/encounter/useEncounterSearchSchemas.js new file mode 100644 index 000000000..a8e8e0909 --- /dev/null +++ b/src/models/encounter/useEncounterSearchSchemas.js @@ -0,0 +1,97 @@ +import useOptions from '../../hooks/useOptions'; +import OptionTermFilter from '../../components/filterFields/OptionTermFilter'; +import PointDistanceFilter from '../../components/filterFields/PointDistanceFilter'; +import SubstringFilter from '../../components/filterFields/SubstringFilter'; +import DateRangeFilter from '../../components/filterFields/DateRangeFilter'; +import useSiteSettings from '../site/useSiteSettings'; +import autogenNameFilter from '../../components/filterFields/autogenNameFilter'; +import useBuildFilter from '../../components/filterFields/useBuildFilter'; + +export default function useSightingSearchSchemas() { + const { regionOptions, speciesOptions, stateOptions } = + useOptions(); + + const { data: siteSettings } = useSiteSettings(); + + const customEncounterFields = + siteSettings['site.custom.customFields.Encounter'].value + .definitions || []; + + const encountersField = useBuildFilter(customEncounterFields); + + return [ + { + id: 'name', + labelId: 'INDIVIDUAL_NAME', + FilterComponent: SubstringFilter, + filterComponentProps: { + filterId: 'name', + queryTerms: ['individualNamesWithContexts.FirstName'], + }, + }, + { + id: 'codexId', + labelId: 'CODEX_ID', + FilterComponent: autogenNameFilter, + filterComponentProps: { + filterId: 'codexId', + queryTerms: ['individualNamesWithContexts'], + }, + }, + { + id: 'species', + labelId: 'SPECIES', + FilterComponent: OptionTermFilter, + filterComponentProps: { + filterId: 'species', + queryTerm: 'taxonomy_guid', + choices: speciesOptions, + }, + }, + { + id: 'match_state', + labelId: 'STATE', + FilterComponent: OptionTermFilter, + filterComponentProps: { + filterId: 'match_state', + queryTerm: 'match_state', + choices: stateOptions, + }, + }, + { + id: 'time', + labelId: 'SIGHTING_DATE_RANGE', + FilterComponent: DateRangeFilter, + filterComponentProps: { queryTerm: 'time', filterId: 'time' }, + }, + { + id: 'locationId', + labelId: 'REGION', + FilterComponent: OptionTermFilter, + filterComponentProps: { + queryTerm: 'locationId', + filterId: 'locationId', + choices: regionOptions, + }, + }, + { + id: 'latlong', + labelId: 'EXACT_LOCATION', + FilterComponent: PointDistanceFilter, + filterComponentProps: { + filterId: 'latlong', + queryTerm: 'location_geo_point', + }, + }, + { + id: 'verbatimLocality', + labelId: 'FREEFORM_LOCATION', + FilterComponent: SubstringFilter, + filterComponentProps: { + filterId: 'verbatimLocality', + queryTerms: ['verbatimLocality'], + }, + }, + ...encountersField, + ]; +} diff --git a/src/models/encounter/useEncounterTermQuery.js b/src/models/encounter/useEncounterTermQuery.js new file mode 100644 index 000000000..341b75e5b --- /dev/null +++ b/src/models/encounter/useEncounterTermQuery.js @@ -0,0 +1,32 @@ +import useFetch from '../../hooks/useFetch'; +import { getEncounterTermQueryKey } from '../../constants/queryKeys'; + +export default function useEncounterTermQuery(searchTerm) { + const query = { + bool: { + minimum_should_match: 1, + should: [ + { + query_string: { + query: `*${searchTerm}*`, + fields: [ + 'verbatimLocality', + 'owner.full_name', + 'locationId_value', + ], + }, + }, + ], + }, + }; + + return useFetch({ + method: 'post', + url: '/encounters/search', + queryKey: getEncounterTermQueryKey(searchTerm), + data: query, + queryOptions: { + enabled: Boolean(searchTerm), + }, + }); +} diff --git a/src/models/encounter/useFilterEncounters.js b/src/models/encounter/useFilterEncounters.js new file mode 100644 index 000000000..82e434fbd --- /dev/null +++ b/src/models/encounter/useFilterEncounters.js @@ -0,0 +1,48 @@ +import { get, partition } from 'lodash-es'; + +import useFetch from '../../hooks/useFetch'; +import { getEncounterFilterQueryKey } from '../../constants/queryKeys'; + +export default function useFilterEncounters({ + queries, + params = {}, +}) { + const [filters, mustNots] = partition( + queries, + q => q.clause === 'filter', + ); + + const filterQueries = filters.map(f => f.query); + const mustNotQueries = mustNots.map(f => f.query); + + const compositeQuery = { + bool: { filter: filterQueries, must_not: mustNotQueries }, + }; + return useFetch({ + method: 'post', + queryKey: getEncounterFilterQueryKey(queries, params), + url: '/encounters/search', + data: compositeQuery, + params: { + limit: 20, + offset: 0, + sort: 'created', + reverse: false, + ...params, + }, + dataAccessor: result => { + const resultCountString = get(result, [ + 'data', + 'headers', + 'x-total-count', + ]); + return { + resultCount: parseInt(resultCountString, 10), + results: get(result, ['data', 'data']), + }; + }, + queryOptions: { + retry: 2, + }, + }); +} diff --git a/src/models/individual/useFilterIndividuals.js b/src/models/individual/useFilterIndividuals.js index 1e43e4b3d..c123b1582 100644 --- a/src/models/individual/useFilterIndividuals.js +++ b/src/models/individual/useFilterIndividuals.js @@ -4,10 +4,11 @@ import useFetch from '../../hooks/useFetch'; import { nestQueries } from '../../utils/elasticSearchUtils'; import { getIndividualFilterQueryKey } from '../../constants/queryKeys'; + export default function useFilterIndividuals({ queries, params = {}, -}) { +}) { const [filters, mustNots] = partition( queries, q => q.clause === 'filter', diff --git a/src/models/users/useGetUserSightings.js b/src/models/users/useGetUserSightings.js index 1b5605531..f257efe64 100644 --- a/src/models/users/useGetUserSightings.js +++ b/src/models/users/useGetUserSightings.js @@ -1,23 +1,37 @@ +import { get } from 'lodash-es'; import useFetch from '../../hooks/useFetch'; import { getUserSightingsQueryKey } from '../../constants/queryKeys'; -export default function useGetUserSightings(userGuid) { +export default function useGetUserSightings(userGuid, params) { const query = { term: { 'owners.guid': userGuid } }; return useFetch({ method: 'post', url: '/sightings/search', - queryKey: getUserSightingsQueryKey(userGuid), + queryKey: getUserSightingsQueryKey(userGuid, query, params), data: query, /* Return up to 20 sightings, most recently reported first */ params: { limit: 20, offset: 0, sort: 'created', - reverse: true, + reverse: false, + ...params, }, queryOptions: { enabled: Boolean(userGuid), + retry: 2, + }, + dataAccessor: result => { + const resultCountString = get(result, [ + 'data', + 'headers', + 'x-total-count', + ]); + return { + resultCount: parseInt(resultCountString, 10), + results: get(result, ['data', 'data']), + }; }, }); } diff --git a/src/models/users/useGetUserUnproccessedAssetGroupSightings.js b/src/models/users/useGetUserUnproccessedAssetGroupSightings.js index fc71d75d5..4cd2954bc 100644 --- a/src/models/users/useGetUserUnproccessedAssetGroupSightings.js +++ b/src/models/users/useGetUserUnproccessedAssetGroupSightings.js @@ -1,3 +1,4 @@ +import { get } from 'lodash-es'; import { getUserAgsQueryKey } from '../../constants/queryKeys'; import useFetch from '../../hooks/useFetch'; @@ -6,12 +7,26 @@ export default function useGetUserUnprocessedAssetGroupSightings( params = {}, ) { return useFetch({ - queryKey: getUserAgsQueryKey(userId), + queryKey: getUserAgsQueryKey(userId, params), url: `/users/${userId}/asset_group_sightings`, params: { limit: 20, offset: 0, ...params, }, + dataAccessor: result => { + const resultCountString = get(result, [ + 'data', + 'headers', + 'x-total-count', + ]); + return { + resultCount: parseInt(resultCountString, 10), + results: get(result, ['data', 'data']), + }; + }, + queryOptions: { + retry: 2, + }, }); } diff --git a/src/pages/changeLog/ChangeLog.jsx b/src/pages/changeLog/ChangeLog.jsx index 0582f02fe..a9e1b6440 100644 --- a/src/pages/changeLog/ChangeLog.jsx +++ b/src/pages/changeLog/ChangeLog.jsx @@ -43,7 +43,7 @@ export default function ChangeLog() { const tableColumns = buildTableColumns(intl); const [inputValue, setInputValue] = useState(''); - const rowsPerPage = 100; + const rowsPerPage = 5; const [searchParams, setSearchParams] = useState({ limit: rowsPerPage, offset: 0, diff --git a/src/pages/controlPanel/ControlPanel.jsx b/src/pages/controlPanel/ControlPanel.jsx index 5e6ae38e4..ff9eb40d0 100644 --- a/src/pages/controlPanel/ControlPanel.jsx +++ b/src/pages/controlPanel/ControlPanel.jsx @@ -3,15 +3,12 @@ import { get } from 'lodash-es'; import { useTheme } from '@material-ui/core'; import Paper from '@material-ui/core/Paper'; -import SplashSettingsIcon from '@material-ui/icons/Home'; import SiteSettingsIcon from '@material-ui/icons/SettingsApplications'; -import PreferencesIcon from '@material-ui/icons/Settings'; import CustomFieldsIcon from '@material-ui/icons/Tune'; import SiteStatusIcon from '@material-ui/icons/Speed'; import UserManagementIcon from '@material-ui/icons/People'; import AdministrationIcon from '@material-ui/icons/Gavel'; import GroupWorkIcon from '@material-ui/icons/GroupWork'; -import AssignmentOutlinedIcon from '@material-ui/icons/AssignmentOutlined'; import ListAltIcon from '@material-ui/icons/ListAlt'; import useDocumentTitle from '../../hooks/useDocumentTitle'; @@ -28,13 +25,6 @@ const adminPages = [ href: '/settings/general', roles: ['is_admin'], }, - // { - // icon: SplashSettingsIcon, - // name: 'front-page-config', - // labelId: 'FRONT_PAGE', - // href: '/settings/front-page', - // roles: ['is_admin'], - // }, { icon: CustomFieldsIcon, name: 'field-management', @@ -75,21 +65,6 @@ const adminPages = [ href: '/settings/actions', roles: ['is_admin'], }, - { - icon: PreferencesIcon, - name: 'preferences', - labelId: 'PREFERENCES', - href: '/settings/preferences', - roles: [ - 'is_admin', - 'is_exporter', - 'is_internal', - 'is_staff', - 'is_user_manager', - 'is_researcher', - 'is_contributor', - ], - }, { icon: ListAltIcon, name: 'change-log', diff --git a/src/pages/dataPage/DataPage.jsx b/src/pages/dataPage/DataPage.jsx new file mode 100644 index 000000000..881e1619c --- /dev/null +++ b/src/pages/dataPage/DataPage.jsx @@ -0,0 +1,350 @@ +import React, { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { FormControl, MenuItem, Select } from '@material-ui/core'; +import { useHistory } from 'react-router-dom'; +import { useTheme } from '@material-ui/core/styles'; +import { get } from 'lodash-es'; +import useGetUserSightings from '../../models/users/useGetUserSightings'; +import useGetUserUnprocessedAssetGroupSightings from '../../models/users/useGetUserUnproccessedAssetGroupSightings'; +// import { formatUserMessage } from '../../utils/formatters'; + +import MainColumn from '../../components/MainColumn'; +import SightingsCard from '../../components/cards/SightingsCard'; + +import CardContainer from '../../components/cards/CardContainer'; +import useGetMe from '../../models/users/useGetMe'; +import Text from '../../components/Text'; +import LoadingScreen from '../../components/LoadingScreen'; + +import useDeleteSighting from '../../models/sighting/useDeleteSighting'; +import useDeleteAssetGroupSighting from '../../models/assetGroupSighting/useDeleteAssetGroupSighting'; +import ConfirmDelete from '../../components/ConfirmDelete'; +import Paginator from '../../components/dataDisplays/Paginator'; +import ProfileSetup from '../home/ProfileSetup'; +import useDocumentTitle from '../../hooks/useDocumentTitle'; + +export default function DataPage() { + const { data: userData, loading: userDataLoading } = useGetMe(); + + const fullName = get(userData, ['full_name']); + + useDocumentTitle('HOME', { refreshKey: fullName }); + + const theme = useTheme(); + + const history = useHistory(); + + const rowsPerPagePendingSightings = 10; + const [ + searchParamsPendingSightings, + setSearchParamsPendingSightings, + ] = useState({ + limit: rowsPerPagePendingSightings, + offset: 0, + sort: 'created', + reverse: true, + }); + + const rowsPerPageSightings = 10; + const [searchParamsSightings, setSearchParamsSightings] = useState({ + limit: rowsPerPageSightings, + offset: 0, + sort: 'created', + reverse: true, + }); + + const userId = userData?.guid; + const { data: sightingsDataObject, loading: sightingsLoading } = + useGetUserSightings(userId, searchParamsSightings); + const { + results: sightingsData, + resultCount: resultCountSightings, + } = sightingsDataObject; + + const unapprovedSightingsData = sightingsData?.filter( + sighting => + sighting.match_state === 'unreviewed' || + sighting.match_state === 'in_progress', + ); + + const [selected, setSelected] = React.useState('all_data'); + + const intl = useIntl(); + + const { data: agsDataObject, loading: agsLoading } = + useGetUserUnprocessedAssetGroupSightings( + userId, + searchParamsPendingSightings, + ); + + const { + results: agsData, + resultCount: resultCountPendingSigtings, + } = agsDataObject; + + const { + deleteSighting, + loading: deleteInProgress, + error: deleteSightingError, + onClearError: deleteSightingOnClearError, + vulnerableIndividual, + onClearVulnerableIndividual, + } = useDeleteSighting(); + + const { + deleteAssetGroupSighting, + isLoading: deleteAgsInProgress, + error: deleteAssetGroupSightingError, + onClearError: deleteAsgOnClearError, + } = useDeleteAssetGroupSighting(); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [id, setId] = useState(null); + + const [pending, setPending] = useState(''); + + const onClearError = pending + ? deleteAsgOnClearError + : deleteSightingOnClearError; + + if (userDataLoading) return ; + // if (error) handle error + if (!fullName) return ; + + return ( + + +
+ { + onClearVulnerableIndividual(); + onClearError(); + setDeleteDialogOpen(false); + }} + onDelete={async () => { + let deleteResults; + if (pending) { + deleteResults = await deleteAssetGroupSighting(id); + } else if (vulnerableIndividual) { + deleteResults = await deleteSighting(id, true); + } else { + deleteResults = await deleteSighting(id); + } + const successful = pending + ? deleteResults?.status === 204 + : deleteResults; + if (successful) { + setDeleteDialogOpen(false); + history.push('/'); + } + }} + deleteInProgress={ + pending ? deleteAgsInProgress : deleteInProgress + } + error={ + pending + ? deleteAssetGroupSightingError + : deleteSightingError + } + errorTitleId={ + vulnerableIndividual + ? 'REQUEST_REQUIRES_ADDITIONAL_CONFIRMATION' + : undefined + } + alertSeverity={vulnerableIndividual ? 'warning' : 'error'} + onClearError={onClearError} + messageId={ + vulnerableIndividual + ? 'SIGHTING_DELETE_VULNERABLE_INDIVIDUAL_MESSAGE' + : 'CONFIRM_DELETE_SIGHTING_DESCRIPTION' + } + /> +
+ + + +
+ +
+ +
+ +
+ { + setPending(true); + setId(value); + setDeleteDialogOpen(true); + }} + /> +
+ + + {intl.formatMessage( + { id: 'TotalAccount' }, + { totalAccount: resultCountPendingSigtings }, + )} + +
+ + + { + setPending(false); + setId(value); + setDeleteDialogOpen(true); + }} + /> + +
+ + + {intl.formatMessage( + { id: 'TotalAccount' }, + { totalAccount: resultCountSightings || 0 }, + )} + +
+
+
+
+
+ ); +} diff --git a/src/pages/fieldManagement/RegionManagement.jsx b/src/pages/fieldManagement/RegionManagement.jsx new file mode 100644 index 000000000..9c3b3218c --- /dev/null +++ b/src/pages/fieldManagement/RegionManagement.jsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from 'react'; +import { get } from 'lodash-es'; +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; +import MainColumn from '../../components/MainColumn'; +import TreeEditor from './settings/defaultFieldComponents/TreeEditor'; +import useSiteSettings from '../../models/site/useSiteSettings'; +import Button from '../../components/Button'; +import Text from '../../components/Text'; +import SettingsBreadcrumbs from '../../components/SettingsBreadcrumbs'; +import usePutSiteSetting from '../../models/site/usePutSiteSetting'; +import CustomAlert from '../../components/Alert'; + +function getInitialFormState(siteSettings) { + 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, socialGroups }; +} + +export default function RegionManagement() { + const [formSettings, setFormSettings] = useState(null); + const { data: siteSettings } = useSiteSettings(); + const history = useHistory(); + + const { + mutate: putSiteSetting, + error: putError, + loading, + clearError, + } = usePutSiteSetting(); + + const onClose = () => { + clearError(); + history.push('/settings/fields'); + }; + + useEffect( + () => setFormSettings(getInitialFormState(siteSettings)), + [siteSettings], + ); + + const tree = get(formSettings, ['regions', 'locationID'], []); + + return ( + + + + { + const newRegions = { + ...get(formSettings, 'regions', {}), + locationID, + }; + setFormSettings({ + ...formSettings, + regions: newRegions, + }); + }} + /> +
+ + +
+
+ {putError ? ( + + ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx index fa0ae1fde..0475c50a3 100644 --- a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx +++ b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx @@ -11,7 +11,7 @@ import DataDisplay from '../../../components/dataDisplays/DataDisplay'; import ActionIcon from '../../../components/ActionIcon'; import Text from '../../../components/Text'; import categoryTypes from '../../../constants/categoryTypes'; -import { RegionEditor } from './defaultFieldComponents/Editors'; +// import { RegionEditor } from './defaultFieldComponents/Editors'; import RelationshipEditor from './defaultFieldComponents/RelationshipEditor'; import SocialGroupsEditor from './defaultFieldComponents/SocialGroupsEditor'; import { cellRendererTypes } from '../../../components/dataDisplays/cellRenderers'; @@ -22,14 +22,13 @@ const configurableFields = [ backendPath: 'site.species', labelId: 'SPECIES', type: categoryTypes.sighting, - // Editor: SpeciesEditor, }, { id: 'region', backendPath: 'site.custom.regions', labelId: 'REGION', type: categoryTypes.sighting, - Editor: RegionEditor, + // Editor: RegionEditor, }, { id: 'relationship', @@ -84,14 +83,48 @@ export default function DefaultFieldTable({ siteSettings }) { [siteSettings], ); + const onCloseEditor = () => { + clearError(); + setEditField(null); + }; + + const onClose = () => { + setFormSettings(getInitialFormState(siteSettings)); + onCloseEditor(); + }; + + const onSubmit = async () => { + if (editField?.id === 'region') { + const response = await putSiteSetting({ + property: editField.backendPath, + data: formSettings.regions, + }); + if (response?.status === 200) onCloseEditor(); + } + if (editField?.id === 'relationship') { + const response = await putSiteSetting({ + property: editField.backendPath, + data: formSettings.relationships, + }); + if (response?.status === 200) onCloseEditor(); + } + if (editField?.id === 'socialGroups') { + const response = await putSiteSetting({ + property: editField.backendPath, + data: formSettings.socialGroups, + }); + if (response?.status === 200) onCloseEditor(); + } + }; + const tableColumns = [ { name: 'labelId', label: intl.formatMessage({ id: 'LABEL' }), options: { - customBodyRender: labelId => ( - - ), + customBodyRender: ( + labelId, //eslint-disable-line + ) => , }, }, { @@ -103,15 +136,20 @@ export default function DefaultFieldTable({ siteSettings }) { name: 'actions', label: intl.formatMessage({ id: 'ACTIONS' }), options: { - customBodyRender: (_, field) => ( + customBodyRender: ( + _, + field, //eslint-disable-line + ) => ( { - if(field.id === 'species'){ + if (field.id === 'species') { history.push('/settings/fields/species'); - }else { + } else if (field.id === 'region') { + history.push('/settings/fields/regions'); + } else { setEditField(field); - } + } }} /> ), @@ -119,11 +157,6 @@ export default function DefaultFieldTable({ siteSettings }) { }, ]; - const onCloseEditor = () => { - clearError(); - setEditField(null); - }; - return ( {editField && ( @@ -131,33 +164,8 @@ export default function DefaultFieldTable({ siteSettings }) { siteSettings={siteSettings} formSettings={formSettings} setFormSettings={setFormSettings} - onClose={() => { - setFormSettings(getInitialFormState(siteSettings)); - onCloseEditor(); - }} - onSubmit={async () => { - if (editField?.id === 'region') { - const response = await putSiteSetting({ - property: editField.backendPath, - data: formSettings.regions, - }); - if (response?.status === 200) onCloseEditor(); - } - if (editField?.id === 'relationship') { - const response = await putSiteSetting({ - property: editField.backendPath, - data: formSettings.relationships, - }); - if (response?.status === 200) onCloseEditor(); - } - if (editField?.id === 'socialGroups') { - const response = await putSiteSetting({ - property: editField.backendPath, - data: formSettings.socialGroups, - }); - if (response?.status === 200) onCloseEditor(); - } - }} + onClose={onClose} + onSubmit={onSubmit} > {error ? ( { const newLocationID = leaf.locationID - ? updateTree(leaf.locationID, leafId, newLeafName) + ? updateTree( + leaf.locationID, + leafId, + newLeafName, + placeholderOnly, + ) : undefined; - const newLeaf = { ...leaf, locationID: newLocationID }; - if (newLeaf.id === leafId) newLeaf.name = newLeafName; + const newLeaf = { + ...leaf, + locationID: newLocationID, + }; + if (newLeaf.id === leafId) { + newLeaf.name = newLeafName; + newLeaf.placeholderOnly = placeholderOnly; + } return newLeaf; }); } const Leaf = function ({ level, data, root, onChange, children }) { + const [placeholderOnly, setPlaceholderOnly] = useState( + data.placeholderOnly || false, + ); return (
{ - onChange(updateTree(root, data.id, newName)); + onChange( + updateTree(root, data.id, newName, placeholderOnly), + ); }} value={get(data, 'name')} - autoFocus + // autoFocus InputProps={{ endAdornment: ( { onChange(addLeaf(root, data.id)); }} > - + { onChange(deleteFromTree(root, data.id)); }} /> ), + startAdornment: ( + + { + const newPlaceholderOnly = !placeholderOnly; + setPlaceholderOnly(newPlaceholderOnly); + onChange( + updateTree( + root, + data.id, + data.name, + newPlaceholderOnly, + ), + ); + }} + name="startCheckbox" + color="primary" + /> + } + /> + + ), }} /> {children} @@ -127,17 +171,18 @@ export default function TreeEditor({
- @@ -153,4 +198,4 @@ export default function TreeEditor({
); -} +} \ No newline at end of file diff --git a/src/pages/home/Home.jsx b/src/pages/home/Home.jsx index 7ae911e7e..bdec6f853 100644 --- a/src/pages/home/Home.jsx +++ b/src/pages/home/Home.jsx @@ -33,11 +33,13 @@ export default function Home() { if (!fullName) return ; const isUserManager = get(data, ['is_user_manager'], false); + const isUserAdmin = get(data, ['is_admin'], false); return ( + + + + {currentPageText} + + ); +} \ No newline at end of file diff --git a/src/pages/individualGallery/components/DerivedAnnotatedPhotograph.jsx b/src/pages/individualGallery/components/DerivedAnnotatedPhotograph.jsx index 975dd00c0..91b47941d 100644 --- a/src/pages/individualGallery/components/DerivedAnnotatedPhotograph.jsx +++ b/src/pages/individualGallery/components/DerivedAnnotatedPhotograph.jsx @@ -60,6 +60,7 @@ export default function DerivedAnnotatedPhotograph(props) { if (imageWidth && imageHeight && !isClamped) { return ( ({ + root: { + display: 'flex', + flexWrap: 'wrap', + // width: '100', + }, + image: { + position: 'relative', + height: 200, + [theme.breakpoints.down('xs')]: { + width: '100% !important', // Overrides inline-style + height: 100, + }, + '&:hover, &$focusVisible': { + zIndex: 1, + '& $imageBackdrop': { + opacity: 0.05, + }, + '& $imageMarked': { + opacity: 0, + }, + '& $imageTitle': { + border: '4px solid currentColor', + }, + }, + }, + focusVisible: {}, + imageButton: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.palette.common.white, + }, + imageSrc: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundSize: 'cover', + backgroundPosition: 'center 40%', + }, + imageBackdrop: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: theme.palette.common.black, + opacity: 0.7, + transition: theme.transitions.create('opacity'), + }, + imageBackdropFocus: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + backgroundColor: theme.palette.common.black, + opacity: 0.05, + transition: theme.transitions.create('opacity'), + }, + imageTitle: { + position: 'relative', + padding: `${theme.spacing(2)}px ${theme.spacing(4)}px ${ + theme.spacing(1) + 6 + }px`, + }, + imageMarked: { + height: 3, + width: 18, + backgroundColor: theme.palette.common.white, + position: 'absolute', + bottom: -2, + left: 'calc(50% - 9px)', + transition: theme.transitions.create('opacity'), + }, +})); + +export default function MyImageButton({ + title, + width, + height, + url, + isSelected, + onClick, +}) { + const classes = useStyles(); + + return ( +
+ + {title} + + + + + {title} + + + + +
+ ); +} diff --git a/src/pages/match/ImageCard.jsx b/src/pages/match/ImageCard.jsx index 6caa94b38..176155cdd 100644 --- a/src/pages/match/ImageCard.jsx +++ b/src/pages/match/ImageCard.jsx @@ -1,6 +1,8 @@ -import React, { useMemo } from 'react'; -import { get } from 'lodash-es'; +import React, { useEffect, useMemo, useState } from 'react'; +import _, { get } from 'lodash-es'; +import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos'; +import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos'; import { formatSpecifiedTime } from '../../utils/formatters'; import useSiteSettings from '../../models/site/useSiteSettings'; import AnnotatedPhotograph from '../../components/AnnotatedPhotograph'; @@ -9,8 +11,18 @@ import Link from '../../components/Link'; import Card from '../../components/cards/Card'; import LocationIdViewer from '../../components/fields/view/LocationIdViewer'; import DataLineItem from './DataLineItem'; +import Button from '../../components/Button'; +import MyImageButton from './ImageButton'; +import DerivedAnnotatedPhotograph from '../individualGallery/components/DerivedAnnotatedPhotograph'; -export default function ImageCard({ titleId, annotation, heatmapon, heatmapurl, left }) { +export default function ImageCard({ + titleId, + annotation, + heatmapon, + heatmapurl, + left, + allData, +}) { const { data: siteSettings, loading } = useSiteSettings(); const regionChoices = useMemo( @@ -35,37 +47,139 @@ export default function ImageCard({ titleId, annotation, heatmapon, heatmapurl, annotation?.sighting_time_specificity, ); + const getSelectedIndexByAnnotation = () => { + if (_.isNil(annotation) || _.isEmpty(annotation)) { + return 0; + } + const index = allData.findIndex(data => { + if (data?.guid === annotation?.guid) { + return true; + } + return false; + }); + if (index === -1) { + return 0; + } + return index; + }; + + const getAnnotationByIndex = index => { + if (_.isNil(allData) || _.isEmpty(allData)) { + return annotation; + } + if (index < 0 || index > allData.length - 1) { + return allData[0]; + } + return allData[index]; + }; + + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + const index = getSelectedIndexByAnnotation(); + setSelectedIndex(index); + }, [annotation?.image_url, annotation?.bounds]); + + const getDisplayedIndex = index => { + const arr = _.range(0, allData.length); + if (index === 0) { + return _.take(arr, 3); + } + if (index === arr.length - 1) { + return _.takeRight(arr, 3); + } + return _.slice(arr, index - 1, index + 2); + }; + return ( - { - heatmapon && heatmapurl ? ( - + ) : ( +
+ - ) - : ( - - ) - } - + /> + {allData.length > 1 && ( +
+
+ )} +
+ )} +
({ ...data, - individual_first_name : data.individual_first_name || '-' - })) + individual_first_name: data.individual_first_name || '-', + })); return ( { + encounters.push(data); + return null; + }); + const annotations = []; + encounters.map(data => { + annotations.push(...data.annotations); + return null; + }); + return annotations; + } + + function getAllAnnotationsFromEncounter(encounterData) { + const annotations = encounterData?.annotations; + return annotations; + } + + const getAndDeduplicateAnnotations = ( + individualData, + encounterData, + ) => { + const annotationsFromIndividual = + getAllAnnotationsFromIndividual(individualData) || []; + const annotationsFromEncounter = + getAllAnnotationsFromEncounter(encounterData) || []; + + return ( + _.uniqWith( + [...annotationsFromIndividual, ...annotationsFromEncounter], + _.isEqual, + ) || [] + ); + }; + + const queryAllData = getAndDeduplicateAnnotations( + individualData_query, + encounterData_query, + ); + const matchAllData = getAndDeduplicateAnnotations( + individualData_match, + encounterData_match, + ); + const matchCandidates = useMemo(() => { const hotspotterAnnotationScores = get( selectedQueryAnnotation, @@ -273,6 +334,7 @@ export default function MatchSighting() { heatmapon={checked} heatmapurl={heatMapUrl} left + allData={queryAllData} /> { const _defaultSightingSchemas = sightingFieldSchemas.filter( schema => !schema.customField, - ); + ); const _customSightingSchemas = sightingFieldSchemas.filter( - schema => schema.customField - ); + schema => schema.customField, + ); const visibleEncounterFieldSchemas = encounterFieldSchemas.filter( schema => !schema.hideOnBasicReport, ); @@ -120,7 +119,9 @@ export default function ReportForm({ customEncounterSchemas: _customEncounterSchemas, }; }, [sightingFieldSchemas, encounterFieldSchemas]); - const [acceptedTerms, setAcceptedTerms] = useState(authenticated === "undefined" ? false : authenticated); + const [acceptedTerms, setAcceptedTerms] = useState( + authenticated === undefined ? false : authenticated, + ); // const [exifButtonClicked, setExifButtonClicked] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const [incompleteFields, setIncompleteFields] = useState([]); @@ -152,176 +153,118 @@ export default function ReportForm({ setEncounterFormValues(initialDefaultEncounterFormValues); setCustomEncounterFormValues(initialCustomEncounterFormValues); }, [ - customEncounterSchemas, - customSightingSchemas, - defaultEncounterSchemas, - defaultSightingSchemas, - sightingFieldSchemas, - encounterFieldSchemas, + customEncounterSchemas.join('-'), + customSightingSchemas.join('-'), + defaultEncounterSchemas.join('-'), + defaultSightingSchemas.join('-'), + sightingFieldSchemas.join('-'), + encounterFieldSchemas.join('-'), ]); - const requiredDefaultSightingSchemas = defaultSightingSchemas.filter(data => data.required); - const optionalDefaultSightingSchemas = defaultSightingSchemas.filter(data => !data.required); - const requiredCustomSightingSchemas = customSightingSchemas.filter(data => data.required); - const optionalCustomSightingSchemas = customSightingSchemas.filter(data => !data.required); - const requiredDefaultEncounterFieldSchemas = defaultEncounterSchemas.filter(data => data.required); - const optionalDefaultEncounterFieldSchemas = defaultEncounterSchemas.filter(data => !data.required); - const requiredCustomEncounterFieldSchemas = customEncounterSchemas.filter(data => data.required); - const optionalCustomEncounterFieldSchemas = customEncounterSchemas.filter(data => !data.required); + const requiredDefaultSightingSchemas = + defaultSightingSchemas.filter(data => data.required); + const optionalDefaultSightingSchemas = + defaultSightingSchemas.filter(data => !data.required); + const requiredCustomSightingSchemas = customSightingSchemas.filter( + data => data.required, + ); + const optionalCustomSightingSchemas = customSightingSchemas.filter( + data => !data.required, + ); + const requiredDefaultEncounterFieldSchemas = + defaultEncounterSchemas.filter(data => data.required); + const optionalDefaultEncounterFieldSchemas = + defaultEncounterSchemas.filter(data => !data.required); + const requiredCustomEncounterFieldSchemas = + customEncounterSchemas.filter(data => data.required); + const optionalCustomEncounterFieldSchemas = + customEncounterSchemas.filter(data => !data.required); const [optional, setOptional] = useState(false); let formValid = false; const checkRequired = () => { const sightingsRequired = sightingFieldSchemas - .filter( - schema => schema.customField && schema.required && !customSightingFormValues[schema.name], + .filter( + schema => + schema.customField && + schema.required && + !customSightingFormValues[schema.name], ) - .map(data => ({ ...data, labelId: data.label })); + .map(data => ({ ...data, labelId: data.label })); const encountersRequired = encounterFieldSchemas - .filter( - schema => sightingType === 'one' && schema.customField && schema.required && !customEncounterFormValues[schema.name], + .filter( + schema => + sightingType === 'one' && + schema.customField && + schema.required && + !customEncounterFormValues[schema.name], ) - .map(data => ({ ...data, labelId: data.label })); - const requiredCustomFields = sightingsRequired.concat(encountersRequired); - // check that required fields are complete. - // specifiedTime field is required, but the logic and message - // are different from the other fields - const nextIncompleteFields = - defaultSightingSchemas.filter( - field => - field.required && - field.defaultValue === - sightingFormValues[field.name] && - field.name !== 'specifiedTime', - ).concat(requiredCustomFields); - - setIncompleteFields(nextIncompleteFields); - - // check that specifiedTime fields are complete - let nextFormErrorId = null; - const formTimeSpecificity = get(sightingFormValues, [ - 'specifiedTime', - 'timeSpecificity', - ]); - const formTime = get(sightingFormValues, [ - 'specifiedTime', - 'time', - ]); - - const specifiedTimeField = - defaultSightingSchemas.find( - field => field.name === 'specifiedTime', - ) || {}; - - const defaultTimeSpecificity = get(specifiedTimeField, [ - 'defaultValue', - 'timeSpecificity', - ]); - - const defaultTime = get(specifiedTimeField, [ - 'defaultValue', - 'time', - ]); - - const isTimeSpecificityDefault = - formTimeSpecificity === defaultTimeSpecificity; - - const isTimeDefault = formTime === defaultTime; - - if ( - !formTimeSpecificity || - isTimeSpecificityDefault || - !formTime || - isTimeDefault - ) { - nextFormErrorId = 'INCOMPLETE_TIME_SPECIFICITY'; - } - - setFormErrorId(nextFormErrorId); - - // check that terms and conditions were accepted - setTermsError(!acceptedTerms); - formValid = - nextIncompleteFields.length === 0 && - acceptedTerms && - !nextFormErrorId; - - } - const submitReport = async () => { - checkRequired(); - if (formValid) { - const report = - sightingType === 'one' - ? prepareReportWithEncounter( - sightingFormValues, - customSightingFormValues, - customSightingSchemas, - assetReferences, - encounterFormValues, - customEncounterFormValues, - customEncounterSchemas, - true, - ) - : prepareBasicReport( - sightingFormValues, - customSightingFormValues, - customSightingSchemas, - assetReferences, - true, - ); - - const assetGroup = { - description: 'Form report from user', - uploadType: 'form', - speciesDetectionModel: get( - report, - 'speciesDetectionModel', - [], - ), - transactionId: get(assetReferences, [ - 0, - 'transactionId', - ]), - sightings: [report], - }; - if (window.grecaptcha) { - const grecaptchaReady = new Promise(resolve => { - window.grecaptcha.ready(() => { - resolve(); - }); - }); - - await grecaptchaReady; - - const token = await window.grecaptcha.execute( - recaptchaPublicKey, - { action: 'submit' }, - ); - assetGroup.token = token; - } - - const assetGroupData = await postAssetGroup( - assetGroup, - ); - - const assetGroupSightingId = get(assetGroupData, [ - 'asset_group_sightings', - '0', - 'guid', - ]); - if (assetGroupSightingId) { - const relativeUrl = authenticated - ? `/pending-sightings/${assetGroupSightingId}` - : '/report/success/'; - history.push(relativeUrl); - } - } - - } - - // const locationSuggestion = useMemo( - // () => getLocationSuggestion(exifData), - // [exifData], - // ); + .map(data => ({ ...data, labelId: data.label })); + const requiredCustomFields = sightingsRequired.concat( + encountersRequired, + ); + // check that required fields are complete. + // specifiedTime field is required, but the logic and message + // are different from the other fields + const nextIncompleteFields = defaultSightingSchemas + .filter( + field => + field.required && + field.defaultValue === sightingFormValues[field.name] && + field.name !== 'specifiedTime', + ) + .concat(requiredCustomFields); + + setIncompleteFields(nextIncompleteFields); + + // check that specifiedTime fields are complete + let nextFormErrorId = null; + const formTimeSpecificity = get(sightingFormValues, [ + 'specifiedTime', + 'timeSpecificity', + ]); + const formTime = get(sightingFormValues, [ + 'specifiedTime', + 'time', + ]); + + const specifiedTimeField = + defaultSightingSchemas.find( + field => field.name === 'specifiedTime', + ) || {}; + + const defaultTimeSpecificity = get(specifiedTimeField, [ + 'defaultValue', + 'timeSpecificity', + ]); + + const defaultTime = get(specifiedTimeField, [ + 'defaultValue', + 'time', + ]); + + const isTimeSpecificityDefault = + formTimeSpecificity === defaultTimeSpecificity; + + const isTimeDefault = formTime === defaultTime; + + if ( + !formTimeSpecificity || + isTimeSpecificityDefault || + !formTime || + isTimeDefault + ) { + nextFormErrorId = 'INCOMPLETE_TIME_SPECIFICITY'; + } + + setFormErrorId(nextFormErrorId); + + // check that terms and conditions were accepted + setTermsError(!acceptedTerms); + formValid = + nextIncompleteFields.length === 0 && + acceptedTerms && + !nextFormErrorId; + }; const { postAssetGroup, @@ -329,6 +272,77 @@ export default function ReportForm({ error: postAssetGroupError, } = usePostAssetGroup(); + const submitReport = async () => { + checkRequired(); + if (formValid) { + const report = + sightingType === 'one' + ? prepareReportWithEncounter( + sightingFormValues, + customSightingFormValues, + customSightingSchemas, + assetReferences, + encounterFormValues, + customEncounterFormValues, + customEncounterSchemas, + true, + ) + : prepareBasicReport( + sightingFormValues, + customSightingFormValues, + customSightingSchemas, + assetReferences, + true, + ); + + const assetGroup = { + description: 'Form report from user', + uploadType: 'form', + speciesDetectionModel: get( + report, + 'speciesDetectionModel', + [], + ), + transactionId: get(assetReferences, [0, 'transactionId']), + sightings: [report], + }; + if (window.grecaptcha) { + const grecaptchaReady = new Promise(resolve => { + window.grecaptcha.ready(() => { + resolve(); + }); + }); + + await grecaptchaReady; + + const token = await window.grecaptcha.execute( + recaptchaPublicKey, + { action: 'submit' }, + ); + assetGroup.token = token; + } + + const assetGroupData = await postAssetGroup(assetGroup); + + const assetGroupSightingId = get(assetGroupData, [ + 'asset_group_sightings', + '0', + 'guid', + ]); + if (assetGroupSightingId) { + const relativeUrl = authenticated + ? `/pending-sightings/${assetGroupSightingId}` + : '/report/success/'; + history.push(relativeUrl); + } + } + }; + + // const locationSuggestion = useMemo( + // () => getLocationSuggestion(exifData), + // [exifData], + // ); + const showErrorAlertBox = incompleteFields.length > 0 || termsError || @@ -343,17 +357,15 @@ export default function ReportForm({ setDialogOpen(false)} - /> - { - !optional && ( - - ) - } + /> + {!optional && ( + + )} {!optional && sightingType && ( <> )} - { - !optional && sightingType === 'one' && ( - <> - - - - ) - } + {!optional && sightingType === 'one' && ( + <> + + + + )} {optional && ( - <> - - - - )} + <> + + + + )} {optional && sightingType === 'one' && ( <> @@ -421,8 +431,8 @@ export default function ReportForm({ fieldSchema={optionalCustomEncounterFieldSchemas} /> - )} - + )} + {!optional && hasSightingTypeAndNotAuthenticated && ( ))} @@ -475,52 +489,51 @@ export default function ReportForm({ marginTop: 12, }} > - -
- -
) : null} - - {optional && sightingType ? ( + {optional && sightingType ? ( -