diff --git a/.eslintrc.json b/.eslintrc.json index 159df0193..4014fd2b4 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -34,6 +34,7 @@ "react/destructuring-assignment": 0, "react/forbid-prop-types": 0, "react/jsx-wrap-multilines": 0, + "react/style-prop-object": [2, { "allow": ["FormattedNumber"] }], "react-hooks/exhaustive-deps": 1, "import/prefer-default-export": 0, "function-paren-newline": 0, diff --git a/docs/contribution-guide.md b/docs/contribution-guide.md index 88a935833..229c5afb7 100644 --- a/docs/contribution-guide.md +++ b/docs/contribution-guide.md @@ -22,6 +22,20 @@ All displayed text must support translation - for this we use `react-intl`. Tran If you want to help translate the project, that is very much appreciated and needed, but please don't do it by manually editing files in `/locale`. Your changes will wind up getting overwritten by Lokalise. +## Lodash + +We frequently use a utility library called [lodash](https://lodash.com/docs/). When importing a utility function, be sure to use named imports rather than importing the entire library to reduce our bundle size. One incorrect import will cause the entire library to be bundled with the application. + +``` +import _ from 'lodash-es'; // incorrect, please don't do this +_.random(10); + +import { random } from 'lodash-es'; // correct! +random(10); +``` + +In situations where a lodash utility and a native utility exist, we should use the native utility unless there is a reason to use lodash (eg. native JS `map` instead of `_.map()`). + ## Conventions - Any file with a React component should have the suffix `.jsx` diff --git a/locale/en.json b/locale/en.json index f283d0a5d..f85844035 100644 --- a/locale/en.json +++ b/locale/en.json @@ -7,18 +7,25 @@ "COMMUNITY_FORUMS": "Community forums", "ENCOUNTERS": "Encounters", "ADD_NEW": "Add new", + "ADD_SOCIAL_GROUP": "Add social group", + "SOCIAL_GROUP_SUBHEADER": "Social group created on {dateCreated}", + "ALL_SOCIAL_GROUPS": "All social groups", "BULK_IMPORT": "Bulk import", "LIME": "Lime", "LABEL": "Label", "TYPE": "Type", "FOREST": "Forest", + "NAME_OF_GROUP": "Name of group", "STAGE": "Stage", "NO_PENDING_PUBLIC_SIGHTINGS": "There are no pending public sightings", "DATA_UNAVAILABLE": "Data unavailable", - "DATA_MANAGER": "Data Manager", "ANNOTATION_CLASS": "Annotation class", "INDIVIDUAL_METADATA": "Individual profile", "COMMIT": "Commit", + "UNNAMED_SOCIAL_GROUP": "Unnamed social group", + "SOCIAL_GROUP_NOT_FOUND": "Social group not found", + "SOCIAL_GROUP_NOT_FOUND_DESCRIPTION": "Social group lookup failed. There may be a problem with the URL or the social group may no longer exist.", + "SOCIAL_GROUP_DELETE_CONFIRMATION": "Are you sure you want to delete this social group? This action cannot be undone.", "REGION_MATCHING_SET_DESCRIPTION": "Select the region you would like to match against. Sub-regions will be included in the matching set. If no region is selected, all regions will be matched against.", "CREATED": "Created", "BLANK": " ", @@ -68,11 +75,11 @@ "ALL_NOTIFICATION_EMAILS": "All notification emails", "COLLABORATION_EDIT_REQUESTS": "Edit requests", "COLLABORATION_REQUESTS": "Collaboration requests", - "INDIVIDUAL_MERGE_REQUESTS": "Individual merge requests", + "MERGE_OF_INDIVIDUAL": "Merge of individual", "ALL_NOTIFICATION_EMAILS_DESCRIPTION": "Receive emails for notifications per the settings below.", "COLLABORATION_EDIT_REQUESTS_DESCRIPTION": "Receive an email whenever someone requests to upgrade our collaboration for edit access.", "COLLABORATION_REQUESTS_DESCRIPTION": "Receive an email whenever someone sends me a collaboration request.", - "INDIVIDUAL_MERGE_REQUESTS_DESCRIPTION": "Receive an email whenever someone attempts to merge an individual that I created.", + "MERGE_OF_INDIVIDUAL_DESCRIPTION": "Receive email notifications for merge activity involving any individual that I created.", "GRANT_ACCESS": "Grant access", "REVOKE_ACCESS": "Revoke access", "COLLABORATION_REQUEST": "Collaboration request", @@ -130,8 +137,6 @@ "DELETE_ANNOTATION": "Delete annotation", "DELETE_ANIMAL": "Delete animal", "PENDING_BULK_IMPORT": "Bulk import in progress", - "SIGHTING_STATE_IN_PROGRESS": "In progress ({timeDelta} so far).", - "IN_PROGRESS": "In progress.", "NEW_INDIVIDUAL_CREATED_DESCRIPTION": "New individual created.", "PENDING_BULK_IMPORT_MESSAGE": "You reported {sightingCount} sightings on {date}. You must finish processing this bulk import before starting another.", "PENDING_IMAGE_PROCESSING": "Image processing in progress", @@ -141,22 +146,11 @@ "HISTORY": "History", "SIGHTING_PREPARATION_ALERT_TITLE": "This sighting has not finished processing", "SIGHTING_PREPARATION_ALERT_MESSAGE": "Until this step is complete, images cannot be viewed and some functionality is unavailable.", - "SIGHTING_PREPARATION_SKIPPED_MESSAGE": "Sighting preparation skipped.", - "DETECTION_SKIPPED_MESSAGE": "Detection skipped.", - "DETECTION_SKIPPED_NO_IMAGES_MESSAGE": "Detection skipped - no images to annotate.", - "DETECTION_SKIPPED_NO_MODEL_MESSAGE": "Detection skipped - no detection model was specified.", - "CURATION_SKIPPED_MESSAGE": "Curation skipped - no images to curate.", - "IDENTIFICATION_SKIPPED_MESSAGE": "Identification skipped.", - "IDENTIFICATION_SKIPPED_NO_IMAGES_MESSAGE": "Identification skipped - no images to match.", - "IDENTIFICATION_SKIPPED_MIGRATED_MESSAGE_DEFAULT": "Identification skipped - this sighting was migrated from a Wildbook database.", - "IDENTIFICATION_SKIPPED_MIGRATED_MESSAGE_SITE": "Identification skipped - this sighting was migrated from {migratedSiteName}.", - "CURATION_INSTRUCTIONS": "All annotations must be assigned to animals before identification can begin.", "IDENTIFICATION": "Identification", "WAITING_ELLIPSES": "Waiting...", - "CURATION_ASSIGN_ANNOTATIONS": "Assign annotations", - "CURATION_ANNOTATE_PHOTOS": "Annotate photographs", - "IDENTIFICATION_VIEW_RESULTS": "View match results", "REPORTED_BY": "Reported by ", + "REPORTED_BY_USER": "Reported by {name}", + "REPORTED_BY_UNNAMED_USER": "Reported by Unnamed User", "GENERAL_SETTINGS": "General settings", "ONE_SIGHTING_TOGGLE_DESCRIPTION": "This sighting has only one individual and I would like to report metadata about it now.", "FRONT_PAGE": "Front page", @@ -202,10 +196,11 @@ "FIRST_NAME": "First name", "ADOPTION_NAME": "Adoption name", "ALIAS": "Alias", - "SETTINGS_AND_PRIVACY": "My settings & privacy", + "PREFERENCES": "Preferences", "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_USER_INSTRUCTION": "Search for a user by name or email", "PRAIRIE": "Prairie", "EDIT_USER_METADATA": "Edit user metadata", "EDIT_SIGHTING_METADATA": "Edit sighting metadata", @@ -225,16 +220,6 @@ "TIME_SECONDS": "Time (seconds)", "TIMEZONE": "Timezone", "ADD_TAG": "Add tag", - "SIGHTING_PREPARATION_FINISHED_MESSAGE": "Sighting preparation finished on {date}. ({photoCount} {photoCount, plural, =0 {photographs} one {photograph} other {photographs}})", - "DETECTION_FINISHED_MESSAGE": "Image detection finished on {date}.", - "CURATION_FINISHED_MESSAGE": "Image curation finished on {date}.", - "MIGRATION_FINISHED_MESSAGE_DEFAULT": "Sighting migration from a Wildbook database finished on {date}.", - "MIGRATION_FINISHED_MESSAGE_SITE": "Sighting migration from {migratedSiteName} finished on {date}.", - "IDENTIFICATION_FINISHED_MESSAGE": "Identification finished on {date}.", - "SIGHTING_PREPARATION_FAILED": "Sighting preparation failed.", - "DETECTION_FAILED": "Image detection failed.", - "CURATION_FAILED": "Image curation failed.", - "IDENTIFICATION_FAILED": "Image identification failed.", "SIGHTING_START": "Sighting start", "SIGHTING_END": "Sighting end", "NOT_READY_FOR_COMMIT": "This sighting is pending", @@ -452,6 +437,7 @@ "PRIVATE_SITE": "Private", "PRIVATE_SITE_DESCRIPTION": "Future versions of Codex will support public sites.", "SITE_NAME": "Site name", + "SITE_NAME_IS_REQUIRED": "Site name is required.", "SITE_NAME_DESCRIPTION": "The name of this site, excluding domain name suffix (ie. PandaMatcher, NOT pandamatcher.org)", "FEET": "feet", "SITE_SETTINGS": "Site configuration", @@ -493,7 +479,6 @@ "ADD_ANOTHER_FIELD": "Add another field", "FEET_METERS_SELECTOR": "Feet/meters selector", "INDIVIDUAL_SELECTOR": "Individual selector", - "RELATIONSHIPS_SELECTOR": "Relationships selector", "INTEGER_LABEL": "Whole number", "FILE_UPLOADER": "File uploader", "LAT_LONG_SELECTOR": "Lat/long selector", @@ -568,7 +553,6 @@ "SEX_IS": "Sex: {sex}", "REGIONS_DESCRIPTION": "These regions will be available to choose from during submission and search. Region assignment is used to improve performance by allowing for matching against a reduced set up submissions. Click ↳ on a region to create a nested subregion.", "DROPDOWN_CHOICE_HELPER_TEXT": "Each option needs a value and a label. Use the label field for a brief and well-formatted, English-language name (eg. 'Some scars'). The value field specifies how the option will be stored in databases and appear in data exports. Values cannot contain spaces or special characters (eg. 'somescars').", - "RELATIONSHIP_OPTIONS_HELPER_TEXT": "Each relationship type needs a value and a label. Use the label field for a brief and well-formatted, English-language name (eg. 'Best friend'). The value field specifies how the option will be stored in databases and appear in data exports. Values cannot contain spaces or special characters (eg. 'bestfriend').", "FILETYPE_OPTIONS_HELPER_TEXT": "Enter the file types you wish to allow for this input, including the dot (ie. '.jpg' or '.wav'). If you skip this step, all file types will be permitted.", "CHOOSE_FIELDS_DESCRIPTION": "The following fields are available for data import. Each field should be a column in your spreadsheet. Note that region, time, and time precision are required. For sighting time, use whatever columns are known. For example, if the full date is known but not the time of day, leave the hours, minutes, and seconds columns blank.", "GENERATE_DISABLED_NO_LOCATION": "At least one location field must be selected (region, exact location, or location freeform).", @@ -587,7 +571,6 @@ "CUSTOM_FIELD_SAME_LABEL_ERROR": "Error: two or more {fieldsetName}s have the same value.", "CUSTOM_FIELD_SAME_VALUE_ERROR": "Error: two or more {fieldsetName}s have the same value.", "FINISH": "Finish", - "RELATIONSHIP_OPTIONS": "Relationship options", "MALE": "Male", "FEMALE": "Female", "OPTION": "Option", @@ -631,6 +614,7 @@ "BACK_TO_PHOTOS": "Back to photographs", "BACK_TO_SELECTION": "Back to selection", "INCOMPLETE_FIELD": "{fieldName} is a required field.", + "INCOMPLETE_TIME_SPECIFICITY": "Enter the Year information as well as the Time Specificity.", "CONTINUE_WITHOUT_PHOTOGRAPHS": "Continue without photographs", "LAST_SIGHTING_DATE_RANGE": "Last sighting date", "LAST_SIGHTING_DATE_RANGE_DESCRIPTION": "Date of this individual's most recent sighting.", @@ -833,7 +817,6 @@ "STATUS_INDIVIDUALS_DESCRIPTION": "Last known status of the individual.", "STATUS_SIGHTING_DESCRIPTION": "Status at the time of the sighting.", "SIGHTING_REPORTED_ON": "Sighting reported on {date}", - "SIGHTING_SUBMISSION_REPORT_DATE": "Sighting reported on {date}", "MEMBER_COUNT": "{memberCount} {memberCount, plural, =0 {members} one {member} other {members}}", "SEND_INVITATION": "Send invitation", "SEX": "Sex", @@ -1005,6 +988,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.", + "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", "CONFIGURATION_SITE_NAME_DESCRIPTION": "The name of this site, excluding domain name suffix (ie. PandaMatcher, NOT pandamatcher.org).", @@ -1023,20 +1007,23 @@ "GPS_TITLE": "Sighting Location", "INVALID_GPS": "Invalid GPS Coordinates", "UNNAMED_USER": "Unnamed User", + "UNNAMED_USER_WITH_EMAIL": "Unnamed User ({email})", + "UNNAMED_USER_WITH_UNKNOWN_EMAIL": "Unnamed User (unknown email)", + "USER_WITH_EMAIL": "{fullName} ({email})", + "USER_WITH_UNKNOWN_EMAIL": "{fullName} (unknown email)", "REGION_NAME_REMOVED": "Region Name Has Been Removed", "SELECT_COLLABORATOR_1": "Select Collaborator 1", "SELECT_COLLABORATOR_2": "Select Collaborator 2", "CREATE_COLLABORATIONS": "Create Collaborations", "CREATE_COLLABORATION": "Create Collaboration", "COLLABORATION_CREATED": "Collaboration Created", - "EDIT_COLLABORATIONS": "Edit Collaborations", + "USER_MANAGEMENT_COLLABORATIONS": "Collaborations", "USER_ONE": "User 1", "USER_TWO": "User 2", + "USER_X": "User {userNumber, number}", + "COLLABORATION_CURRENT_ACCESS": "Current access", + "COLLABORATION_REQUESTED_ACCESS": "Requested access", "COLLABORATION_STATUS": "Status", - "USER_ONE_VIEW_STATUS": "User 1 Status", - "USER_TWO_VIEW_STATUS": "User 2 Status", - "USER_ONE_EDIT_STATUS": "User 1 Edit", - "USER_TWO_EDIT_STATUS": "User 2 Edit", "COLLABORATION_ALREADY_EXISTS": "A collaboration between these two users has already been created", "SIGHTING_ON_MAP": "Sighting", "COLLABORATION_DATA_ERROR": "Error fetching collaboration data", @@ -1044,9 +1031,10 @@ "COLLABORATION_REVOKE_SUCCESS": "Successfully revoked the collaboration", "UNKNOWN_ERROR": "Unknown error", "REVOKED_COLLAB_EXISTS": "Could not create collaboration - a revoked collaboration between those users already exists", - "COLLAB_REVOKE_ERROR_SUPPLEMENTAL": "Note that collaborations cannot be revoked unless they are mutually approved.", + "COLLAB_REVOKE_ERROR_SUPPLEMENTAL": "{error}. Note that collaborations cannot be revoked unless they are mutually approved.", "PENDING_SIGHTINGS": "Pending Sightings", "UNKNOWN_DATE": "Unknown date", + "UNKNOWN_ENCOUNTER_DATE": "unknown encounter date", "CREATED_ON_DATE": "Created on {createdDate}", "SENT_YOU_A_COLLABORATION_REQUEST": "{userName} sent you a collaboration request", "A_COLLABORATION_WAS_CREATED_ON_YOUR_BEHALF": "A collaboration was created on your behalf", @@ -1065,7 +1053,7 @@ "INDIVIDUALS_MERGE_PENDING_TITLE": "Merge pending", "INDIVIDUALS_MERGE_PENDING_DESCRIPTION": "Your merge could not be completed immediately because you do not have edit access to all of the sightings associated with these individuals. A notification has been sent to the researchers who own these sightings so they can approve or block the merge. They must respond by {deadlineDate} to block the merge or it will proceed.", "DATE_MISSING": "DATE MISSING", - "COLLABORATION_ESTABLISHED_BY_USER_MANAGER": "Collaboration established by your user manager", + "COLLABORATION_ESTABLISHED_BY_USER_MANAGER": "Collaboration established by a user manager", "COLLABORATION_REVOKE_TITLE": "Revoke collaboration", "COLLABORATION_EDIT_REQUEST_TITLE": "Edit collaboration request", "COLLABORATION_EDIT_APPROVED_TITLE": "Collaboration edit approved", @@ -1092,12 +1080,15 @@ "BLOCK_MERGE": "Block", "SELECT_RELATIONSHIP_TYPE": "Select relationship type", "SELECT_RELATIONSHIP_ROLE": "Select role from {ind}'s perspective", - "NO_RELATIONSHIPS": "This individual currently has no known relationships", + "NO_RELATIONSHIPS": "This individual does not have any relationships.", + "NO_SOCIAL_GROUPS_ON_INDIVIDUAL": "This individual is not part of any social groups.", + "NO_SOCIAL_GROUPS_ON_SITE": "There are no social groups on this site.", "NO_PERMISSIONS_ERROR_SUBTITLE": "Permissions error", "NOT_AUTHENTICATED_ERROR_SUBTITLE": "Authentication error", "NO_PERMISSIONS_ERROR_DETAILS": "You do not have permissions to access the requested resource.", "NOT_AUTHENTICATED_ERROR_DETAILS": "Your request could not be processed due to an authentication error. Log in and try again.", "CONFIRM_REMOVE_RELATIONSHIP": "Are you sure you want to delete this relationship?", + "CONFIRM_REMOVE_INDIVIDUAL_FROM_SOCIAL_GROUP": "Are you sure you want to remove this individual from this social group?", "TWITTER_HANDLE": "Twitter handle", "CONFIGURATION_INTELLIGENT_AGENT_TWITTERBOT_ENABLED_LABEL": "Allow Twitter submissions?", "CONFIGURATION_INTELLIGENT_AGENT_TWITTERBOT_ENABLED_DESCRIPTION": "Any Twitter user will be able to create submissions to this platform via tweet. Platform users can add their Twitter handle on their user profile to maintain ownership of the sightings. Otherwise, the data will be public. Make sure that the bio of the platform twitter account reminds users to include image(s), a hashtag of the species, region/location id, and time/date.", @@ -1125,11 +1116,9 @@ "PASSWORD_RESET_ERROR": "Password reset error", "PASSWORD_RESET_SUCCESS": "Password reset success", "RETURN_TO_LOGIN_PAGE": "Return to login page", - "RESTORE": "Restore", - "COLLABORATION_RESTORE_SUCCESS": "Successfully restored the collaboration", - "COLLABORATION_RESTORE_ERROR": "Error restoring the collaboration", - "COLLAB_RESTORE_ERROR_SUPPLEMENTAL": "Collaboration was not successfully restored.", + "NO_INDIVIDUALS_IN_SOCIAL_GROUP": "This group contains no individuals.", "PENDING": "pending", + "UNKNOWN_ROLE": "Unknown role", "PENDING_CITIZEN_SCIENCE_SIGHTINGS": "Pending citizen science sightings", "CANDIDATE_ANNOTATIONS": "Candidate annotations", "SIGHTING_DELETE_VULNERABLE_INDIVIDUAL_MESSAGE": "Deleting this sighting would result in assigned individuals being deleted. Are you sure you want to continue?", @@ -1141,12 +1130,86 @@ "PAGINATION_PREVIOUS_PAGE": "previous page", "PAGINATION_NEXT_PAGE": "next page", "SOCIAL_GROUPS": "Social groups", - "CONFIGURATION_SOCIAL_GROUP_ROLES_DESCRIPTION": "These roles populate the dropdown menu for any social groups made. It is recommended to include a generic role, such as, \"Member\"", + "SOCIAL_GROUP": "Social group", + "ADD_TO_SOCIAL_GROUP": "Add to social group", + "CONFIGURATION_SOCIAL_GROUP_ROLES_DESCRIPTION": "These roles populate the dropdown menu for any social groups made. It is recommended to include a generic role, such as \"Member\".", "CONFIGURATION_SOCIAL_GROUP_ROLES_LABEL": "Social group roles", "NEW_SOCIAL_GROUP_ROLE": "Add role", "ALLOW_MULTIPLE_OF_THIS_ROLE": "Allow multiple of this role", "ONE_OR_MORE_ROLES_MISSING_LABELS": "One or more roles are missing labels", "TWO_OR_MORE_ROLES_SAME_LABEL": "Two or more roles have the same label. Make sure each label is different", "ROLE_GUID_MISSING": "Role is missing ID", - "UNFINISHED_OPTIONS": "Options must have valid values and labels and unique values" + "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_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.", + "PROGRESS_STATISTICS_ETA_&_QUEUE": "{timeRemaining} left. Queued behind {ahead, number} {ahead, plural, one {job} other {jobs}}.", + "STATUS_SUBMISSION_REPORT_ON": "Sighting reported on {date}.", + "STATUS_SUBMISSION_REPORT_ON_UNKNOWN": "Sighting reported on unknown date.", + "STATUS_PREPARATION_STARTED_ON": "Sighting preparation started on {date}.", + "STATUS_PREPARATION_STARTED_ON_UNKNOWN": "Sighting preparation started on unknown date.", + "STATUS_PREPARATION_FINISHED_ON": "Sighting preparation finished on {date}. ({photoCount} {photoCount, plural, one {photograph} other {photographs}})", + "STATUS_PREPARATION_FINISHED_ON_UNKNOWN": "Sighting preparation finished on unknown date. ({photoCount} {photoCount, plural, one {photograph} other {photographs}})", + "STATUS_PREPARATION_SKIPPED": "Sighting preparation skipped.", + "STATUS_PREPARATION_FAILED": "Sighting preparation failed.", + "STATUS_DETECTION_STARTED_ON": "Image detection started on {date}.", + "STATUS_DETECTION_STARTED_ON_UNKNOWN": "Image detection started on unknown date.", + "STATUS_DETECTION_FINISHED_ON": "Image detection finished on {date}.", + "STATUS_DETECTION_FINISHED_ON_UNKNOWN": "Image detection finished on unknown date.", + "STATUS_DETECTION_SKIPPED": "Image detection skipped.", + "STATUS_DETECTION_SKIPPED_NO_IMAGES": "Image detection skipped - no images to annotate.", + "STATUS_DETECTION_SKIPPED_NO_MODEL": "Image detection skipped - no detection model was specified.", + "STATUS_DETECTION_FAILED": "Image detection failed.", + "STATUS_CURATION_CURRENT_EDIT": "All annotations must be assigned to animals before identification can begin.", + "STATUS_CURATION_CURRENT_VIEW": "Image curation in progress.", + "STATUS_CURATION_CURRENT_ASSIGN_ANNOTATIONS": "Assign annotations", + "STATUS_CURATION_CURRENT_ANNOTATE_PHOTOS": "Annotate photographs", + "STATUS_CURATION_FINISHED_ON": "Image curation finished on {date}.", + "STATUS_CURATION_FINISHED_ON_UNKNOWN": "Image curation finished on unknown date.", + "STATUS_CURATION_MIGRATED_FROM_SITE_FINISHED_ON": "Sighting migration from {migratedSiteName} finished on {date}.", + "STATUS_CURATION_MIGRATED_FROM_SITE_FINISHED_ON_UNKNOWN": "Sighting migration from {migratedSiteName} finished on unknown date.", + "STATUS_CURATION_MIGRATED_FROM_UNKNOWN_SITE_FINISHED_ON": "Sighting migration from a Wildbook database finished on {date}.", + "STATUS_CURATION_MIGRATED_FROM_UNKNOWN_SITE_FINISHED_ON_UNKNOWN": "Sighting migration from a Wildbook database finished on unknown date.", + "STATUS_CURATION_SKIPPED": "Image curation skipped - no images to curate.", + "STATUS_CURATION_FAILED": "Image curation failed.", + "STATUS_IDENTIFICATION_STARTED_ON": "Identification started on {date}.", + "STATUS_IDENTIFICATION_STARTED_ON_UNKNOWN": "Identification started on unknown date.", + "STATUS_IDENTIFICATION_FINISHED_ON": "Identification finished on {date}.", + "STATUS_IDENTIFICATION_FINISHED_ON_UNKNOWN": "Identification finished on unknown date.", + "STATUS_IDENTIFICATION_FINISHED_VIEW_RESULTS": "View match results", + "STATUS_IDENTIFICATION_SKIPPED": "Identification skipped.", + "STATUS_IDENTIFICATION_SKIPPED_NO_IMAGES": "Identification skipped - no images to match.", + "STATUS_IDENTIFICATION_SKIPPED_MIGRATED_FROM_SITE": "Identification skipped - this sighting was migrated from {migratedSiteName}.", + "STATUS_IDENTIFICATION_SKIPPED_MIGRATED_FROM_UNKNOWN_SITE": "Identification skipped - this sighting was migrated from a Wildbook database.", + "STATUS_IDENTIFICATION_FAILED": "Identification failed.", + "INDIVIDUAL_GALLERY_SEE_ALL": "See all", + "INDIVIDUAL_GALLERY_TITLE": "{name}'s gallery", + "INDIVIDUAL_GALLERY_IMAGE_ALT": "{annotationCount} {annotationCount, plural, one {annotation} other {annotations}} with {assetClassCount, plural, one {class} other {classes}} {assetClassesList}", + "INDIVIDUAL_GALLERY_UNABLE_TO_DISPLAY_ANNOTATIONS": "We are currently unable to show annotations for uploaded images with either a width or a height of 4,096 pixels or more.", + "INDIVIDUAL_BACK_TO_PROFILE": "Return to {name}'s profile", + "ERROR_FETCHING_IMAGE": "Error fetching image", + "COLLABORATION_STATE_VIEW": "View", + "COLLABORATION_STATE_EDIT": "Edit", + "COLLABORATION_STATE_REVOKED": "Revoked", + "EDIT_COLLABORATION": "Edit collaboration", + "EDIT_COLLABORATION_CURRENT_STATE_LABEL": "Current state", + "EDIT_COLLABORATION_CURRENT_STATE_DESCRIPTION": "Changing the state will cancel any pending requests.", + "COLLABORATIONS": "Collaborations", + "URLS_MUST_INCLUDE_HTTPS": "All URLs must include https:// to be valid", + "ADD_COLLABORATION": "Add collaboration", + "EMAIL": "Email", + "EDIT_COLLABORATION_REVOKED_BY_USER_MANAGER": "Edit 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}.", + "COLLABORATION_EDIT_DENIED": "Collaboration edit denied", + "EDIT_COLLABORATION_DENIED_MESSAGE": "{userName} denied your collaboration edit 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", + "EDIT_COLLABORATION_DENIED_BY_USER_MANAGER_MESSAGE": "An edit-level collaboration with {otherUserNameForManagerNotifications} was denied by a user manager {managerName}.", + "EDIT_COLLABORATION_APPROVED_BY_USER_MANAGER": "Edit 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}.", + "UNNAMED_MANAGER": "Unnamed manager" } diff --git a/package.json b/package.json index c73292333..09e514620 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-frontend", - "version": "1.0.2", + "version": "1.1.0", "description": "", "main": "index.js", "scripts": { @@ -10,6 +10,7 @@ "lint": "NODE_ENV=development BABEL_ENV=development ./node_modules/.bin/eslint --fix \"src/**/*.{js,jsx}\"", "format": "./node_modules/.bin/prettier --write \"src/**/*.{js,jsx}\"", "test": "echo \"Error: no test specified\" && exit 1", + "print-unused-keys": "node ./scripts/printUnusedKeys.js", "clean": "rm -rf dist", "build": "npm run clean && NODE_ENV=production webpack --config ./config/webpack/webpack.common.js", "prepare": "husky install" @@ -68,6 +69,7 @@ "eslint-plugin-react": "~7.28.0", "eslint-plugin-react-hooks": "~4.3.0", "file-loader": "^6.2.0", + "find-in-files": "^0.5.0", "html-webpack-plugin": "^5.0.0", "husky": "^7.0.4", "lint-staged": ">=8", diff --git a/scripts/printUnusedKeys.js b/scripts/printUnusedKeys.js new file mode 100644 index 000000000..6a425e178 --- /dev/null +++ b/scripts/printUnusedKeys.js @@ -0,0 +1,45 @@ +const { find } = require('find-in-files'); +const englishTranslations = require('../locale/en.json'); + +const translationKeys = Object.keys(englishTranslations); +let unusedCount = 0; + +function getLinesWithMatch(lines, substring) { + return lines.filter(line => line.includes(substring)); +} + +async function printUnusedKeys() { + /* eslint-disable */ + for await (const translationKey of translationKeys) { + const results = await find(translationKey, './src'); + const matches = Object.values(results); + const trueMatches = matches.filter(match => { + const matchingLines = match?.line || []; + const linesWithSingleQuoteMatch = getLinesWithMatch( + matchingLines, + `"${translationKey}"`, + ); + const linesWithDoubleQuoteMatch = getLinesWithMatch( + matchingLines, + `\'${translationKey}\'`, + ); + return ( + linesWithSingleQuoteMatch.length > 0 || + linesWithDoubleQuoteMatch.length > 0 + ); + }); + if (trueMatches.length === 0) { + unusedCount += 1; + console.log(`"${translationKey}" may be unused.`); + } + } + /* eslint-enable */ + console.log( + `Script finished. ${unusedCount} potentially unused translation keys found.`, + ); +} + +console.log( + 'FYI, this script takes forever to run! Recommendation is to leave it running overnight or in the background.', +); +printUnusedKeys(); diff --git a/src/AuthenticatedSwitch.jsx b/src/AuthenticatedSwitch.jsx index 2b645ddb3..080b6334f 100644 --- a/src/AuthenticatedSwitch.jsx +++ b/src/AuthenticatedSwitch.jsx @@ -15,10 +15,13 @@ import ControlPanel from './pages/controlPanel/ControlPanel'; import AssignEncounters from './pages/assignEncounters/AssignEncounters'; import CreateIndividual from './pages/createIndividual/CreateIndividual'; import Individual from './pages/individual/Individual'; +import IndividualGallery from './pages/individualGallery/IndividualGallery'; // import PictureBook from './pages/individual/PictureBook'; import Sighting from './pages/sighting/Sighting'; import AssetGroupSighting from './pages/sighting/AssetGroupSighting'; import Splash from './pages/splash/Splash'; +import SocialGroups from './pages/socialGroups/SocialGroups'; +import SocialGroup from './pages/socialGroups/SocialGroup'; import AssetGroup from './pages/assetGroup/AssetGroup'; import PendingCitizenScienceSightings from './pages/pendingCitizenScienceSightings/PendingCitizenScienceSightings'; import User from './pages/user/User'; @@ -39,7 +42,7 @@ 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 Settings from './pages/settings/Settings'; +import Preferences from './pages/preferences/Preferences'; import ResendVerificationEmail from './pages/auth/ResendVerificationEmail'; import Footer from './components/Footer'; import { defaultCrossfadeDuration } from './constants/defaults'; @@ -94,36 +97,42 @@ export default function AuthenticatedSwitch({ ) : ( - + - + - + - + - + - + - + - + - - + + + + + - + + + + @@ -133,6 +142,9 @@ export default function AuthenticatedSwitch({ {/* */} + + + @@ -172,8 +184,8 @@ export default function AuthenticatedSwitch({ - - + + diff --git a/src/components/ActionIcon.jsx b/src/components/ActionIcon.jsx index 8b1a4cfb1..d4e57db3c 100644 --- a/src/components/ActionIcon.jsx +++ b/src/components/ActionIcon.jsx @@ -5,9 +5,9 @@ import IconButton from '@material-ui/core/IconButton'; import EditIcon from '@material-ui/icons/Edit'; import ViewIcon from '@material-ui/icons/Launch'; import DeleteIcon from '@material-ui/icons/Delete'; +import RemoveCircleIcon from '@material-ui/icons/RemoveCircle'; import DownloadIcon from '@material-ui/icons/GetApp'; import CopyIcon from '@material-ui/icons/FileCopy'; -import RestoreIcon from '@material-ui/icons/Restore'; import Link from './Link'; @@ -22,11 +22,7 @@ const variantMap = { }, revoke: { labelId: 'MUTUAL_REVOKE', - component: DeleteIcon, - }, - restore: { - labelId: 'RESTORE', - component: RestoreIcon, + component: RemoveCircleIcon, }, delete: { labelId: 'DELETE', diff --git a/src/components/AuthenticatedAppHeader/ActionsPane.jsx b/src/components/AuthenticatedAppHeader/ActionsPane.jsx index c944a9aca..230d25410 100644 --- a/src/components/AuthenticatedAppHeader/ActionsPane.jsx +++ b/src/components/AuthenticatedAppHeader/ActionsPane.jsx @@ -7,7 +7,6 @@ import Popover from '@material-ui/core/Popover'; import MenuItem from '@material-ui/core/MenuItem'; import MenuList from '@material-ui/core/MenuList'; import Divider from '@material-ui/core/Divider'; -import SettingsIcon from '@material-ui/icons/Settings'; import PublicIcon from '@material-ui/icons/SupervisedUserCircle'; import ControlPanelIcon from '@material-ui/icons/PermDataSetting'; import BulkImportIcon from '@material-ui/icons/PostAdd'; @@ -27,22 +26,13 @@ const actions = [ { id: 'pending-citizen-science-sightings', href: '/pending-citizen-science-sightings', - permissionsTest: userData => - userData?.is_admin || userData?.is_data_manager, + permissionsTest: userData => userData?.is_admin, messageId: 'PENDING_CITIZEN_SCIENCE_SIGHTINGS', icon: PublicIcon, }, - { - id: 'settings', - href: '/settings', - messageId: 'SETTINGS_AND_PRIVACY', - icon: SettingsIcon, - }, { id: 'control-panel', - href: '/admin', - permissionsTest: userData => - userData?.is_admin || userData?.is_user_manager, + href: '/settings', messageId: 'CONTROL_PANEL', icon: ControlPanelIcon, }, diff --git a/src/components/AuthenticatedAppHeader/NotificationPaneDisplayText.jsx b/src/components/AuthenticatedAppHeader/NotificationPaneDisplayText.jsx index 65cecfedd..886c60e20 100644 --- a/src/components/AuthenticatedAppHeader/NotificationPaneDisplayText.jsx +++ b/src/components/AuthenticatedAppHeader/NotificationPaneDisplayText.jsx @@ -15,6 +15,9 @@ export default function NotificationPaneDisplayText({ theirIndividualName, theirIndividualGuid, formattedDeadline, + otherUserGuidForManagerNotifications, + otherUserNameForManagerNotifications, + managerName, timeSince, }) { const theme = useTheme(); @@ -54,6 +57,17 @@ export default function NotificationPaneDisplayText({ ), formattedDeadline, + otherUserNameForManagerNotifications: ( + + + {otherUserNameForManagerNotifications} + + + ), + managerName, }} /> diff --git a/src/components/AuthenticatedAppHeader/index.js b/src/components/AuthenticatedAppHeader/index.js index f13247f23..0aed4a728 100644 --- a/src/components/AuthenticatedAppHeader/index.js +++ b/src/components/AuthenticatedAppHeader/index.js @@ -142,6 +142,7 @@ export default function AppHeader() { refreshNotifications={refreshNotifications} shouldOpen={shouldOpenNotificationPane} setShouldOpen={setShouldOpenNotificationPane} + currentUserGuid={meData?.guid} /> setUserMenuAnchorEl(e.currentTarget)} diff --git a/src/components/BannerLogo.jsx b/src/components/BannerLogo.jsx index 3cfcf5449..96d6c1269 100644 --- a/src/components/BannerLogo.jsx +++ b/src/components/BannerLogo.jsx @@ -41,11 +41,13 @@ export default function BannerLogo({ >
{logo ? ( - {`Logo +
+ {`Logo +
) : ( - + {currentPageText} diff --git a/src/components/UserManagerCollaborationEditTable.jsx b/src/components/UserManagerCollaborationEditTable.jsx new file mode 100644 index 000000000..2dd89d80b --- /dev/null +++ b/src/components/UserManagerCollaborationEditTable.jsx @@ -0,0 +1,197 @@ +import React, { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { get } from 'lodash-es'; + +import { collaborationLabelIds } from '../pages/userManagement/constants/collaboration'; +import { formatUserMessage } from '../pages/userManagement/utils'; +import EditCollaborationDialog from '../pages/userManagement/components/EditCollaborationDialog'; +import CustomAlert from './Alert'; +import Text from './Text'; +import DataDisplay from './dataDisplays/DataDisplay'; +import ActionIcon from './ActionIcon'; +import { cellRenderers } from './dataDisplays/cellRenderers'; +import usePatchCollaboration from '../models/collaboration/usePatchCollaboration'; +import { + getRequestedState, + getSummaryState, + summaryStates, +} from '../utils/collaborationUtils'; + +const ActionGroupRenderer = cellRenderers.actionGroup; + +function Actions({ onRevoke, ...actionGroupRendererProps }) { + const collaborationRow = actionGroupRendererProps.datum; + + return ( + + onRevoke(collaborationRow?.guid)} + /> + + ); +} + +export default function UserManagerCollaborationEditTable({ + inputData, + collaborationLoading, + collaborationError, +}) { + const intl = useIntl(); + const [activeCollaborationGuid, setActiveCollaborationGuid] = + useState(null); + + const activeCollaboration = inputData?.find( + collaboration => collaboration?.guid === activeCollaborationGuid, + ); + + const { + mutate: revokeCollab, + success: revokeSuccess, + loading: revokeLoading, + clearSuccess: clearRevokeSuccess, + error: revokeError, + clearError: onClearRevokeError, + } = usePatchCollaboration(); + + const isLoading = revokeLoading || collaborationLoading; + + function processRevoke(collaborationGuid) { + const operations = [ + { + op: 'replace', + path: '/managed_view_permission', + value: { permission: 'revoked' }, + }, + ]; + + revokeCollab({ collaborationGuid, operations }); + } + + function handleEditCollaboration(_, collaborationRow) { + setActiveCollaborationGuid(collaborationRow?.guid); + } + + function tranformDataForCollabTable(originalData) { + if (!originalData || originalData.length === 0) return null; + return originalData.map(collaboration => { + const collaborators = Object.values( + get(collaboration, 'members', {}), + ); + const member1 = get(collaborators, 0, {}); + const member2 = get(collaborators, 1, {}); + + // Note: the collaboration API call returned a members OBJECT instead of array of objects, which made some tranformation gymnastics here necessary + const currentAccess = getSummaryState(collaboration); + const currentAccessLabelId = + collaborationLabelIds[currentAccess]; + const requestedAccessLabelId = + collaborationLabelIds[getRequestedState(collaboration)]; + + return { + guid: get(collaboration, 'guid'), + userOne: formatUserMessage( + { fullName: member1?.full_name, email: member1?.email }, + intl, + ), + userOneGuid: get(member1, 'guid'), + userTwo: formatUserMessage( + { fullName: member2?.full_name, email: member2?.email }, + intl, + ), + userTwoGuid: get(member2, 'guid'), + currentAccess: currentAccessLabelId + ? intl.formatMessage({ id: currentAccessLabelId }) + : '', + requestedAccess: requestedAccessLabelId + ? intl.formatMessage({ id: requestedAccessLabelId }) + : '', + isRevocable: Boolean( + currentAccess && currentAccess !== summaryStates.revoked, + ), // Only collaborations that are mutually approved can be mutually revoked. + }; + }); + } + const tableFriendlyData = tranformDataForCollabTable(inputData); + const tableColumns = [ + { + name: 'userOne', + align: 'left', + labelId: 'USER_ONE', + }, + { + name: 'userTwo', + align: 'left', + labelId: 'USER_TWO', + }, + { + name: 'currentAccess', + align: 'left', + labelId: 'COLLABORATION_CURRENT_ACCESS', + }, + { + name: 'requestedAccess', + align: 'left', + labelId: 'COLLABORATION_REQUESTED_ACCESS', + }, + { + name: 'actions', + align: 'right', + labelId: 'ACTIONS', + options: { + displayInFilter: false, + customBodyComponent: Actions, + cellRendererProps: { + onRevoke: processRevoke, + onEdit: handleEditCollaboration, + }, + }, + }, + ]; + return ( + <> + setActiveCollaborationGuid(null)} + collaboration={activeCollaboration} + /> + + {collaborationError ? ( + + ) : null} + {revokeError && ( + + )} + {revokeSuccess && ( + + )} + + ); +} diff --git a/src/components/UserProfile.jsx b/src/components/UserProfile.jsx index f5968dd53..f68bbb780 100644 --- a/src/components/UserProfile.jsx +++ b/src/components/UserProfile.jsx @@ -2,11 +2,13 @@ import React, { useState, useMemo } from 'react'; import { useIntl, FormattedMessage } from 'react-intl'; import { get } from 'lodash-es'; +import Grid from '@material-ui/core/Grid'; + import { getHighestRoleLabelId } from '../utils/roleUtils'; import useUserMetadataSchemas from '../models/users/useUserMetadataSchemas'; import useGetUserSightings from '../models/users/useGetUserSightings'; import useGetUserUnprocessedAssetGroupSightings from '../models/users/useGetUserUnproccessedAssetGroupSightings'; -import { formatDate } from '../utils/formatters'; +import { formatDate, formatUserMessage } from '../utils/formatters'; import EntityHeader from './EntityHeader'; import BigAvatar from './profilePhotos/BigAvatar'; import MainColumn from './MainColumn'; @@ -16,7 +18,8 @@ import Text from './Text'; import RequestCollaborationButton from './RequestCollaborationButton'; import MetadataCard from './cards/MetadataCard'; import SightingsCard from './cards/SightingsCard'; -import CollaborationsCard from './cards/CollaborationsCard'; +import MyCollaborationsCard from './cards/MyCollaborationsCard'; +import UserManagerCollaborationsCard from './cards/UserManagerCollaborationsCard'; import CardContainer from './cards/CardContainer'; export default function UserProfile({ @@ -26,6 +29,7 @@ export default function UserProfile({ userDataLoading, refreshUserData, someoneElse, + viewerIsUserManager, noCollaborate = false, }) { const { data: sightingsData, loading: sightingsLoading } = @@ -46,16 +50,14 @@ export default function UserProfile({ ...schema, value: schema?.getValue(schema, userData), })); - }, [userData, metadataSchemas]); + }, [userData, metadataSchemas, someoneElse]); const imageSrc = get(userData, ['profile_fileupload', 'src']); const imageGuid = get(userData, ['profile_fileupload', 'guid']); - let name = get( - userData, - 'full_name', - intl.formatMessage({ id: 'UNNAMED_USER' }), + const name = formatUserMessage( + { fullName: userData?.full_name }, + intl, ); - if (name === '') name = intl.formatMessage({ id: 'UNNAMED_USER' }); const dateCreated = formatDate(get(userData, 'created'), true); const highestRoleLabelId = getHighestRoleLabelId(userData); @@ -168,10 +170,14 @@ export default function UserProfile({ } /> {!someoneElse && ( - + + + + )} + {someoneElse && viewerIsUserManager && ( + + + )}
diff --git a/src/components/cards/EncounterCard.jsx b/src/components/cards/EncounterCard.jsx index cb832bef6..a5650e5e6 100644 --- a/src/components/cards/EncounterCard.jsx +++ b/src/components/cards/EncounterCard.jsx @@ -1,4 +1,5 @@ -import React, { useMemo } from 'react'; +import React from 'react'; +import { useIntl } from 'react-intl'; import { get } from 'lodash-es'; import Paper from '@material-ui/core/Paper'; @@ -6,8 +7,6 @@ import Skeleton from '@material-ui/lab/Skeleton'; import defaultSightingSrc from '../../assets/defaultSighting.png'; import useEncounter from '../../models/encounter/useEncounter'; -import useSiteSettings from '../../models/site/useSiteSettings'; -import LocationIdViewer from '../fields/view/LocationIdViewer'; import Text from '../Text'; import Link from '../Link'; import { @@ -16,19 +15,7 @@ import { } from '../../utils/formatters'; export default function EncounterCard({ encounterGuid }) { - const { data: siteSettings, siteSettingsVersion } = - useSiteSettings(); - - const regionChoices = useMemo( - () => - get( - siteSettings, - ['site.custom.regions', 'value', 'locationID'], - [], - ), - [siteSettingsVersion, siteSettings], - ); - + const intl = useIntl(); const { data, loading, error } = useEncounter(encounterGuid); const sightingOwner = get( @@ -77,12 +64,11 @@ export default function EncounterCard({ encounterGuid }) { variant="body2" id="ENTITY_HEADER_REGION" values={{ - region: ( - - ), + region: data?.locationId_value + ? data.locationId_value + : intl.formatMessage({ + id: 'REGION_LABEL_NOT_FOUND', + }), }} /> See all} + renderActions={ + individualGuid ? ( + + + + ) : null + } overflow="hidden" overflowX="hidden" > diff --git a/src/components/cards/CollaborationsCard.jsx b/src/components/cards/MyCollaborationsCard.jsx similarity index 52% rename from src/components/cards/CollaborationsCard.jsx rename to src/components/cards/MyCollaborationsCard.jsx index 76dec05b5..b7dce9d05 100644 --- a/src/components/cards/CollaborationsCard.jsx +++ b/src/components/cards/MyCollaborationsCard.jsx @@ -1,38 +1,61 @@ -import React, { useState, useEffect } from 'react'; -import { useIntl } from 'react-intl'; +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; import { get, partition } from 'lodash-es'; +import { useIntl } from 'react-intl'; +import { useMutation, useQueryClient } from 'react-query'; -import useGetMe from '../../models/users/useGetMe'; -import Card from './Card'; -import ActionIcon from '../ActionIcon'; +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; + +import { withApiPrefix } from '../../utils/requestUtils'; +import { cellRendererTypes } from '../dataDisplays/cellRenderers'; import Text from '../Text'; -import Link from '../Link'; import DataDisplay from '../dataDisplays/DataDisplay'; +import AddCollaboratorButton from './collaborations/AddCollaboratorButton'; import CollaborationsDialog from './collaborations/CollaborationsDialog'; +import queryKeys from '../../constants/queryKeys'; +import useHandleRequestError from '../../hooks/useHandleRequestError'; -export default function CollaborationsCard({ - userId, - htmlId = null, -}) { +export default function MyCollaborationsCard({ userData }) { const intl = useIntl(); - const [activeCollaboration, setActiveCollaboration] = useState( - null, - ); + const queryClient = useQueryClient(); + const handleRequestError = useHandleRequestError(); + + const [activeCollaboration, setActiveCollaboration] = + useState(null); const [ collabDialogButtonClickLoading, setCollabDialogButtonClickLoading, ] = useState(false); - const { data, loading } = useGetMe(); + const handleEdit = useCallback((_, collaboration) => { + setActiveCollaboration(collaboration); + }, []); - useEffect( - () => { - setCollabDialogButtonClickLoading(false); - }, - [data], - ); + async function addCollaboratorMutationFn({ userGuid }) { + try { + const result = await axios.request({ + url: withApiPrefix('/collaborations/'), + method: 'POST', + data: { user_guid: userGuid }, + }); + return result; + } catch (error) { + return handleRequestError(error); + } + } - const collaborations = get(data, ['collaborations'], []); + const mutation = useMutation(addCollaboratorMutationFn, { + onSuccess: async () => + queryClient.invalidateQueries(queryKeys.me), + }); + + useEffect(() => { + setCollabDialogButtonClickLoading(false); + }, [userData]); + + const collaborations = get(userData, ['collaborations'], []); const tableData = collaborations.map(collaboration => { const collaborationMembers = Object.values( get(collaboration, 'members', []), @@ -44,7 +67,7 @@ export default function CollaborationsCard({ ); const [thisUserDataArray, otherUserDataArray] = partition( filteredCollaborationMembers, - member => member.guid === userId, + member => member.guid === userData?.guid, ); const thisUserData = get(thisUserDataArray, '0', {}); @@ -95,11 +118,11 @@ export default function CollaborationsCard({ name: 'otherUserName', label: intl.formatMessage({ id: 'NAME' }), options: { - customBodyRender: (otherUserName, datum) => ( - - {otherUserName} - - ), + cellRenderer: cellRendererTypes.user, + cellRendererProps: { + guidProperty: 'otherUserId', + nameProperty: 'otherUserName', + }, }, }, { @@ -114,19 +137,14 @@ export default function CollaborationsCard({ name: 'actions', label: intl.formatMessage({ id: 'ACTIONS' }), options: { - customBodyRender: (_, collaboration) => ( - setActiveCollaboration(collaboration)} - /> - ), + cellRenderer: cellRendererTypes.actionGroup, + cellRendererProps: { onEdit: handleEdit }, }, }, ]; return ( - + <> setActiveCollaboration(null)} @@ -135,16 +153,27 @@ export default function CollaborationsCard({ setCollabDialogButtonClickLoading } /> - - + + + + + + + + + + ); } diff --git a/src/components/cards/RelationshipsCard.jsx b/src/components/cards/RelationshipsCard.jsx index a2aa7b6bb..945b3ee4d 100644 --- a/src/components/cards/RelationshipsCard.jsx +++ b/src/components/cards/RelationshipsCard.jsx @@ -98,7 +98,7 @@ export default function RelationshipsCard({ relationships = [], individualGuid, loading, - noDataMessage = 'NO_RELATIONSHIPS', + noDataMessageId = 'NO_RELATIONSHIPS', title, titleId, }) { @@ -359,7 +359,7 @@ export default function RelationshipsCard({ {noRelationships && ( )} diff --git a/src/components/cards/SocialGroupsCard.jsx b/src/components/cards/SocialGroupsCard.jsx new file mode 100644 index 000000000..e24799abd --- /dev/null +++ b/src/components/cards/SocialGroupsCard.jsx @@ -0,0 +1,76 @@ +import React, { useState, useCallback } from 'react'; + +import AddIcon from '@material-ui/icons/Add'; + +import Button from '../Button'; +import Card from './Card'; +import RemoveFromSocialGroupDialog from '../dialogs/RemoveFromSocialGroupDialog'; +import AddToSocialGroupDialog from '../dialogs/AddToSocialGroupDialog'; +import Text from '../Text'; +import SocialGroupsDisplay from '../../pages/individual/components/SocialGroupsDisplay'; + +export default function SocialGroupsCard({ + socialGroups = [], + individualGuid, + loading, + noDataMessageId = 'NO_SOCIAL_GROUPS_ON_INDIVIDUAL', + title, + titleId, +}) { + const noSocialGroups = + Array.isArray(socialGroups) && socialGroups.length === 0; + + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [groupToRemove, setGroupToRemove] = useState(null); + + const handleClose = useCallback(() => { + setGroupToRemove(null); + }, []); + + const handleClickDelete = useCallback(socialGroup => { + setGroupToRemove(socialGroup); + }, []); + + return ( + <> + + setAddDialogOpen(false)} + individualGuid={individualGuid} + /> + + {noSocialGroups ? ( + + ) : ( + + )} + + - )} - - ); -} diff --git a/src/components/inputs/inputMap.js b/src/components/inputs/inputMap.js index ff5b5fa77..dc6d1779b 100644 --- a/src/components/inputs/inputMap.js +++ b/src/components/inputs/inputMap.js @@ -12,7 +12,6 @@ import LocationIdInput from './LocationIdInput'; import ComparatorInput from './ComparatorInput'; import DateInput from './DateInput'; import DateRangeInput from './DateRangeInput'; -import RelationshipsInput from './RelationshipsInput'; import ColorInput from './ColorInput'; import CategoryListInput from './CategoryListInput'; import ProjectIdInput from './ProjectIdInput'; @@ -32,7 +31,6 @@ const inputMap = { [fieldTypes.date]: DateInput, [fieldTypes.daterange]: DateRangeInput, [fieldTypes.feetmeters]: FeetMetersInput, - [fieldTypes.relationships]: RelationshipsInput, [fieldTypes.string]: TextInput, [fieldTypes.longstring]: TextInput, [fieldTypes.password]: TextInput, diff --git a/src/components/progress/ProgressMetrics.jsx b/src/components/progress/ProgressMetrics.jsx new file mode 100644 index 000000000..81cd16e61 --- /dev/null +++ b/src/components/progress/ProgressMetrics.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { isFinite, round } from 'lodash-es'; +import { FormattedMessage, FormattedNumber } from 'react-intl'; +import { + formatDuration, + intervalToDuration, + secondsToMilliseconds, +} from 'date-fns'; + +import LinearProgress from '@material-ui/core/LinearProgress'; + +import Text from '../Text'; + +function getSubtitleIntlProps({ ahead, eta }) { + const isAheadValid = isFinite(ahead); + const isEtaValid = isFinite(eta); + const isEtaLessThanOne = isEtaValid && eta < 1; + + if (!isEtaValid && !isAheadValid) { + return { id: 'PROGRESS_STATISTICS_UNKNOWN_ETA_&_UNKNOWN_QUEUE' }; + } + + if (!isEtaValid && isAheadValid) { + return { + id: 'PROGRESS_STATISTICS_UNKNOWN_ETA_&_QUEUE', + values: { ahead }, + }; + } + + if (isEtaLessThanOne && !isAheadValid) { + return { id: 'PROGRESS_STATISTICS_WRAPPING_ETA_&_UNKNOWN_QUEUE' }; + } + + if (isEtaLessThanOne && isAheadValid) { + return { + id: 'PROGRESS_STATISTICS_WRAPPING_ETA_&_QUEUE', + values: { ahead }, + }; + } + + const interval = { + start: 0, + end: secondsToMilliseconds(eta), + }; + const duration = intervalToDuration(interval); + const timeRemaining = formatDuration(duration); + + if (!isAheadValid) { + return { + id: 'PROGRESS_STATISTICS_ETA_&_UNKNOWN_QUEUE', + values: { timeRemaining }, + }; + } + + return { + id: 'PROGRESS_STATISTICS_ETA_&_QUEUE', + values: { ahead, timeRemaining }, + }; +} + +export default function ProgressMetrics({ + progress: progressObj, + style, +}) { + const { ahead, eta, progress } = progressObj || {}; + + const isProgressValid = isFinite(progress); + const roundedProgress = isProgressValid ? round(progress, 2) : null; + + const subtitleIntlProps = getSubtitleIntlProps({ ahead, eta }); + + return ( +
+
+
+ +
+ + {isProgressValid ? ( + + ) : ( + + )} + +
+ +
+ ); +} diff --git a/src/components/settings/MediaDeleteButton.jsx b/src/components/settings/MediaDeleteButton.jsx index 9d3fe6d61..bb8843460 100644 --- a/src/components/settings/MediaDeleteButton.jsx +++ b/src/components/settings/MediaDeleteButton.jsx @@ -5,7 +5,7 @@ import { useTheme } from '@material-ui/core/styles'; import ActionIcon from '../ActionIcon'; import ConfirmDelete from '../ConfirmDelete'; import CustomAlert from '../Alert'; -import useDeleteSiteSettingsMedia from '../../models/site/useDeleteSiteSettingsMedia'; +import useDeleteSiteSetting from '../../models/site/useDeleteSiteSetting'; export default function MediaDeleteButton({ includeDeleteButton = false, @@ -24,15 +24,17 @@ export default function MediaDeleteButton({ setShouldOpenConfirmDeleteDialog, ] = useState(false); const theme = useTheme(); + const { - deleteSettingsAsset, + mutate: deleteSiteSetting, error, - setError, + clearError, success: localSuccess, - } = useDeleteSiteSettingsMedia(); + } = useDeleteSiteSetting(); + const [dismissed, setDismissed] = useState(false); function onCloseConfirmDelete() { - setError(null); + clearError(); setShouldOpenConfirmDeleteDialog(false); } if (dismissed) { @@ -77,16 +79,17 @@ export default function MediaDeleteButton({ messageId="CONFIRM_DELETE_FILE" onClose={onCloseConfirmDelete} error={error} - onDelete={() => { + onDelete={async () => { if (shouldTryDelete) { - deleteSettingsAsset(settingKey).then(isOk => { - if (isOk) { - setPreviewUrl(null); - setPreviewText(null); - onSetPostData(null); - onCloseConfirmDelete(); - } + const result = await deleteSiteSetting({ + property: settingKey, }); + if (result?.status === 204) { + setPreviewUrl(null); + setPreviewText(null); + onSetPostData(null); + onCloseConfirmDelete(); + } } else { setPreviewUrl(null); setPreviewText(null); diff --git a/src/components/settings/SettingsFileUpload.jsx b/src/components/settings/SettingsFileUpload.jsx index 0d3bedbac..bac2f4aa8 100644 --- a/src/components/settings/SettingsFileUpload.jsx +++ b/src/components/settings/SettingsFileUpload.jsx @@ -55,7 +55,6 @@ export default function SettingsFileUpload({ const uploadObject = get(uppyState, 'successful.0'); if (uploadObject) { onSetPostData({ - key: settingName, transactionId: assetSubmissionId, transactionPath: get(uploadObject, ['meta', 'name']), }); @@ -110,7 +109,7 @@ export default function SettingsFileUpload({
; -}; - export default function SettingsTextInput({ siteSettings, currentValues, setCurrentValues, - customFieldCategories, settingKey, skipDescription = false, }) { - const matchingSetting = get(siteSettings, ['data', settingKey]); + const matchingSetting = get(siteSettings, [settingKey]); const matchingSettingSchema = get(matchingSetting, 'schema', {}); const valueIsDefined = get(currentValues, settingKey, undefined) !== undefined; @@ -85,8 +76,7 @@ export default function SettingsTextInput({ }} > {matchingSetting && valueIsDefined ? ( - { if (loading || error) @@ -43,5 +42,5 @@ export default function useOptions() { .filter(o => o); return { regionOptions, speciesOptions }; - }, [data, loading, error, siteSettingsVersion]); + }, [loading, error, data]); } diff --git a/src/index.jsx b/src/index.jsx index 1a1721be9..58577228e 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -13,15 +13,9 @@ import pjson from '../package.json'; import App from './App'; axios - .get(`${__houston_url__}/api/v1/site-settings/main/sentryDsn`) + .get(`${__houston_url__}/api/v1/site-settings/data/sentryDsn`) .then(response => { - const sentryDsn = get(response, [ - 'data', - 'response', - 'configuration', - 'sentryDsn', - 'value', - ]); + const sentryDsn = get(response, ['data', 'sentryDsn', 'value']); if (sentryDsn) { Sentry.init({ dsn: sentryDsn, diff --git a/src/models/encounter/useEncounterFieldSchemas.js b/src/models/encounter/useEncounterFieldSchemas.js index ef3c734cf..4bbec089d 100644 --- a/src/models/encounter/useEncounterFieldSchemas.js +++ b/src/models/encounter/useEncounterFieldSchemas.js @@ -13,8 +13,7 @@ import { export default function useSightingFieldSchemas() { const intl = useIntl(); - const { data, loading, error, siteSettingsVersion } = - useSiteSettings(); + const { data, loading, error } = useSiteSettings(); const encounterFieldSchemas = useMemo(() => { if (loading || error) return []; @@ -95,6 +94,6 @@ export default function useSightingFieldSchemas() { }), ...customFieldSchemas, ]; - }, [intl, data, loading, error, siteSettingsVersion]); + }, [intl, loading, error, data]); return encounterFieldSchemas; } diff --git a/src/models/identification/useIdConfigSchemas.js b/src/models/identification/useIdConfigSchemas.js index 92c8bdfc0..e701cb454 100644 --- a/src/models/identification/useIdConfigSchemas.js +++ b/src/models/identification/useIdConfigSchemas.js @@ -11,7 +11,6 @@ export default function useIdConfigSchemas() { data, loading: siteSettingsLoading, error: siteSettingsError, - siteSettingsVersion, } = useSiteSettings(); const { @@ -78,7 +77,7 @@ export default function useIdConfigSchemas() { }, }), ]; - }, [data, siteSettingsVersion, detectionConfig, loading, error]); + }, [data, detectionConfig, loading, error]); return sightingFieldSchemas; } diff --git a/src/models/individual/useIndividualFieldSchemas.js b/src/models/individual/useIndividualFieldSchemas.js index 4a2d518aa..c7690a987 100644 --- a/src/models/individual/useIndividualFieldSchemas.js +++ b/src/models/individual/useIndividualFieldSchemas.js @@ -12,8 +12,7 @@ import { defaultIndividualCategories } from '../../constants/fieldCategories'; import { deriveIndividualName } from '../../utils/nameUtils'; export default function useIndividualFieldSchemas() { - const { data, loading, error, siteSettingsVersion } = - useSiteSettings(); + const { data, loading, error } = useSiteSettings(); const individualFieldSchemas = useMemo(() => { if (loading || error) return []; @@ -57,7 +56,7 @@ export default function useIndividualFieldSchemas() { }), ...customFieldSchemas, ]; - }, [data, siteSettingsVersion, loading, error]); + }, [data, loading, error]); return individualFieldSchemas; } diff --git a/src/models/individual/useQueryIndividualsByGuid.js b/src/models/individual/useQueryIndividualsByGuid.js new file mode 100644 index 000000000..9a44ada42 --- /dev/null +++ b/src/models/individual/useQueryIndividualsByGuid.js @@ -0,0 +1,21 @@ +import useFetch from '../../hooks/useFetch'; +import { getIndividualsByGuidsQueryKey } from '../../constants/queryKeys'; + +export default function useQueryIndividualsByGuid( + individualGuids = [], +) { + const query = { + terms: { guid: individualGuids }, + }; + + return useFetch({ + method: 'post', + url: '/individuals/search', + queryKey: getIndividualsByGuidsQueryKey(individualGuids), + data: query, + queryOptions: { + enabled: + Array.isArray(individualGuids) && individualGuids.length > 0, + }, + }); +} diff --git a/src/models/sighting/useSightingFieldSchemas.js b/src/models/sighting/useSightingFieldSchemas.js index 3b42cb269..95aec79d0 100644 --- a/src/models/sighting/useSightingFieldSchemas.js +++ b/src/models/sighting/useSightingFieldSchemas.js @@ -18,7 +18,6 @@ export default function useSightingFieldSchemas() { data, loading: siteSettingsLoading, error: siteSettingsError, - siteSettingsVersion, } = useSiteSettings(); const { @@ -144,14 +143,7 @@ export default function useSightingFieldSchemas() { }), ...customFieldSchemas, ]; - }, [ - intl, - data, - detectionConfig, - siteSettingsVersion, - loading, - error, - ]); + }, [intl, data, detectionConfig, loading, error]); return sightingFieldSchemas; } diff --git a/src/models/site/useDeleteSiteSetting.jsx b/src/models/site/useDeleteSiteSetting.jsx new file mode 100644 index 000000000..8ca9dadca --- /dev/null +++ b/src/models/site/useDeleteSiteSetting.jsx @@ -0,0 +1,9 @@ +import { useDelete } from '../../hooks/useMutate'; +import queryKeys from '../../constants/queryKeys'; + +export default function useDeleteSiteSetting() { + return useDelete({ + deriveUrl: ({ property }) => `/site-settings/data/${property}`, + fetchKeys: [queryKeys.settingsSchema], + }); +} diff --git a/src/models/site/useDeleteSiteSettingsMedia.js b/src/models/site/useDeleteSiteSettingsMedia.js deleted file mode 100644 index 51ef79e5f..000000000 --- a/src/models/site/useDeleteSiteSettingsMedia.js +++ /dev/null @@ -1,54 +0,0 @@ -import { useState } from 'react'; -import axios from 'axios'; -import { get } from 'lodash-es'; -import { useQueryClient } from 'react-query'; - -import queryKeys from '../../constants/queryKeys'; -import { formatError } from '../../utils/formatters'; - -export default function useDeleteSiteSettingsMedia() { - const queryClient = useQueryClient(); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - const [loading, setLoading] = useState(false); - - const deleteSettingsAsset = async data => { - let okStatus = false; - try { - setLoading(true); - const response = await axios({ - url: `${__houston_url__}/api/v1/site-settings/file/${data}`, - withCredentials: true, - method: 'delete', - }); - const statusResponse = get(response, 'status'); - const successful = statusResponse === 204; - setLoading(false); - if (successful) { - queryClient.invalidateQueries(queryKeys.settingsConfig); - setSuccess(true); - setError(null); - okStatus = true; - } else { - setError(formatError(response)); - setSuccess(false); - okStatus = false; - } - } catch (postError) { - setLoading(false); - setError(formatError(postError)); - setSuccess(false); - okStatus = false; - } - return okStatus; - }; - - return { - deleteSettingsAsset, - loading, - error, - setError, - success, - setSuccess, - }; -} diff --git a/src/models/site/usePostSettingsAsset.js b/src/models/site/usePostSettingsAsset.js deleted file mode 100644 index bc141ea9f..000000000 --- a/src/models/site/usePostSettingsAsset.js +++ /dev/null @@ -1,10 +0,0 @@ -import { usePost } from '../../hooks/useMutate'; -import queryKeys from '../../constants/queryKeys'; - -export default function usePostSettingsAsset() { - return usePost({ - url: '/site-settings/file/', - deriveData: ({ data }) => data, - fetchKeys: [queryKeys.settingsConfig], - }); -} diff --git a/src/models/site/usePutSiteSetting.js b/src/models/site/usePutSiteSetting.js index 220940bc7..000c557f1 100644 --- a/src/models/site/usePutSiteSetting.js +++ b/src/models/site/usePutSiteSetting.js @@ -1,14 +1,17 @@ import { usePost } from '../../hooks/useMutate'; import queryKeys from '../../constants/queryKeys'; +/** + * To POST multiple site settings, pass a property of '' + */ export default function usePutSiteSetting() { return usePost({ - deriveUrl: ({ property }) => `/site-settings/main/${property}`, + deriveUrl: ({ property }) => `/site-settings/data/${property}`, deriveData: ({ data }) => ({ - _value: data, + value: data, }), fetchKeys: [ - queryKeys.settingsConfig, + queryKeys.settingsSchema, queryKeys.twitterBotTestResults, ], }); diff --git a/src/models/site/usePutSiteSettings.js b/src/models/site/usePutSiteSettings.js deleted file mode 100644 index c02a5d459..000000000 --- a/src/models/site/usePutSiteSettings.js +++ /dev/null @@ -1,13 +0,0 @@ -import { usePost } from '../../hooks/useMutate'; -import queryKeys from '../../constants/queryKeys'; - -export default function usePutSiteSettings() { - return usePost({ - url: '/site-settings/main/', - deriveData: ({ data }) => data, - fetchKeys: [ - queryKeys.settingsConfig, - queryKeys.twitterBotTestResults, - ], - }); -} diff --git a/src/models/site/useRemoveCustomField.js b/src/models/site/useRemoveCustomField.js index 4c3e0726b..7046f81a5 100644 --- a/src/models/site/useRemoveCustomField.js +++ b/src/models/site/useRemoveCustomField.js @@ -27,7 +27,7 @@ export default function useRemoveCustomField() { try { setLoading(true); const patchResponse = await axios({ - url: `${__houston_url__}/api/v1/site-settings/main`, + url: `${__houston_url__}/api/v1/site-settings/data`, withCredentials: true, method: 'patch', data: [operation], @@ -37,7 +37,7 @@ export default function useRemoveCustomField() { setNeedsForce(false); if (successful) { - queryClient.invalidateQueries(queryKeys.settingsConfig); + queryClient.invalidateQueries(queryKeys.settingsSchema); setLoading(false); setSuccess(true); setError(null); diff --git a/src/models/site/useSiteSettings.js b/src/models/site/useSiteSettings.js index 3b7dc61eb..7bcdec1da 100644 --- a/src/models/site/useSiteSettings.js +++ b/src/models/site/useSiteSettings.js @@ -1,66 +1,9 @@ -import { useMemo } from 'react'; -import { get, merge } from 'lodash-es'; -import axios from 'axios'; -import { useQuery } from 'react-query'; - +import useFetch from '../../hooks/useFetch'; import queryKeys from '../../constants/queryKeys'; export default function useSiteSettings() { - const settingsSchemaResult = useQuery( - queryKeys.settingsSchema, - async () => { - const response = await axios({ - url: `${__houston_url__}/api/v1/site-settings/definition/main/block`, - timeout: 2000, - }); - return get(response, 'data.response.configuration'); - }, - { - staleTime: Infinity, - }, - ); - - const settingsConfigResult = useQuery( - queryKeys.settingsConfig, - async () => { - const response = await axios( - `${__houston_url__}/api/v1/site-settings/main/block`, - ); - return get(response, 'data.response'); - }, - { - staleTime: Infinity, - }, - ); - - const { - data: schemaData, - isLoading: schemaLoading, - isError: schemaError, - } = settingsSchemaResult; - - const { - data: configData, - isLoading: configLoading, - isError: configError, - } = settingsConfigResult; - - const loading = schemaLoading || configLoading; - const error = schemaError || configError; - const { version: siteSettingsVersion, configuration } = - configData || {}; - - let data = null; - if (schemaData && configuration) { - /* Order of this merge is crucial. Values from the settings object must - * override values from the schema object. If the order ever needs to be - * changed for some reason, extensive QA of the RegionEditor component - * will be necesssary. */ - data = merge(configuration, schemaData); - } - - return useMemo( - () => ({ data, loading, error, siteSettingsVersion }), - [data, loading, error, siteSettingsVersion], - ); + return useFetch({ + queryKey: queryKeys.settingsSchema, + url: '/site-settings/data', + }); } diff --git a/src/models/socialGroups/useDeleteSocialGroup.js b/src/models/socialGroups/useDeleteSocialGroup.js new file mode 100644 index 000000000..9e2aaa017 --- /dev/null +++ b/src/models/socialGroups/useDeleteSocialGroup.js @@ -0,0 +1,9 @@ +import { useDelete } from '../../hooks/useMutate'; +import queryKeys from '../../constants/queryKeys'; + +export default function useDeleteSocialGroup() { + return useDelete({ + deriveUrl: ({ guid }) => `/social-groups/${guid}`, + fetchKeys: [queryKeys.socialGroups], + }); +} diff --git a/src/models/socialGroups/usePatchSocialGroup.jsx b/src/models/socialGroups/usePatchSocialGroup.jsx new file mode 100644 index 000000000..64ef20a5c --- /dev/null +++ b/src/models/socialGroups/usePatchSocialGroup.jsx @@ -0,0 +1,33 @@ +import { usePatch } from '../../hooks/useMutate'; +import queryKeys, { + getSocialGroupQueryKey, + getIndividualQueryKey, +} from '../../constants/queryKeys'; + +export default function usePatchSocialGroup() { + return usePatch({ + deriveUrl: ({ guid }) => `/social-groups/${guid}`, + deriveData: ({ name, members }) => { + const patchData = [ + { + op: 'replace', + path: '/name', + value: name, + }, + { + op: 'replace', + path: '/members', + value: members, + }, + ]; + return patchData.filter(o => o.value); + }, + invalidateKeys: [queryKeys.socialGroups], + deriveFetchKeys: ({ guid, affectedIndividualGuids = [] }) => { + const individualQueries = affectedIndividualGuids.map(i => + getIndividualQueryKey(i), + ); + return [getSocialGroupQueryKey(guid), ...individualQueries]; + }, + }); +} diff --git a/src/models/socialGroups/usePostSocialGroup.jsx b/src/models/socialGroups/usePostSocialGroup.jsx new file mode 100644 index 000000000..a56eac147 --- /dev/null +++ b/src/models/socialGroups/usePostSocialGroup.jsx @@ -0,0 +1,13 @@ +import { usePost } from '../../hooks/useMutate'; +import queryKeys from '../../constants/queryKeys'; + +export default function usePostSocialGroup() { + return usePost({ + url: '/social-groups/', + deriveData: ({ name }) => ({ + name, + members: {}, + }), + fetchKeys: [queryKeys.socialGroups], + }); +} diff --git a/src/models/socialGroups/useSocialGroup.jsx b/src/models/socialGroups/useSocialGroup.jsx new file mode 100644 index 000000000..5667a7ea6 --- /dev/null +++ b/src/models/socialGroups/useSocialGroup.jsx @@ -0,0 +1,13 @@ +import { getSocialGroupQueryKey } from '../../constants/queryKeys'; +import useFetch from '../../hooks/useFetch'; + +export default function useSocialGroup(guid, queryOptions = {}) { + return useFetch({ + queryKey: getSocialGroupQueryKey(guid), + url: `/social-groups/${guid}`, + queryOptions: { + enabled: Boolean(guid), + ...queryOptions, + }, + }); +} diff --git a/src/models/socialGroups/useSocialGroups.jsx b/src/models/socialGroups/useSocialGroups.jsx new file mode 100644 index 000000000..9d1558043 --- /dev/null +++ b/src/models/socialGroups/useSocialGroups.jsx @@ -0,0 +1,10 @@ +import queryKeys from '../../constants/queryKeys'; +import useFetch from '../../hooks/useFetch'; + +export default function useSocialGroups(queryOptions = {}) { + return useFetch({ + queryKey: queryKeys.socialGroups, + url: `/social-groups`, + queryOptions, + }); +} diff --git a/src/models/users/useGetUsers.js b/src/models/users/useGetUsers.js index 153bb3962..2f67f7529 100644 --- a/src/models/users/useGetUsers.js +++ b/src/models/users/useGetUsers.js @@ -1,16 +1,9 @@ import queryKeys from '../../constants/queryKeys'; import useFetch from '../../hooks/useFetch'; -const limit = 20; -const offset = 0; - export default function useGetUsers() { return useFetch({ queryKey: queryKeys.users, url: '/users/', - data: { - limit, - offset, - }, }); } diff --git a/src/models/users/useUserMetadataSchemas.js b/src/models/users/useUserMetadataSchemas.js index 694cc35f8..931c27d1d 100644 --- a/src/models/users/useUserMetadataSchemas.js +++ b/src/models/users/useUserMetadataSchemas.js @@ -15,7 +15,7 @@ import ForumIdViewer from '../../components/fields/view/ForumIdViewer'; export default function useUserMetadataSchemas(displayedUserId) { const { data: currentUserData, loading, error } = useGetMe(); - const siteSettings = useSiteSettings(); + const { data: siteSettings } = useSiteSettings(); const isAdmin = get(currentUserData, 'is_admin', false); const isCurrentUser = @@ -41,7 +41,6 @@ export default function useUserMetadataSchemas(displayedUserId) { 'enablingField', ]); const isEnabled = get(siteSettings, [ - 'data', currentPlatformEnablingField, 'value', ]); @@ -84,7 +83,7 @@ export default function useUserMetadataSchemas(displayedUserId) { }), ...intelligentAgentFields, ]; - }, [isAdmin, includeEmail, siteSettings]); + }, [includeEmail, siteSettings]); if (loading || error) return null; return userMetadataSchemas; diff --git a/src/pages/assetGroup/AssetGroup.jsx b/src/pages/assetGroup/AssetGroup.jsx index 8b59946de..c97e1bcf9 100644 --- a/src/pages/assetGroup/AssetGroup.jsx +++ b/src/pages/assetGroup/AssetGroup.jsx @@ -4,28 +4,44 @@ import { useIntl } from 'react-intl'; import { get } from 'lodash-es'; import { useQueryClient } from 'react-query'; -import LinearProgress from '@material-ui/core/LinearProgress'; +import { makeStyles, lighten } from '@material-ui/core/styles'; import errorTypes from '../../constants/errorTypes'; import useDeleteAssetGroup from '../../models/assetGroup/useDeleteAssetGroup'; import useAssetGroup from '../../models/assetGroup/useAssetGroup'; import useDocumentTitle from '../../hooks/useDocumentTitle'; import { formatDate } from '../../utils/formatters'; - +import { getProgress } from '../../utils/pipelineStatusUtils'; import queryKeys from '../../constants/queryKeys'; import MainColumn from '../../components/MainColumn'; import LoadingScreen from '../../components/LoadingScreen'; import SadScreen from '../../components/SadScreen'; -import Text from '../../components/Text'; -import Link from '../../components/Link'; +import FormattedReporter from '../../components/formatters/FormattedReporter'; import MoreMenu from '../../components/MoreMenu'; import ConfirmDelete from '../../components/ConfirmDelete'; import EntityHeader from '../../components/EntityHeader'; import CustomAlert from '../../components/Alert'; +import ProgressMetrics from '../../components/progress/ProgressMetrics'; import AGSTable from './AGSTable'; const POLLING_INTERVAL = 5000; // 5 seconds +const useStyles = makeStyles(theme => ({ + alert: { + // extend the progress bar the full width of the alert + '& .MuiAlert-message': { + flexGrow: 1, + }, + // use the info alert's colors instead of the primary site color + '& .MuiLinearProgress-colorPrimary': { + backgroundColor: lighten(theme.palette.info.light, 0.5), + }, + '& .MuiLinearProgress-barColorPrimary': { + backgroundColor: theme.palette.info.main, + }, + }, +})); + function isProgressSettled(pipelineStatus) { const { skipped, failed, complete } = pipelineStatus || {}; @@ -54,6 +70,7 @@ export default function AssetGroup() { const history = useHistory(); const queryClient = useQueryClient(); const intl = useIntl(); + const classes = useStyles(); const { data, loading, error, statusCode } = useAssetGroup(guid, { queryOptions: { refetchInterval: deriveRefetchInterval }, @@ -88,10 +105,6 @@ export default function AssetGroup() { const dateCreated = get(data, 'created'); const sightingCreator = data?.creator; - const creatorName = - sightingCreator?.full_name || - intl.formatMessage({ id: 'UNNAMED_USER' }); - const creatorUrl = `/users/${sightingCreator?.guid}`; const pipelineStatusPreparation = get( data, @@ -149,10 +162,13 @@ export default function AssetGroup() { } > {sightingCreator && ( - - {intl.formatMessage({ id: 'REPORTED_BY' })} - {creatorName} - + )} {isPreparationFailed && ( @@ -163,23 +179,17 @@ export default function AssetGroup() { /> )} {showPreparationInProgressAlert && ( - <> - - + - + )}
{adminPages.map(page => { - const buttonEnabled = page.roles.some(r => - get(userData, r), - ); - const additionalStyles = buttonEnabled - ? {} - : disabledStyles; - const hovered = buttonEnabled && hoveredCard === page.name; - let iconFill = hovered + const showButton = page.roles.some(r => get(userData, r)); + if (!showButton) return null; + const hovered = hoveredCard === page.name; + const iconFill = hovered ? theme.palette.primary.light : theme.palette.grey['800']; - iconFill = buttonEnabled - ? iconFill - : theme.palette.text.disabled; return ( setHoveredCard(page.name)} onMouseLeave={() => setHoveredCard(null)} noUnderline @@ -115,7 +129,6 @@ export default function ControlPanel() { cursor: 'pointer', margin: 12, height: 160, - ...additionalStyles, }} elevation={hovered ? 10 : undefined} > diff --git a/src/pages/fieldManagement/FieldManagement.jsx b/src/pages/fieldManagement/FieldManagement.jsx index b572fad9e..dc7d89583 100644 --- a/src/pages/fieldManagement/FieldManagement.jsx +++ b/src/pages/fieldManagement/FieldManagement.jsx @@ -21,12 +21,7 @@ function getCustomFields(siteSettings, property) { } export default function FieldManagement() { - const { - data: siteSettings, - loading, - error, - siteSettingsVersion, - } = useSiteSettings(); + const { data: siteSettings, loading, error } = useSiteSettings(); useDocumentTitle('MANAGE_FIELDS'); @@ -77,10 +72,7 @@ export default function FieldManagement() { spacing={3} style={{ padding: 20 }} > - + { + setPreviewInitialValue(field.defaultValue); + setPreviewField(field); + }, []); + + const deriveEditHref = useCallback( + (_, field) => + `/settings/fields/save-custom-field/${fieldTypeName}/${field.id}`, + [fieldTypeName], + ); + + const onDeleteCustomField = useCallback( + (_, field) => setDeleteField(field), + [], + ); + const tableColumns = useMemo( () => [ { @@ -90,32 +105,16 @@ export default function CustomFieldTable({ name: 'actions', labelId: 'ACTIONS', options: { - customBodyRender: (_, field) => ( -
- { - setPreviewInitialValue(field.defaultValue); - setPreviewField(field); - }} - /> - - setDeleteField(field)} - /> -
- ), + cellRenderer: cellRendererTypes.actionGroup, + cellRendererProps: { + onView: onViewCustomField, + editHref: deriveEditHref, + onDelete: onDeleteCustomField, + }, }, }, ], - [intl, fieldTypeName], + [onViewCustomField, deriveEditHref, onDeleteCustomField], ); const onCloseConfirmDelete = () => { @@ -176,7 +175,7 @@ export default function CustomFieldTable({ display="panel" startIcon={} disabled={addButtonDisabled} - href={`/admin/fields/save-custom-field/${fieldTypeName}`} + href={`/settings/fields/save-custom-field/${fieldTypeName}`} /> diff --git a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx index e1a63baf7..6ef77d228 100644 --- a/src/pages/fieldManagement/settings/DefaultFieldTable.jsx +++ b/src/pages/fieldManagement/settings/DefaultFieldTable.jsx @@ -68,10 +68,7 @@ function getInitialFormState(siteSettings) { return { regions, species, relationships, socialGroups }; } -export default function DefaultFieldTable({ - siteSettings, - siteSettingsVersion, -}) { +export default function DefaultFieldTable({ siteSettings }) { const intl = useIntl(); const [formSettings, setFormSettings] = useState(null); const [editField, setEditField] = useState(null); @@ -83,7 +80,7 @@ export default function DefaultFieldTable({ useEffect( () => setFormSettings(getInitialFormState(siteSettings)), - [siteSettingsVersion], + [siteSettings], ); const tableColumns = [ diff --git a/src/pages/fieldManagement/settings/FieldTypeSelector.jsx b/src/pages/fieldManagement/settings/FieldTypeSelector.jsx deleted file mode 100644 index fe63dd9d3..000000000 --- a/src/pages/fieldManagement/settings/FieldTypeSelector.jsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import { FormattedMessage, useIntl } from 'react-intl'; -import { get } from 'lodash-es'; - -import FormControl from '@material-ui/core/FormControl'; -import Select from '@material-ui/core/Select'; -import InputLabel from '@material-ui/core/InputLabel'; - -import fieldTypes, { - fieldTypeInfo, -} from '../../../constants/fieldTypesNew'; - -const fieldTypeCategories = [ - { - labelId: 'BASIC_INPUTS', - fields: [ - fieldTypes.boolean, - fieldTypes.date, - fieldTypes.string, - fieldTypes.longstring, - fieldTypes.float, - fieldTypes.integer, - ], - }, - { - labelId: 'SPECIAL_INPUTS', - fields: [ - fieldTypes.daterange, - fieldTypes.select, - fieldTypes.multiselect, - fieldTypes.file, - fieldTypes.individual, - fieldTypes.latlong, - fieldTypes.relationships, - fieldTypes.feetmeters, - ], - }, -]; - -export default function FieldTypeSelector({ onChange, field }) { - const intl = useIntl(); - - return ( - - - - - - - ); -} diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx index 6b3bd7e73..43a7e31fa 100644 --- a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupComponents/SocialGroupRole.jsx @@ -61,7 +61,28 @@ export default function SocialGroupRole({ ); return ( -
+
+ { + onChange(updateRoleLabel(roles, roleGuid, newLabel)); + }} + value={roleLabel} + autoFocus + InputProps={{ + endAdornment: ( + + { + onChange(deleteRole(roles, roleGuid)); + }} + /> + + ), + }} + /> - { - onChange(updateRoleLabel(roles, roleGuid, newLabel)); - }} - value={roleLabel} - autoFocus - InputProps={{ - endAdornment: ( - - { - onChange(deleteRole(roles, roleGuid)); - }} - /> - - ), - }} - />
); } diff --git a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx index 13ff1d2d7..23514d576 100644 --- a/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx +++ b/src/pages/fieldManagement/settings/defaultFieldComponents/SocialGroupsEditor.jsx @@ -98,7 +98,7 @@ export default function SocialGroupsEditor({ style={{ display: 'flex', flexDirection: 'column', - padding: 20, + padding: '20px 0', }} > {map(safeRoles, role => ( diff --git a/src/pages/fieldManagement/settings/saveField/SaveField.jsx b/src/pages/fieldManagement/settings/saveField/SaveField.jsx index 84cadcf4b..dca314090 100644 --- a/src/pages/fieldManagement/settings/saveField/SaveField.jsx +++ b/src/pages/fieldManagement/settings/saveField/SaveField.jsx @@ -231,7 +231,7 @@ export default function SaveField() { } label={} - value={get(formData, 'required', false)} + checked={get(formData, 'required', false)} onChange={e => setFormData({ ...formData, @@ -467,7 +467,7 @@ export default function SaveField() { data: { definitions: newFields }, }); if (response?.status === 200) - history.push('/admin/fields'); + history.push('/settings/fields'); }} /> diff --git a/src/pages/generalSettings/GeneralSettings.jsx b/src/pages/generalSettings/GeneralSettings.jsx index 467b80c5d..5e4c1959d 100644 --- a/src/pages/generalSettings/GeneralSettings.jsx +++ b/src/pages/generalSettings/GeneralSettings.jsx @@ -4,8 +4,7 @@ import { get, reduce, zipObject } from 'lodash-es'; import Grid from '@material-ui/core/Grid'; import useSiteSettings from '../../models/site/useSiteSettings'; -import usePutSiteSettings from '../../models/site/usePutSiteSettings'; -import usePostSettingsAsset from '../../models/site/usePostSettingsAsset'; +import usePutSiteSetting from '../../models/site/usePutSiteSetting'; import CustomAlert from '../../components/Alert'; import Button from '../../components/Button'; @@ -20,12 +19,6 @@ import SettingsTextInput from '../../components/settings/SettingsTextInput'; import IntelligentAgentSettings from './IntelligentAgentSettings'; import { intelligentAgentSchema } from '../../constants/intelligentAgentSchema'; -const customFields = { - sighting: 'site.custom.customFields.Sighting', - encounter: 'site.custom.customFields.Encounter', - individual: 'site.custom.customFields.Individual', -}; - const generalSettingsFields = [ 'site.name', 'site.private', @@ -64,10 +57,9 @@ const allSettingsFields = [ ]; export default function GeneralSettings() { - const siteSettings = useSiteSettings(); + const { data: siteSettings } = useSiteSettings(); const [currentValues, setCurrentValues] = useState(null); - const [logoPostData, setLogoPostData] = useState(null); const [ intelligentAgentFieldsValid, setIntelligentAgentFieldsValid, @@ -77,19 +69,12 @@ export default function GeneralSettings() { ); const { - mutate: putSiteSettings, - error: putSiteSettingsError, - loading: formPostLoading, - success: formPostSuccess, - clearSuccess: setClearPostSuccess, - } = usePutSiteSettings(); - - const { - mutate: postSettingsAsset, - loading: assetPostLoading, - error: settingsAssetPostError, - clearSuccess: setClearAssetPostSuccess, - } = usePostSettingsAsset(); + mutate: putSiteSetting, + error: putSiteSettingError, + loading: putSiteSettingLoading, + success: putSiteSettingSuccess, + clearSuccess: clearPutSiteSettingSuccess, + } = usePutSiteSetting(); const { data: twitterTestResults, @@ -110,15 +95,11 @@ export default function GeneralSettings() { }, [twitterTestResults, twitterStatusCode]); useEffect(() => { - const edmValues = allSettingsFields.map(fieldKey => - get(siteSettings, ['data', fieldKey, 'value']), + const fieldValues = allSettingsFields.map(fieldKey => + get(siteSettings, [fieldKey, 'value']), ); - setCurrentValues(zipObject(allSettingsFields, edmValues)); - }, [siteSettings, allSettingsFields]); - - const loading = assetPostLoading || formPostLoading; - const error = putSiteSettingsError || settingsAssetPostError; - const success = formPostSuccess && !error && !loading; + setCurrentValues(zipObject(allSettingsFields, fieldValues)); + }, [siteSettings]); return ( @@ -133,6 +114,11 @@ export default function GeneralSettings() { direction="column" style={{ marginTop: 20, padding: 20 }} > + + { + setCurrentValues(prev => ({ + ...prev, + logo: fileUploadData, + })); + }} /> - {error && ( + {putSiteSettingError && ( - {error} + {putSiteSettingError} )} {twitterTestError && isTwitterEnabled && ( @@ -272,12 +263,9 @@ export default function GeneralSettings() { style={{ marginBottom: 16 }} /> )} - {success && ( + {putSiteSettingSuccess && ( { - setClearPostSuccess(); - setClearAssetPostSuccess(); - }} + onClose={clearPutSiteSettingSuccess} severity="success" titleId="SUCCESS" descriptionId="CHANGES_SAVED" @@ -297,41 +285,11 @@ export default function GeneralSettings() { )}
diff --git a/src/pages/individual/SearchIndividuals.jsx b/src/pages/individual/SearchIndividuals.jsx index 7bdf647d7..f6fd02f46 100644 --- a/src/pages/individual/SearchIndividuals.jsx +++ b/src/pages/individual/SearchIndividuals.jsx @@ -47,6 +47,7 @@ export default function SearchIndividuals() { > { + const members = get(datum, 'members', {}); + const membershipData = members[individualGuid]; + return { + ...datum, + role: get(membershipData, ['role_guids', 0]), + }; + }); + + const columns = [ + { + reference: 'name', + name: 'name', + labelId: 'SOCIAL_GROUP', + }, + { + name: 'role', + labelId: 'ROLE', + options: { + cellRenderer: cellRendererTypes.socialGroupRole, + }, + }, + { + reference: 'guid', + name: 'guid', + labelId: 'ACTIONS', + options: { + customBodyRender: (guid, socialGroup) => ( + <> + + onClickDelete(socialGroup)} + /> + + ), + }, + }, + ]; + + return ( + + ); +} diff --git a/src/pages/individualGallery/IndividualGallery.jsx b/src/pages/individualGallery/IndividualGallery.jsx new file mode 100644 index 000000000..74212de57 --- /dev/null +++ b/src/pages/individualGallery/IndividualGallery.jsx @@ -0,0 +1,169 @@ +import React, { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { capitalize } from 'lodash-es'; + +import { useTheme } from '@material-ui/core/styles'; + +import useDocumentTitle from '../../hooks/useDocumentTitle'; +import useIndividual from '../../models/individual/useIndividual'; +import { deriveIndividualName } from '../../utils/nameUtils'; +import errorTypes from '../../constants/errorTypes'; +import CustomAlert from '../../components/Alert'; +import Button from '../../components/Button'; +import Link from '../../components/Link'; +import LoadingScreen from '../../components/LoadingScreen'; +import MainColumn from '../../components/MainColumn'; +import SadScreen from '../../components/SadScreen'; +import Text from '../../components/Text'; +import GalleryItem from './components/GalleryItem'; + +const gridColumnWidth = 300; + +function transformToAssets(individualData) { + if (!individualData?.encounters) return []; + + const dataByAsset = individualData.encounters.reduce( + (memo, encounter) => { + const sharedEncounterData = { + owner: { + guid: encounter?.owner?.guid, + fullName: encounter?.owner?.full_name, + }, + time: { + time: encounter?.time, + timeSpecificity: encounter?.timeSpecificity, + }, + location: { + label: encounter?.locationId_value, + decimalLatitude: encounter?.decimalLatitude, + decimalLongitude: encounter?.decimalLongitude, + }, + }; + + // Each asset needs information from the annotation that it belongs to. + // Every encounter annotation will be used, but multiple annotations may have the same asset. + if (encounter?.annotations) { + encounter.annotations.forEach(annotation => { + const { + guid, + bounds, + ia_class: iAClass, + } = annotation || {}; + const annotationData = { guid, bounds, iAClass }; + const assetGuid = annotation?.asset_guid; + + if (assetGuid) { + if (!memo[assetGuid]) { + const assetMetadata = { src: annotation.asset_src }; + + memo[assetGuid] = { + guid: assetGuid, + metadata: assetMetadata, + annotations: [], + ...sharedEncounterData, + }; + } + memo[assetGuid].annotations.push(annotationData); + } + }); + } + + return memo; + }, + {}, + ); + + return Object.values(dataByAsset); +} + +export default function IndividualGallery() { + const intl = useIntl(); + const theme = useTheme(); + const { guid } = useParams(); + + const { data, statusCode, loading, error } = useIndividual(guid); + + const galleryAssets = useMemo( + () => transformToAssets(data), + [data], + ); + + const name = deriveIndividualName( + data, + 'FirstName', + intl.formatMessage({ id: 'UNNAMED_INDIVIDUAL' }), + ); + + useDocumentTitle('INDIVIDUAL_GALLERY_TITLE', { + messageValues: { name: capitalize(name) }, + refreshKey: name, + }); + + if (error) { + return ( + + ); + } + + if (loading) return ; + + return ( + +
+ + + ); +} diff --git a/src/pages/socialGroups/AddSocialGroupDialog.jsx b/src/pages/socialGroups/AddSocialGroupDialog.jsx new file mode 100644 index 000000000..46d1eabdf --- /dev/null +++ b/src/pages/socialGroups/AddSocialGroupDialog.jsx @@ -0,0 +1,79 @@ +import React, { useCallback, useState } from 'react'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogActions from '@material-ui/core/DialogActions'; + +import usePostSocialGroup from '../../models/socialGroups/usePostSocialGroup'; +import CustomAlert from '../../components/Alert'; +import StandardDialog from '../../components/StandardDialog'; +import Button from '../../components/Button'; +import TextInput from '../../components/inputs/TextInput'; + +export default function AddSocialGroupDialog({ open, onClose }) { + const { + mutate: postSocialGroup, + loading, + error, + clearError, + } = usePostSocialGroup(); + + const [name, setName] = useState(''); + + const onCloseDialog = useCallback(() => { + setName(''); + if (error) clearError(); + onClose(); + }, [error]); + + return ( + + + + + + {error && ( + + {error} + + )} +
+
+
+
+ ); +} diff --git a/src/pages/socialGroups/SocialGroup.jsx b/src/pages/socialGroups/SocialGroup.jsx new file mode 100644 index 000000000..cf8486c0a --- /dev/null +++ b/src/pages/socialGroups/SocialGroup.jsx @@ -0,0 +1,255 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useIntl } from 'react-intl'; +import { get } from 'lodash-es'; + +import Grid from '@material-ui/core/Grid'; +import Paper from '@material-ui/core/Paper'; +import AddIcon from '@material-ui/icons/Add'; + +import errorTypes from '../../constants/errorTypes'; +import useDocumentTitle from '../../hooks/useDocumentTitle'; +import useSocialGroup from '../../models/socialGroups/useSocialGroup'; +import useQueryIndividualsByGuid from '../../models/individual/useQueryIndividualsByGuid'; +import usePatchSocialGroup from '../../models/socialGroups/usePatchSocialGroup'; +import { formatDate } from '../../utils/formatters'; +import RemoveFromSocialGroupDialog from '../../components/dialogs/RemoveFromSocialGroupDialog'; +import ActionIcon from '../../components/ActionIcon'; +import IndividualsDisplay from '../../components/dataDisplays/IndividualsDisplay'; +import InputRow from '../../components/fields/edit/InputRow'; +import TextInput from '../../components/inputs/TextInput'; +import ErrorDialog from '../../components/dialogs/ErrorDialog'; +import ButtonLink from '../../components/ButtonLink'; +import Button from '../../components/Button'; +import LoadingScreen from '../../components/LoadingScreen'; +import MainColumn from '../../components/MainColumn'; +import SadScreen from '../../components/SadScreen'; +import Text from '../../components/Text'; +import AddMembersDialog from './AddMembersDialog'; + +const nameSchema = { labelId: 'NAME_OF_GROUP' }; + +export default function SocialGroup() { + const intl = useIntl(); + const { guid } = useParams(); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [individualToRemove, setIndividualToRemove] = useState(null); + + const handleOpenAddDialog = useCallback( + () => setAddDialogOpen(true), + [], + ); + const handleCloseAddDialog = useCallback( + () => setAddDialogOpen(false), + [], + ); + const handleCloseDeleteDialog = useCallback( + () => setIndividualToRemove(null), + [], + ); + + const { data, loading, error, statusCode } = useSocialGroup(guid); + const safeMembers = get(data, 'members', {}); + const memberGuids = Object.keys(safeMembers); + const { data: membersWithData, loading: membersLoading } = + useQueryIndividualsByGuid(memberGuids); + + const { + mutate: patchSocialGroup, + loading: patchLoading, + error: patchError, + clearError: clearPatchError, + } = usePatchSocialGroup(); + + const [name, setName] = useState(''); + + useEffect(() => { + if (data?.name) setName(data.name); + }, [data?.name]); + + const socialGroupName = + data?.name || intl.formatMessage({ id: 'UNNAMED_SOCIAL_GROUP' }); + + useDocumentTitle(socialGroupName || 'SOCIAL_GROUP', { + translateMessage: false, + }); + + if (error) + return ( + + ); + if (loading) return ; + + const nameChanged = name !== data?.name; + const safeMembersWithData = membersWithData || []; + const membersWithRoles = safeMembersWithData.map(member => { + const membershipData = safeMembers[member?.guid]; + return { + ...member, + role: get(membershipData, ['role_guids', 0]), + }; + }); + + return ( + + + + + + {socialGroupName} + + + + + + + + + +
+ + {nameChanged && ( +
+
+ )} +
+
+
+
+ +
+ + +
+ + { + const handleClickDelete = () => + setIndividualToRemove(individualGuid); + return ( + + ); + }, + }, + }, + ]} + loading={membersLoading} + showNoResultsBao={false} + noResultsTextId="NO_INDIVIDUALS_IN_SOCIAL_GROUP" + /> +
+
+
+ ); +} diff --git a/src/pages/socialGroups/SocialGroups.jsx b/src/pages/socialGroups/SocialGroups.jsx new file mode 100644 index 000000000..39ba15bd3 --- /dev/null +++ b/src/pages/socialGroups/SocialGroups.jsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; + +import AddIcon from '@material-ui/icons/Add'; + +import useSocialGroups from '../../models/socialGroups/useSocialGroups'; +import useDeleteSocialGroup from '../../models/socialGroups/useDeleteSocialGroup'; +import useDocumentTitle from '../../hooks/useDocumentTitle'; +import { cellRendererTypes } from '../../components/dataDisplays/cellRenderers'; +import MainColumn from '../../components/MainColumn'; +import LoadingScreen from '../../components/LoadingScreen'; +import SadScreen from '../../components/SadScreen'; +import Text from '../../components/Text'; +import ActionIcon from '../../components/ActionIcon'; +import Button from '../../components/Button'; +import ConfirmDelete from '../../components/ConfirmDelete'; +import SettingsBreadcrumbs from '../../components/SettingsBreadcrumbs'; +import DataDisplay from '../../components/dataDisplays/DataDisplay'; +import AddSocialGroupDialog from './AddSocialGroupDialog'; + +export default function SocialGroups() { + const { data, loading, error, statusCode } = useSocialGroups(); + const { + mutate: deleteSocialGroup, + loading: deletePending, + error: deleteError, + clearError: clearDeleteError, + } = useDeleteSocialGroup(); + + useDocumentTitle('SOCIAL_GROUPS'); + const [addDialogOpen, setAddDialogOpen] = useState(false); + const [pendingDeleteGuid, setPendingDeleteGuid] = useState(null); + + const columns = [ + { + id: 'name', + name: 'name', + labelId: 'NAME', + options: { + cellRenderer: cellRendererTypes.capitalizedString, + }, + }, + { + id: 'guid', + name: 'actions', + labelId: 'ACTIONS', + options: { + customBodyRender: (_, { guid }) => ( + <> + + setPendingDeleteGuid(guid)} + /> + + ), + }, + }, + ]; + + if (error) return ; + if (loading) return ; + + return ( + + setAddDialogOpen(false)} + /> + setPendingDeleteGuid(null)} + onDelete={async () => { + const result = await deleteSocialGroup({ + guid: pendingDeleteGuid, + }); + if (result?.status === 204) setPendingDeleteGuid(null); + }} + deleteInProgress={deletePending} + error={deleteError} + onClearError={clearDeleteError} + messageId="SOCIAL_GROUP_DELETE_CONFIRMATION" + /> + + + +
- {success && shouldDisplay && ( + {success && ( { - setShouldDisplay(false); - }} + onClose={clearSuccess} /> )} - {error && shouldDisplay && ( + {error && ( { - setShouldDisplay(false); + clearEstablishCollaborationError(); + setFormError(null); }} - > - {error} - + /> )} ); diff --git a/src/pages/userManagement/components/EditCollaborationDialog.jsx b/src/pages/userManagement/components/EditCollaborationDialog.jsx new file mode 100644 index 000000000..ed197bc23 --- /dev/null +++ b/src/pages/userManagement/components/EditCollaborationDialog.jsx @@ -0,0 +1,225 @@ +import React, { useEffect, useState } from 'react'; +import { useIntl } from 'react-intl'; + +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; + +import Alert from '../../../components/Alert'; +import Button from '../../../components/Button'; +import InputRow from '../../../components/fields/edit/InputRow'; +import SelectionEditor from '../../../components/fields/edit/SelectionEditor'; +import StandardDialog from '../../../components/StandardDialog'; +import Text from '../../../components/Text'; +import usePatchCollaboration from '../../../models/collaboration/usePatchCollaboration'; +import { formatUserMessage } from '../../../utils/formatters'; +import { + getSummaryState, + isEditApproved, + isViewApproved, + isViewRevoked, + states as collaborationStates, + summaryStates, +} from '../../../utils/collaborationUtils'; + +const coreStateChoices = [ + { + value: 'view', + labelId: 'COLLABORATION_STATE_VIEW', + }, + { + value: 'edit', + labelId: 'COLLABORATION_STATE_EDIT', + }, +]; + +const fullStateChoices = [ + ...coreStateChoices, + { + value: 'revoked', + labelId: 'COLLABORATION_STATE_REVOKED', + }, +]; + +function getPatchCollaborationOperations( + newSummaryState, + collaboration, +) { + if (newSummaryState === summaryStates.revoked) { + return [ + { + op: 'replace', + path: '/managed_view_permission', + value: { + permission: collaborationStates.revoked, + }, + }, + ]; + } + + if (newSummaryState === summaryStates.edit) { + return [ + { + op: 'replace', + path: '/managed_edit_permission', + value: { + permission: collaborationStates.approved, + }, + }, + ]; + } + + if (newSummaryState === summaryStates.view) { + const operations = [ + { + op: 'replace', + path: '/managed_view_permission', + value: { + permission: collaborationStates.approved, + }, + }, + ]; + + if (isEditApproved(collaboration)) { + operations.push({ + op: 'replace', + path: '/managed_edit_permission', + value: { + permission: collaborationStates.revoked, + }, + }); + } + + return operations; + } + + return []; +} + +export default function EditCollaborationDialog({ + collaboration, + open, + onClose, +}) { + const intl = useIntl(); + const [collaborationState, setCollaborationState] = useState( + getSummaryState(collaboration), + ); + const members = Object.values(collaboration?.members || {}); + + const { + mutate: patchCollaboration, + isLoading, + error, + clearError, + } = usePatchCollaboration(); + + useEffect(() => { + setCollaborationState(getSummaryState(collaboration)); + }, [collaboration]); + + const stateChoices = + isViewApproved(collaboration) || isViewRevoked(collaboration) + ? fullStateChoices + : coreStateChoices; + + function handleClose() { + setCollaborationState(''); + clearError(); + onClose(); + } + + return ( + + +
+ {members.map((member, index) => ( +
+ + + {formatUserMessage( + { fullName: member?.full_name }, + intl, + )} + + {member?.email && ( + + {member.email} + + )} +
+ ))} +
+ + + + {error && ( + + )} +
+ +