diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 50a958408..f4d2a7843 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,119 +1,151 @@ ## March 08, 2024 + - **Task**Multi-language - Create engagement translation table & API routes [DESENG-510](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-510) - - Added Engagement translation model. - - Added Engagement translation API. - - Added Unit tests. + - Added Engagement translation model. + - Added Engagement translation API. + - Added Unit tests. + +## March 07, 2024 + +- **Task**: Remove "default_values" from metadata taxa. + Replace with "preset values", metadata entries that are not assigned to an engagement. +- **Task**: Update authorization documentation in the API blueprint. Update + metadata management to rely on normal authorization check functions. +- **Task**: Clean up metadata management code and tests. +- **Task**: Add endpoint for updating metadata by taxon in bulk +- **Feature**: Add editor for metadata taxa (admin only). [🎟️DESENG-443](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-443) +- **Feature**: Add editor for metadata entries (available to anyone who can + edit an engagement). [🎟️DESENG-443](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-443) ## March 06, 2024 + - **Task**Multi-language - Create simple widget translation tables & API routes [DESENG-514](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-514) - - Added Widget translation model. - - Added Widget translation API. - - Added Unit tests. + - Added Widget translation model. + - Added Widget translation API. + - Added Unit tests. - **Task**Multi-language - Create survey translation table & API routes [DESENG-511](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-511) - - Added Survey Translation model. - - Added Survey Translation API. - - Added Survey Translation tests. + - Added Survey Translation model. + - Added Survey Translation API. + - Added Survey Translation tests. ## March 04, 2024 + - **Task**Engagement "save" enhancements [DESENG-507](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-507) - - The existing "Save" button in the floating bar has been split into two distinct actions: "Save and Continue" and "Save and Exit". - - Tabs are greyed out, and widgets are disabled until the engagement is successfully saved. A helpful tool-tip has been added to inform users that the engagement needs to be saved before accessing certain features. - - Independent save buttons previously present in tabs, such as "Additional Details", "Settings" have been removed. Now, the floating save bar is universally employed when editing an engagement. + - The existing "Save" button in the floating bar has been split into two distinct actions: "Save and Continue" and "Save and Exit". + - Tabs are greyed out, and widgets are disabled until the engagement is successfully saved. A helpful tool-tip has been added to inform users that the engagement needs to be saved before accessing certain features. + - Independent save buttons previously present in tabs, such as "Additional Details", "Settings" have been removed. Now, the floating save bar is universally employed when editing an engagement. - **Task**Multi-language - Create language table & API [DESENG-509](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-509) - - Added Language model. - - Added Language API. - - Added Unit tests. + - Added Language model. + - Added Language API. + - Added Unit tests. ## February 27, 2024 + - **Bug Fix**Comments cannot be approved while reviewing [DESENG-496](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-496) - - Fixed by adding a missing decorator for transactional methods. + - Fixed by adding a missing decorator for transactional methods. - **Task**Enhance analytics api for Improved Readability and Maintainability [DESENG-492](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-492) - - Refactor analytics-api config to harmonize its structure and conventions with met-api. - - Ensure the sample.env file maintains consistent formatting. - - Adjusted the component_id column size in the comment table of the met-api to resolve an error encountered during user submissions. + - Refactor analytics-api config to harmonize its structure and conventions with met-api. + - Ensure the sample.env file maintains consistent formatting. + - Adjusted the component_id column size in the comment table of the met-api to resolve an error encountered during user submissions. ## February 26, 2024 + - **Task**Models for dynamic engagement pages [DESENG-500](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-500) - - Implemented endpoints for dynamic engagement pages, including summary and custom sections. - - Default behavior ensures that each engagement has a dynamic summary page. - - Introduced logic to migrate existing content and rich content for engagements to the summary table. + - Implemented endpoints for dynamic engagement pages, including summary and custom sections. + - Default behavior ensures that each engagement has a dynamic summary page. + - Introduced logic to migrate existing content and rich content for engagements to the summary table. - **Task**Add font awesome libraries [DESENG-490](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-490) - - Added related libraries to the frontend package.json. + - Added related libraries to the frontend package.json. - **Task**Adding missing unit test [DESENG-483](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-483) - - Added missing unit test for components. + - Added missing unit test for components. ## February 20, 2024 + - **Task**Upgrade the version of flask [DESENG-502](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-502) - - Performed a Flask version upgrade to version 2.2.5. + - Performed a Flask version upgrade to version 2.2.5. ## February 16, 2024 + - **Task**Make a floating save/preview bar when editing engagements [DESENG-498](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-498) - - Implemented a floating behavior for the save/preview buttons during engagement editing. This feature persists across all tabs but exclusively saves data for the Engagement Content tab. + - Implemented a floating behavior for the save/preview buttons during engagement editing. This feature persists across all tabs but exclusively saves data for the Engagement Content tab. ## February 15, 2024 + - **Task**Restore role assignment functionality to MET with the CSS API [DESENG-473](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-473) - - Utilize the CSS API for efficient management of composite roles. This involves the assignment, reassignment, or removal of users from the composite roles of TEAM_MEMBER, REVIEWER, IT_ADMIN, or IT_VIEWER. + - Utilize the CSS API for efficient management of composite roles. This involves the assignment, reassignment, or removal of users from the composite roles of TEAM_MEMBER, REVIEWER, IT_ADMIN, or IT_VIEWER. ## February 09, 2024 + - **Task**Consolidate and re-write old migration files [DESENG-452](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-452) - - Deleted old migration files - - Created [ec0128056a33_table_until_feb_09_2024.py](met-api/migrations/versions/ec0128056a33_table_until_feb_09_2024.py) with all tables until feb 09 2024 - - Created [37176ea4708d_data_until_feb_09_2024.py](met-api/migrations/versions/37176ea4708d_data_until_feb_09_2024.py) with all initial data until feb 09 2024 + - Deleted old migration files + - Created [ec0128056a33_table_until_feb_09_2024.py](met-api/migrations/versions/ec0128056a33_table_until_feb_09_2024.py) with all tables until feb 09 2024 + - Created [37176ea4708d_data_until_feb_09_2024.py](met-api/migrations/versions/37176ea4708d_data_until_feb_09_2024.py) with all initial data until feb 09 2024 ## February 08, 2024 + - **Task**Cache CORS preflight responses with the browser for a given period of time [DESENG-484](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-484) - - Introduces a new configuration variable to specify the maximum age for Cross-Origin Resource Sharing (CORS) - - Modified the CORS preflight method to utilize this newly introduced variable. + - Introduces a new configuration variable to specify the maximum age for Cross-Origin Resource Sharing (CORS) + - Modified the CORS preflight method to utilize this newly introduced variable. - **Task**Consolidate and re-write old migration files [DESENG-452](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-452) - - Change some foreign key field to nullbale false in model files - - Change `rejected_reason_other` to nullable true in `submission` model - - Generated new migration file based on the pending model changes which confirmed to be valid - - Updated Unit test of email verfication to send type to the api + - Change some foreign key field to nullbale false in model files + - Change `rejected_reason_other` to nullable true in `submission` model + - Generated new migration file based on the pending model changes which confirmed to be valid + - Updated Unit test of email verfication to send type to the api ## February 06, 2024 + - **Task**Convert keycloak groups to composite roles for permission levels [DESENG-447](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-447) - - Commented out unit test related to Keycloak groups - - Changed reference of Keycloak `groups` to `roles` - - Commented out code related to Keycloak groups + - Commented out unit test related to Keycloak groups + - Changed reference of Keycloak `groups` to `roles` + - Commented out code related to Keycloak groups ## February 06, 2024 + - **Task** Streamline CRON jobs [DESENG-493](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-493) - Aligned the CRON configuration and sample environment files with the structure used in the Met API. - Eliminated the reliance on engagement metadata within CRON jobs. - Implemented necessary code adjustments to seamlessly integrate with the updated CRON configuration. ## February 05, 2024 + - **Task** Change "Superuser" to "Administrator" [DESENG-476](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-476) ## February 02, 2024 + - **Task** Updated Timeline widget icons so that the circles are more consistent. [🎟️DESENG-488](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-488) ## February 01, 2024 + - **Task** Change name from "Engagement Core" to "Engagement Content". [🎟️DESENG-489](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-489) ## January 29, 2024 + - **Task** Updated Babel Traverse library. [🎟️DESENG-474](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-474) - Run `npm audit fix` to update the vulnerable Babel traverse library. ## January 26, 2024 + - **Task** Poll Widget: Front-end. [🎟️DESENG-464](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-464) - Created UI for Poll Widget. - Updated Poll widget API and unit tests. ## January 25, 2024 + - **Task** Resolve issue preventing met-web from deploying on the Dev OpenShift environment. [🎟️DESENG-469](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-469) - Remove Epic Engage-related links and update Keycloak link. - Remove additional authentication method. ## January 24, 2024 + - **Task** Update default project type to GDX for all deployments by default. [🎟️DESENG-472](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-472) - Set the default project type to GDX on all continuous deployment (CD) files. - Removed the option to deploy to EAO. ## January 22, 2024 + - **Task** Poll Widget: Back-end [🎟️DESENG-463](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-463) - Created Database models for Widget Poll, Poll Answers, Poll Response. - - Created API to manage Widget Poll, Poll Answers, Poll Response. + - Created API to manage Widget Poll, Poll Answers, Poll Response. - Created Unit tests to test the code. - **Task** Add missing unit tests for met api [🎟️DESENG-481](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-481) - Added missing unit tests for met api @@ -153,6 +185,7 @@ ## January 9, 2024 - **Task** Improvements from Epic [🎟️DESENG-468](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-468) + - Improvements to Survey Result Tracking analytics - New Rejection Email Template for Closed Engagements - Export Format for Proponent updated to be in excel format @@ -201,9 +234,10 @@ ## November 6, 2023 - **Feature**: Switch MET to use Keycloak SSO service [🎟️DESENG-408](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-408) + - Switch all role-based checks on the API to use a single callback function (`current_app.config['JWT_ROLE_CALLBACK']`) - Added a configurable path `JWT_ROLE_CLAIM` to indicate where your SSO instance places role information in the JWT token. If your access token looks like: - `{ ..., "realm_access": { "roles": [ "role1", "role2"]}}` you would set `JWT_ROLE_CLAIM=realm_access.roles` + `{ ..., "realm_access": { "roles": [ "role1", "role2"]}}` you would set `JWT_ROLE_CLAIM=realm_access.roles` - Explicitly disable single tenant mode by default to ensure correct multi-tenancy behaviour - Remove local Keycloak instances and configuration - Default to the "standard" realm for Keycloak @@ -224,7 +258,6 @@ - Remove one old production .env file with obsolete settings - Changes to DEVELOPMENT.md to reflect the current state of the project - ## v1.0.0 - 2023-10-01 - App handoff from EAO to GDX diff --git a/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py b/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py index fedd36d7c..3bb527743 100644 --- a/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py +++ b/met-api/migrations/versions/c4f7189494ed_create_engagement_translation_table.py @@ -1,7 +1,7 @@ """create_engagement_translation_table Revision ID: c4f7189494ed -Revises: 35124d2e41cb +Revises: dbe023373f4f Create Date: 2024-03-07 16:38:26.958748 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = 'c4f7189494ed' -down_revision = '35124d2e41cb' +down_revision = 'dbe023373f4f' branch_labels = None depends_on = None @@ -19,28 +19,40 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_table('engagement_translation', - sa.Column('created_date', sa.DateTime(), nullable=False), - sa.Column('updated_date', sa.DateTime(), nullable=True), - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('engagement_id', sa.Integer(), nullable=False), - sa.Column('language_id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=50), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('rich_description', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('content', sa.Text(), nullable=True), - sa.Column('rich_content', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('consent_message', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('slug', sa.String(length=200), nullable=True), - sa.Column('upcoming_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('open_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('closed_status_block_text', postgresql.JSON(astext_type=sa.Text()), nullable=True), - sa.Column('created_by', sa.String(length=50), nullable=True), - sa.Column('updated_by', sa.String(length=50), nullable=True), - sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['language_id'], ['language.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('engagement_id', 'language_id', name='_engagement_language_uc') - ) + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), + autoincrement=True, nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=False), + sa.Column('language_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=50), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('rich_description', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('content', sa.Text(), nullable=True), + sa.Column('rich_content', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('consent_message', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('slug', sa.String(length=200), nullable=True), + sa.Column('upcoming_status_block_text', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('open_status_block_text', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('closed_status_block_text', postgresql.JSON( + astext_type=sa.Text()), nullable=True), + sa.Column('created_by', sa.String( + length=50), nullable=True), + sa.Column('updated_by', sa.String( + length=50), nullable=True), + sa.ForeignKeyConstraint( + ['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint( + ['language_id'], ['language.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('engagement_id', 'language_id', + name='_engagement_language_uc') + ) # ### end Alembic commands ### diff --git a/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py b/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py new file mode 100644 index 000000000..2743b2d9c --- /dev/null +++ b/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py @@ -0,0 +1,31 @@ +"""Remove default_value from engagement_metadata_taxa and add unique constraint + +Revision ID: dbe023373f4f +Revises: 35124d2e41cb +Create Date: 2024-01-30 17:05:25.313222 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'dbe023373f4f' +down_revision = '35124d2e41cb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'engagement_metadata_taxa', ['id']) + op.drop_column('engagement_metadata_taxa', 'default_value') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('engagement_metadata_taxa', sa.Column( + 'default_value', sa.TEXT(), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'engagement_metadata_taxa', type_='unique') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/auth.py b/met-api/src/met_api/auth.py index 724b8f670..4fd49ea03 100644 --- a/met-api/src/met_api/auth.py +++ b/met-api/src/met_api/auth.py @@ -18,11 +18,18 @@ from flask_jwt_oidc import JwtManager from flask_jwt_oidc.exceptions import AuthError +from met_api.utils.constants import TENANT_ID_HEADER + auth_methods = { # for swagger documentation 'apikey': { 'type': 'apiKey', 'in': 'header', 'name': 'Authorization' + }, + 'tenant': { + 'type': 'apiKey', + 'in': 'header', + 'name': TENANT_ID_HEADER } } @@ -53,7 +60,8 @@ def decorated(*args, **kwargs): token = jwt.get_token_auth_header() # pylint: disable=protected-access jwt._validate_token(token) - g.authorization_header = request.headers.get('Authorization', None) + g.authorization_header = request.headers.get( + 'Authorization', None) g.token_info = g.jwt_oidc_token_info except AuthError: g.authorization_header = None diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index 7b6eb7ed5..f25c4b7fa 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -17,7 +17,8 @@ class EngagementMetadata(BaseModel): """A unit of metadata for an Engagement. Can be used to store arbitrary data.""" __tablename__ = 'engagement_metadata' - id = db.Column(db.Integer, primary_key=True, nullable=False, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, + nullable=False, autoincrement=True) engagement_id = db.Column(db.Integer, db.ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True, index=True) engagement = db.relationship('Engagement', backref='metadata') @@ -69,21 +70,16 @@ def __repr__(self) -> str: class MetadataTaxonDataType(str, enum.Enum): """The data types that can be stored in a metadata property.""" - TEXT = 'string' - LONG_TEXT = 'long-text' + TEXT = 'text' + LONG_TEXT = 'long_text' NUMBER = 'number' DATE = 'date' + TIME = 'time' DATETIME = 'datetime' BOOLEAN = 'boolean' - SELECT = 'select' - IMAGE = 'image' - VIDEO = 'video' - AUDIO = 'audio' - FILE = 'other_file' URL = 'url' EMAIL = 'email' PHONE = 'phone' - ADDRESS = 'address' OTHER = 'other' @classmethod @@ -102,7 +98,8 @@ class MetadataTaxon(BaseModel): __tablename__ = 'engagement_metadata_taxa' - id = db.Column(db.Integer, primary_key=True, unique=True, autoincrement=True) + id = db.Column(db.Integer, primary_key=True, + unique=True, autoincrement=True) tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id', ondelete='CASCADE'), nullable=False, index=True) @@ -111,7 +108,6 @@ class MetadataTaxon(BaseModel): description = db.Column(db.String(256), nullable=True) freeform = db.Column(db.Boolean, nullable=False, default=False) data_type = db.Column(db.String(64), nullable=True, default='text') - default_value = db.Column(db.Text, nullable=True) one_per_engagement = db.Column(db.Boolean) position = db.Column(db.Integer, nullable=False, index=True) @@ -122,7 +118,8 @@ def __init__(self, **kwargs) -> None: self.data_type = 'text' if not self.position: # find other taxa in this tenant and set position to the next highest - max_position = MetadataTaxon.query.filter_by(tenant_id=self.tenant_id).count() + max_position = MetadataTaxon.query.filter_by( + tenant_id=self.tenant_id).count() self.position = max_position + 1 @validates('id') @@ -145,6 +142,23 @@ def __repr__(self) -> str: return '' return f'' + @property + def preset_values(self) -> list[str]: + """Get preset values - any metadata entries with no specific engagement.""" + return [entry.value for entry in self.entries if entry.engagement_id is None] + + @preset_values.setter + @transactional() + def preset_values(self, values: list[str]) -> None: + # Update preset values to match the provided list + for entry in self.entries: + if entry.engagement_id is None and entry.value not in values: + entry.delete() + for value in values: + if value not in self.preset_values: + entry = EngagementMetadata(taxon_id=self.id, value=value) + entry.save() + @transactional() def move_to_position(self, new_position: int) -> None: """ @@ -194,6 +208,7 @@ def reorder_taxa(cls, tenant_id: int, taxon_order: list[int]) -> None: Setting their positions accordingly. """ for index, taxon_id in enumerate(taxon_order): - taxon = cls.query.filter_by(tenant_id=tenant_id, taxon_id=taxon_id).first() + taxon = cls.query.filter_by( + tenant_id=tenant_id, taxon_id=taxon_id).first() if taxon: taxon.position = index diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 5e362f44e..f50fe914b 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -91,7 +91,7 @@ API.add_namespace(VALUE_COMPONENTS_API) API.add_namespace(SHAPEFILE_API) API.add_namespace(TENANT_API) -API.add_namespace(METADATA_TAXON_API, path='/tenants//metadata') +API.add_namespace(METADATA_TAXON_API, path='/engagement_metadata') API.add_namespace(ENGAGEMENT_METADATA_API, path='/engagements//metadata') API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements//members') API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets//documents') diff --git a/met-api/src/met_api/resources/engagement_metadata.py b/met-api/src/met_api/resources/engagement_metadata.py index 566a0a798..b9baa9f75 100644 --- a/met-api/src/met_api/resources/engagement_metadata.py +++ b/met-api/src/met_api/resources/engagement_metadata.py @@ -31,7 +31,8 @@ from met_api.utils.util import allowedorigins, cors_preflight EDIT_ENGAGEMENT_ROLES = [Role.EDIT_ENGAGEMENT.value] -VIEW_ENGAGEMENT_ROLES = [Role.VIEW_ENGAGEMENT.value, Role.EDIT_ENGAGEMENT.value] +VIEW_ENGAGEMENT_ROLES = [ + Role.VIEW_ENGAGEMENT.value, Role.EDIT_ENGAGEMENT.value] API = Namespace('engagement_metadata', path='/engagements//metadata', @@ -42,6 +43,12 @@ 'value': fields.String(required=True, description='The value of the metadata entry'), }) +metadata_bulk_update_model = API.model('EngagementMetadataBulkUpdate', { + 'taxon_id': fields.Integer(required=True, description='The id of the taxon'), + 'values': fields.List(fields.String, required=True, + description='The values to save to the taxon'), +}) + metadata_create_model = API.model('EngagementMetadataCreate', model_dict := { 'taxon_id': fields.Integer(required=True, description='The id of the taxon'), **model_dict @@ -57,7 +64,7 @@ metadata_service = EngagementMetadataService() -@cors_preflight('GET,POST') +@cors_preflight('GET,POST,PATCH') @API.route('') # /api/engagements/{engagement.id}/metadata @API.doc(params={'engagement_id': 'The numeric id of the engagement'}) class EngagementMetadata(Resource): @@ -76,7 +83,8 @@ def get(engagement_id): @cross_origin(origins=allowedorigins()) @API.doc(security='apikey') @API.expect(metadata_create_model) - @API.marshal_with(metadata_return_model, code=HTTPStatus.CREATED) # type: ignore + # type: ignore + @API.marshal_with(metadata_return_model, code=HTTPStatus.CREATED) @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) def post(engagement_id: int): """Create a new metadata entry for an engagement.""" @@ -93,9 +101,27 @@ def post(engagement_id: int): except (ValueError, ValidationError) as err: return str(err), HTTPStatus.BAD_REQUEST + @staticmethod + @cross_origin(origins=allowedorigins()) + @API.doc(security='apikey') + @API.expect(metadata_bulk_update_model, validate=True) + @API.marshal_list_with(metadata_return_model) + @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) + def patch(engagement_id): + """Update the values of existing metadata entries for an engagement.""" + authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES, + engagement_id=engagement_id) + data = request.get_json(force=True) + taxon_id = data['taxon_id'] + updated_values = data['values'] + result = metadata_service.update_by_taxon( + engagement_id, taxon_id, updated_values + ) + return result, HTTPStatus.OK + @cors_preflight('GET,PUT,DELETE') -@API.route('/') # /api/engagements/{engagement.id}/metadata/{metadata.id} +@API.route('/') # /metadata/{metadata.id} @API.doc(params={'engagement_id': 'The numeric id of the engagement', 'metadata_id': 'The numeric id of the metadata entry'}) @API.doc(security='apikey') @@ -124,6 +150,7 @@ def get(engagement_id, metadata_id): @staticmethod @cross_origin(origins=allowedorigins()) @auth.has_one_of_roles(EDIT_ENGAGEMENT_ROLES) + @API.expect(metadata_update_model) def patch(engagement_id, metadata_id): """Update the values of an existing metadata entry for an engagement.""" authorization.check_auth(one_of_roles=EDIT_ENGAGEMENT_ROLES, diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py index ec36203d2..c4fb68b73 100644 --- a/met-api/src/met_api/resources/metadata_taxon.py +++ b/met-api/src/met_api/resources/metadata_taxon.py @@ -47,10 +47,11 @@ 'name': fields.String(required=False, description='The name of the taxon'), 'description': fields.String(required=False, description='The taxon description'), 'freeform': fields.Boolean(required=False, description='Whether the taxon is freeform'), - 'default_value': fields.String(required=False, description='The default value for the taxon'), 'data_type': fields.String(required=False, description='The data type for the taxon'), 'one_per_engagement': fields.Boolean(required=False, description='Whether the taxon is limited' ' to one entry per engagement'), + 'preset_values': fields.List(fields.String(), required=False, + description='The preset values for the taxon'), }) taxon_return_model = API.model('MetadataTaxonReturn', { @@ -61,7 +62,10 @@ **taxon_model_dict }) -params = {'tenant_id': 'The short name of the tenant'} +taxon_ids_model = API.model('TaxonIDs', { + 'taxon_ids': fields.List(fields.Integer, required=True, description='A list of taxon ids') +}) + responses = { HTTPStatus.UNAUTHORIZED.value: 'No known user logged in', @@ -74,22 +78,18 @@ def ensure_tenant_access(): """ - Ensure that the user is authorized to access the tenant specified in the request. + Provide access to the tenant as a DB model for the decorated function. - This decorator should be used on any endpoint that requires - access to a tenant's data. Makes the tenant accessible via kwargs. + This does not provide security; that is handled by @require_role. """ def wrapper(f: Callable): @wraps(f) def decorated_function(*args, **func_kwargs): - tenant_short_name = func_kwargs.pop('tenant_name') + tenant_short_name = g.tenant_name tenant = Tenant.find_by_short_name(tenant_short_name) if not tenant: abort(HTTPStatus.NOT_FOUND, - f'Tenant with short name {tenant_short_name} not found') - if tenant.short_name.upper() != g.tenant_name: - abort(HTTPStatus.FORBIDDEN, - f'You are not authorized to access tenant {tenant_short_name}') + f'Tenant with id {tenant_short_name} not found') func_kwargs['tenant'] = tenant return f(*args, **func_kwargs) return decorated_function @@ -97,8 +97,8 @@ def decorated_function(*args, **func_kwargs): @cors_preflight('GET,POST,PATCH,OPTIONS') -@API.route('/taxa') # /api/tenants/{tenant.short_name}/metadata/taxa -@API.doc(params=params, security='apikey', responses=responses) +@API.route('/taxa') # /metadata/taxa +@API.doc(security=['apikey', 'tenant'], responses=responses) class MetadataTaxa(Resource): """Resource for managing engagement metadata taxa.""" @@ -108,14 +108,15 @@ class MetadataTaxa(Resource): @ensure_tenant_access() @require_role(VIEW_TAXA_ROLES) def get(tenant: Tenant): - """Fetch a list of metadata taxa by tenant id.""" + """Fetch a list of metadata taxa for the current tenant.""" tenant_taxa = taxon_service.get_by_tenant(tenant.id) return tenant_taxa, HTTPStatus.OK @staticmethod @cross_origin(origins=allowedorigins()) @API.expect(taxon_modify_model) - @API.marshal_with(taxon_return_model, code=HTTPStatus.CREATED) # type: ignore + # type: ignore + @API.marshal_with(taxon_return_model, code=HTTPStatus.CREATED) @ensure_tenant_access() @require_role(MODIFY_TAXA_ROLES) def post(tenant: Tenant): @@ -131,7 +132,7 @@ def post(tenant: Tenant): @staticmethod @cross_origin(origins=allowedorigins()) - @API.expect({'taxon_ids': fields.List(fields.Integer(required=True))}) + @API.expect(taxon_ids_model) @API.marshal_list_with(taxon_return_model) @ensure_tenant_access() @require_role(MODIFY_TAXA_ROLES) @@ -148,13 +149,13 @@ def patch(tenant: Tenant): return str(err), HTTPStatus.INTERNAL_SERVER_ERROR -params['taxon_id'] = 'The numeric id of the taxon' responses[HTTPStatus.NOT_FOUND.value] = 'Metadata taxon or tenant not found' @cors_preflight('GET,PATCH,DELETE,OPTIONS') -@API.route('/taxon/') # /tenants//metadata/taxon/ -@API.doc(security='apikey', params=params, responses=responses) +# /metadata/taxon/ +@API.route('/taxon/') +@API.doc(security=['apikey', 'tenant'], responses=responses) class MetadataTaxon(Resource): """Resource for managing a single metadata taxon.""" diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index fce17e7ed..abedcad9a 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,7 +1,4 @@ -"""Engagement Metadata schema class. - -Manages the Engagement Metadata -""" +"""Schemas for serializing and deserializing classes related to engagement metadata.""" from marshmallow import ValidationError, fields, pre_load, validate from marshmallow_sqlalchemy import SQLAlchemyAutoSchema @@ -35,7 +32,8 @@ def check_immutable_fields(self, data, **kwargs): return data # Nested fields - taxon = Nested('MetadataTaxonSchema', many=False) + taxon = Nested('MetadataTaxonSchema', many=False, + exclude=['entries']) class MetadataTaxonSchema(SQLAlchemyAutoSchema): @@ -49,12 +47,28 @@ class Meta: include_fk = True name = fields.String(required=True, validate=validate.Length(max=64)) - description = fields.String(validate=validate.Length(max=512), allow_none=True) + description = fields.String( + validate=validate.Length(max=512), allow_none=True) freeform = fields.Boolean() - default_value = fields.String(validate=validate.Length(max=512), allow_none=True) - data_type = fields.String(validate=validate.OneOf([e.value for e in MetadataTaxonDataType])) + default_value = fields.String( + validate=validate.Length(max=512), allow_none=True) + data_type = fields.String(validate=validate.OneOf( + [e.value for e in MetadataTaxonDataType])) one_per_engagement = fields.Boolean() position = fields.Integer(required=False) + preset_values = fields.Method( + 'get_preset_values', deserialize='set_preset_values') + + def get_preset_values(self, obj): + """Serialize the preset_values property for Marshmallow.""" + return obj.preset_values + + def set_preset_values(self, values): + """Deserialize the preset_values into a list of strings. + + The rest is handled in the preset_values property setter. + """ + return [str(value) for value in values] @pre_load def check_immutable_fields(self, data, **kwargs): diff --git a/met-api/src/met_api/services/engagement_metadata_service.py b/met-api/src/met_api/services/engagement_metadata_service.py index 9dae4e013..9436c55e7 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -26,11 +26,12 @@ def get(metadata_id) -> dict: """ engagement_metadata = EngagementMetadata.query.get(metadata_id) if not engagement_metadata: - raise KeyError(f'Engagement metadata with id {metadata_id} does not exist.') + raise KeyError( + f'Engagement metadata with id {metadata_id} does not exist.') return dict(EngagementMetadataSchema().dump(engagement_metadata)) @staticmethod - def get_by_engagement(engagement_id) -> List[dict]: + def get_by_engagement(engagement_id, taxon_id=None) -> List[dict]: """ Get metadata by engagement id. @@ -43,8 +44,12 @@ def get_by_engagement(engagement_id) -> List[dict]: """ engagement_model = EngagementModel.query.get(engagement_id) if not engagement_model: - raise KeyError(f'Engagement with id {engagement_id} does not exist.') - return EngagementMetadataSchema(many=True).dump(engagement_model.metadata) + raise KeyError( + f'Engagement with id {engagement_id} does not exist.') + results = engagement_model.metadata + if taxon_id: + results = [item for item in results if item.taxon_id == taxon_id] + return EngagementMetadataSchema(many=True).dump(results) @staticmethod def check_association(engagement_id, metadata_id) -> bool: @@ -61,7 +66,8 @@ def check_association(engagement_id, metadata_id) -> bool: """ engagement_metadata = EngagementMetadata.query.get(metadata_id) if not engagement_metadata: - raise KeyError(f'Engagement metadata with id {metadata_id} does not exist.') + raise KeyError( + f'Engagement metadata with id {metadata_id} does not exist.') return engagement_metadata.engagement_id == engagement_id @staticmethod @@ -80,12 +86,14 @@ def create(engagement_id: int, taxon_id: int, value: str) -> dict: # Ensure that the engagement exists, or else raise the appropriate error engagement = EngagementModel.query.get(engagement_id) if not engagement: - raise KeyError(f'Engagement with id {engagement_id} does not exist.') + raise KeyError( + f'Engagement with id {engagement_id} does not exist.') taxon = MetadataTaxon.query.get(taxon_id) if not taxon: raise ValueError(f'Taxon with id {taxon_id} does not exist.') if engagement.tenant.id != taxon.tenant.id: - raise ValueError(f'Taxon {taxon} does not belong to tenant {engagement.tenant}') + raise ValueError( + f'Taxon {taxon} does not belong to tenant {engagement.tenant}') metadata = { 'engagement_id': engagement_id, 'taxon_id': taxon_id, @@ -111,21 +119,6 @@ def create_for_engagement(self, engagement_id: int, metadata: dict, **kwargs) -> metadata = metadata or {} metadata = self.create(metadata, engagement_id, **kwargs) - @staticmethod - def create_defaults(engagement_id: int, tenant_id: int) -> List[dict]: - """Create default metadata for an engagement.""" - # Get metadata taxa for the tenant - taxa = MetadataTaxon.query.filter_by(tenant_id=tenant_id).all() - # Create a list of metadata to create - metadata = [] - for taxon in taxa: - if taxon.default_value: - metadata.append(EngagementMetadataService.create( - engagement_id, - taxon.id, - taxon.default_value)) - return metadata - @staticmethod @transactional() def update(metadata_id: int, value: str) -> dict: @@ -145,6 +138,37 @@ def update(metadata_id: int, value: str) -> dict: metadata.value = value return dict(EngagementMetadataSchema().dump(metadata, many=False)) + @staticmethod + @transactional() + def update_by_taxon(engagement_id: int, taxon_id: int, values: List[str]) -> List[dict]: + """ + Update engagement metadata by taxon. + + Args: + engagement_id: The ID of the engagement. + taxon_id: The ID of the metadata taxon. + values: The values to store for the taxon. + Returns: + The updated metadata as a list. + """ + query = EngagementMetadata.query.filter_by( + engagement_id=engagement_id, taxon_id=taxon_id) + metadata = query.all() + if len(metadata) > len(values): + for i in range(len(values), len(metadata)): + metadata[i].delete() + metadata = query.all() # remove deleted entries from the list + for i, value in enumerate(values): + if i < len(metadata): + metadata[i].value = value + if len(values) > len(metadata): + for i in range(len(metadata), len(values)): + metadata.append(EngagementMetadata( + engagement_id=engagement_id, taxon_id=taxon_id, value=values[i] + )) + db.session.add_all(metadata) + return EngagementMetadataSchema(many=True).dump(metadata) + @staticmethod @transactional() def delete(metadata_id: int) -> None: diff --git a/met-api/src/met_api/services/metadata_taxon_service.py b/met-api/src/met_api/services/metadata_taxon_service.py index 043c164cd..7615b4391 100644 --- a/met-api/src/met_api/services/metadata_taxon_service.py +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -1,6 +1,6 @@ """Service for engagement management.""" -from typing import List, Optional, Union +from typing import List, Optional from met_api.models import db from met_api.models.db import transactional @@ -33,19 +33,21 @@ def create(tenant_id: int, taxon_data: dict) -> dict: """Create a new taxon.""" taxon_data['tenant_id'] = tenant_id taxon: MetadataTaxon = MetadataTaxonSchema().load(taxon_data, session=db.session) - taxon.position = MetadataTaxon.query.filter_by(tenant_id=tenant_id).count() + 1 + taxon.position = MetadataTaxon.query.filter_by( + tenant_id=tenant_id).count() + 1 taxon.save() return dict(MetadataTaxonSchema().dump(taxon)) @staticmethod - def update(taxon_id: int, taxon_data: dict) -> Union[dict, list]: + @transactional() + def update(taxon_id: int, taxon_data: dict) -> dict: """Update a taxon.""" taxon = MetadataTaxon.query.get(taxon_id) if not taxon: raise KeyError(f'Taxon with id {taxon_id} does not exist.') schema = MetadataTaxonSchema() - taxon = schema.load(taxon_data, session=db.session, instance=taxon) - taxon.save() + taxon = schema.load(taxon_data, session=db.session, + instance=taxon, partial=True) return schema.dump(taxon) @staticmethod @@ -85,7 +87,8 @@ def auto_order_tenant(tenant_id: int) -> List[dict]: """ tenant = Tenant.query.get(tenant_id) schema = MetadataTaxonSchema() - taxon_ordered = sorted(tenant.metadata_taxa, key=lambda taxon: taxon.position) + taxon_ordered = sorted(tenant.metadata_taxa, + key=lambda taxon: taxon.position) for index, taxon in enumerate(taxon_ordered): taxon.position = index + 1 return schema.dump(taxon_ordered, many=True) @@ -97,4 +100,6 @@ def delete(taxon_id: int) -> None: taxon: MetadataTaxon = MetadataTaxon.query.get(taxon_id) if not taxon: raise KeyError(f'Taxon with id {taxon_id} does not exist.') + for entry in taxon.entries: + entry.delete() taxon.delete() diff --git a/met-api/tests/unit/api/test_engagement_metadata.py b/met-api/tests/unit/api/test_engagement_metadata.py index 08f4f97a8..f79c5d417 100644 --- a/met-api/tests/unit/api/test_engagement_metadata.py +++ b/met-api/tests/unit/api/test_engagement_metadata.py @@ -42,7 +42,8 @@ def test_get_engagement_metadata(client, jwt, session): 'taxon_id': taxon.id, 'value': fake.sentence(), }) - existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) assert existing_metadata is not None response = client.get(f'/api/engagements/{engagement.id}/metadata', headers=headers, content_type=ContentType.JSON.value) @@ -131,6 +132,43 @@ def test_update_engagement_metadata(client, jwt, session): assert response.json.get('value') == 'new value' +def test_bulk_update_engagement_metadata(client, jwt, session): + """Test that metadata values can be updated in bulk.""" + taxon, engagement, _, headers = factory_metadata_requirements(jwt) + for i in range(4): + factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + 'value': f'old value {i}' + }) + response = client.patch(f'/api/engagements/{engagement.id}/metadata', + headers=headers, + data=json.dumps({ + 'taxon_id': taxon.id, + 'values': [f'new value {i}' for i in range(3)] + }), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK, (f'Wrong response code; ' + f'HTTP {response.status_code} -> {response.text}') + assert response.json is not None + assert len(response.json) == 3 + response = client.patch(f'/api/engagements/{engagement.id}/metadata', + headers=headers, + data=json.dumps({ + 'taxon_id': taxon.id, + 'values': [f'newer value {i}' for i in range(5)] + }), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK, (f'Wrong response code; ' + f'HTTP {response.status_code} -> {response.text}') + assert response.json is not None + assert len(response.json) == 5 + assert all(meta['value'] == + f'newer value {i}' for i, meta in enumerate(response.json)) + assert len(EngagementMetadataService( + ).get_by_engagement(engagement.id, taxon_id=taxon.id)) == 5 + + def test_delete_engagement_metadata(client, jwt, session): """Test that metadata can be deleted.""" taxon, engagement, _, headers = factory_metadata_requirements(jwt) diff --git a/met-api/tests/unit/api/test_metadata_taxa.py b/met-api/tests/unit/api/test_metadata_taxa.py index b45ea0413..a6128f28b 100644 --- a/met-api/tests/unit/api/test_metadata_taxa.py +++ b/met-api/tests/unit/api/test_metadata_taxa.py @@ -26,14 +26,17 @@ engagement_metadata_service = EngagementMetadataService() metatada_taxon_service = MetadataTaxonService() +TENANT_TAXA_ENDPOINT = '/api/engagement_metadata/taxa' +TAXON_ENDPOINT = '/api/engagement_metadata/taxon' + def test_get_tenant_metadata_taxa(client, jwt, session): - """Test that metadata taxon can be retrieved by tenant id.""" + """Test that metadata taxa can be retrieved by tenant id.""" tenant, headers = factory_taxon_requirements(jwt) metadata_taxon = factory_metadata_taxon_model(tenant.id) assert metatada_taxon_service.get_by_tenant(tenant.id) is not None - response = client.get(f'/api/tenants/{tenant.short_name}/metadata/taxa', - headers=headers, content_type=ContentType.JSON.value) + response = client.get(TENANT_TAXA_ENDPOINT, headers=headers, + content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.OK metadata_taxon_list = response.json assert len(metadata_taxon_list) == 1, metadata_taxon_list @@ -46,7 +49,7 @@ def test_get_taxon_by_id(client, jwt, session): tenant, headers = factory_taxon_requirements(jwt) metadata_taxon = factory_metadata_taxon_model(tenant.id) assert metatada_taxon_service.get_by_id(metadata_taxon.id) is not None - response = client.get(f'/api/tenants/{tenant.short_name}/metadata/taxon/{metadata_taxon.id}', + response = client.get(f'{TAXON_ENDPOINT}/{metadata_taxon.id}', headers=headers, content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.OK metadata_taxon = response.json @@ -56,14 +59,16 @@ def test_get_taxon_by_id(client, jwt, session): def test_add_metadata_taxon(client, jwt, session): """Test that metadata taxon can be added to a tenant.""" - tenant, headers = factory_taxon_requirements(jwt) - response = client.post(f'/api/tenants/{tenant.short_name}/metadata/taxa', + _, headers = factory_taxon_requirements(jwt) + response = client.post(TENANT_TAXA_ENDPOINT, headers=headers, - data=json.dumps(TestEngagementMetadataTaxonInfo.taxon1), + data=json.dumps( + TestEngagementMetadataTaxonInfo.taxon1), content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.CREATED assert response.json.get('id') is not None - assert response.json.get('name') == TestEngagementMetadataTaxonInfo.taxon1['name'] + assert response.json.get( + 'name') == TestEngagementMetadataTaxonInfo.taxon1['name'] assert MetadataTaxonService.get_by_id(response.json.get('id')) is not None @@ -72,26 +77,45 @@ def test_update_metadata_taxon(client, jwt, session): tenant, headers = factory_taxon_requirements(jwt) taxon = factory_metadata_taxon_model(tenant.id) data = TestEngagementMetadataTaxonInfo.taxon2 - del data['tenant_id'] - del data['position'] - response = client.patch(f'/api/tenants/{tenant.short_name}/metadata/taxon/{taxon.id}', + response = client.patch(f'{TAXON_ENDPOINT}/{taxon.id}', headers=headers, data=json.dumps(data), content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.OK assert response.json.get('id') is not None, response.json - assert response.json.get('name') == TestEngagementMetadataTaxonInfo.taxon2['name'] + assert response.json.get( + 'name') == TestEngagementMetadataTaxonInfo.taxon2['name'] assert MetadataTaxonService.get_by_id(response.json.get('id')) is not None +def test_update_taxon_preset_values(client, jwt, session): + """Test that taxon preset values can be updated.""" + tenant, headers = factory_taxon_requirements(jwt) + taxon = factory_metadata_taxon_model(tenant.id) + assert taxon.preset_values == [] + response = client.patch(f'{TAXON_ENDPOINT}/{taxon.id}', + headers=headers, + data=json.dumps( + {'preset_values': ['value1', 'value2']}), + content_type=ContentType.JSON.value) + assert response.status_code == HTTPStatus.OK, response.text + assert response.json.get('id') is not None, response.json + assert response.json.get('preset_values') == ['value1', 'value2'] + assert MetadataTaxon.query.get(response.json.get( + 'id')).preset_values == ['value1', 'value2'] + + def test_reorder_tenant_metadata_taxa(client, jwt, session): """Test that metadata taxa can be reordered.""" tenant, headers = factory_taxon_requirements(jwt) - taxon1 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) - taxon2 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) - taxon3 = factory_metadata_taxon_model(tenant.id, TestEngagementMetadataTaxonInfo.taxon3) + taxon1 = factory_metadata_taxon_model( + tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + taxon2 = factory_metadata_taxon_model( + tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + taxon3 = factory_metadata_taxon_model( + tenant.id, TestEngagementMetadataTaxonInfo.taxon3) assert all([taxon1 is not None, taxon2 is not None, taxon3 is not None]) - response = client.patch(f'/api/tenants/{tenant.short_name}/metadata/taxa', + response = client.patch(f'{TENANT_TAXA_ENDPOINT}', headers=headers, data=json.dumps({'taxon_ids': [ taxon3.id, taxon1.id, taxon2.id @@ -112,7 +136,7 @@ def test_delete_taxon(client, jwt, session): tenant, headers = factory_taxon_requirements(jwt) taxon = factory_metadata_taxon_model(tenant.id) assert metatada_taxon_service.get_by_id(taxon.id) is not None - response = client.delete(f'/api/tenants/{tenant.short_name}/metadata/taxon/{taxon.id}', + response = client.delete(f'{TAXON_ENDPOINT}/{taxon.id}', headers=headers, content_type=ContentType.JSON.value) assert response.status_code == HTTPStatus.NO_CONTENT diff --git a/met-api/tests/unit/models/test_engagement_metadata.py b/met-api/tests/unit/models/test_engagement_metadata.py index 6dd2a1a44..7f72de2ec 100644 --- a/met-api/tests/unit/models/test_engagement_metadata.py +++ b/met-api/tests/unit/models/test_engagement_metadata.py @@ -11,10 +11,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Tests for the Org model. - -Test suite to ensure that the Engagement model routines are working as expected. -""" +"""Tests for the EngagementMetadata model.""" from faker import Faker @@ -36,7 +33,8 @@ def test_create_basic_engagement_metadata(session): }) assert engagement_metadata.id is not None, ( 'Engagement Metadata ID is missing') - engagement_metadata_existing = EngagementMetadata.find_by_id(engagement_metadata.id) + engagement_metadata_existing = EngagementMetadata.find_by_id( + engagement_metadata.id) assert engagement_metadata.value == engagement_metadata_existing.value, ( 'Engagement Metadata value is missing or incorrect') @@ -76,10 +74,11 @@ def test_create_engagement_metadata(session): 'value': fake.text(max_nb_chars=256) }) assert engagement_metadata.id is not None, 'Engagement Metadata ID is missing' - engagement_metadata_existing = EngagementMetadata.find_by_id(engagement_metadata.id) + engagement_metadata_existing = EngagementMetadata.find_by_id( + engagement_metadata.id) assert engagement_metadata.value == engagement_metadata_existing.value, ( - 'Engagement Metadata value is missing or incorrect') + 'Engagement Metadata value is missing or incorrect') assert engagement_metadata.taxon_id == engagement_metadata_existing.taxon_id, ( - 'Engagement Metadata taxon ID is missing or incorrect') + 'Engagement Metadata taxon ID is missing or incorrect') assert engagement_metadata.engagement_id == engagement_metadata_existing.engagement_id, ( - 'Engagement Metadata engagement ID is missing or incorrect') + 'Engagement Metadata engagement ID is missing or incorrect') diff --git a/met-api/tests/unit/models/test_metadata_taxa.py b/met-api/tests/unit/models/test_metadata_taxa.py index adcd99d8a..3f561d6a0 100644 --- a/met-api/tests/unit/models/test_metadata_taxa.py +++ b/met-api/tests/unit/models/test_metadata_taxa.py @@ -51,7 +51,8 @@ def test_delete_taxon(session): taxon1.delete() assert taxon3.position == 1, 'Taxon 3 should be in the only position' taxon3.delete() - assert MetadataTaxon.query.get(taxon1.id) is None, 'The taxon should not exist' + assert MetadataTaxon.query.get( + taxon1.id) is None, 'The taxon should not exist' def check_taxon_order(session, taxa, expected_order): @@ -70,7 +71,8 @@ def test_move_taxon_to_position(session): test_taxa = [taxon1, taxon2, taxon3] session.add_all(test_taxa) session.commit() - assert all([taxon.id is not None for taxon in test_taxa]), 'Taxon ID is missing' + assert all([taxon.id is not None for taxon in test_taxa] + ), 'Taxon ID is missing' # Check initial order check_taxon_order(session, test_taxa, [1, 2, 3]) taxon1.move_to_position(3) @@ -82,3 +84,18 @@ def test_move_taxon_to_position(session): check_taxon_order(session, test_taxa, [1, 3, 2]) taxon2.move_to_position(2) check_taxon_order(session, test_taxa, [1, 2, 3]) + + +def test_metadata_taxon_presets(session): + """Assert that metadata taxon preset values can be edited.""" + taxon = factory_metadata_taxon_model() + taxon.save() + assert taxon.id is not None + assert taxon.preset_values == [] + taxon.preset_values = ['foo', 'bar', 'baz'] + taxon_existing = MetadataTaxon.find_by_id(taxon.id) + assert taxon_existing.preset_values[0] == 'foo' + assert taxon_existing.preset_values == ['foo', 'bar', 'baz'] + taxon.preset_values = ['foo', 'baz'] + taxon_existing = MetadataTaxon.find_by_id(taxon.id) + assert taxon_existing.preset_values == ['foo', 'baz'] diff --git a/met-api/tests/unit/services/test_engagement_metadata.py b/met-api/tests/unit/services/test_engagement_metadata.py index e2919dd59..b7c8b4f03 100644 --- a/met-api/tests/unit/services/test_engagement_metadata.py +++ b/met-api/tests/unit/services/test_engagement_metadata.py @@ -35,7 +35,8 @@ def test_get_engagement_metadata(session): taxon_id=taxon.id, engagement_id=engagement.id, value=TestEngagementMetadataInfo.metadata1['value'] ) - existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) assert any(meta['id'] == eng_meta['id'] for meta in existing_metadata) @@ -49,8 +50,10 @@ def test_get_engagement_metadata_by_id(session): value=TestEngagementMetadataInfo.metadata1['value'] ) existing_metadata = engagement_metadata_service.get(eng_meta['id']) - assert existing_metadata.get('id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG - assert existing_metadata.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG + assert existing_metadata.get( + 'id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG + assert existing_metadata.get( + 'taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG def test_create_engagement_metadata(session): @@ -65,22 +68,10 @@ def test_create_engagement_metadata(session): assert eng_meta.get('id') is not None, ENGAGEMENT_ID_INCORRECT_MSG assert eng_meta.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG existing_metadata = engagement_metadata_service.get(eng_meta['id']) - assert existing_metadata.get('id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG - assert existing_metadata.get('taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG - - -def test_default_engagement_metadata(session): - """Assert that engagement metadata can be created with default value.""" - taxon, engagement, tenant, _ = factory_metadata_requirements() - assert taxon.id is not None, 'Taxon ID is missing' - assert engagement.id is not None, 'Engagement ID is missing' - taxon.default_value = 'default value' - eng_meta: list = engagement_metadata_service.create_defaults( - engagement_id=engagement.id, tenant_id=tenant.id - ) - assert len(eng_meta) == 1, 'Default engagement metadata not created' - assert eng_meta[0].get('id') is not None, ENGAGEMENT_ID_INCORRECT_MSG - assert eng_meta[0].get('value') == 'default value', 'Default value is incorrect' + assert existing_metadata.get( + 'id') == eng_meta['id'], ENGAGEMENT_ID_INCORRECT_MSG + assert existing_metadata.get( + 'taxon_id') == taxon.id, TAXON_ID_INCORRECT_MSG def test_update_engagement_metadata(session): @@ -95,14 +86,48 @@ def test_update_engagement_metadata(session): 'taxon_id': taxon.id, 'value': old_value }) - existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) assert existing_metadata - metadata_updated = engagement_metadata_service.update(eng_meta.id, new_value) + metadata_updated = engagement_metadata_service.update( + eng_meta.id, new_value) assert metadata_updated['value'] == new_value - existing_metadata2 = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata2 = engagement_metadata_service.get_by_engagement( + engagement.id) assert any(meta['value'] == new_value for meta in existing_metadata2) +def test_bulk_update_engagement_metadata(session): + """Assert that engagement metadata can be updated in bulk.""" + taxon, engagement, _, _ = factory_metadata_requirements() + for i in range(4): + factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + 'value': f'old value {i}' + }) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) + # The initial data is created with 4 metadata entries + assert len(existing_metadata) == 4 + new_values = [f'new value {i}' for i in range(3)] + metadata_updated = engagement_metadata_service.update_by_taxon( + engagement.id, taxon.id, new_values) + # Check that the extra metadata entry was removed + assert len(metadata_updated) == 3 + # and that the values were updated + for i, meta in enumerate(metadata_updated): + assert meta['value'] == new_values[i] + new_values_2 = [f'newer value {i}' for i in range(5)] + metadata_updated_2 = engagement_metadata_service.update_by_taxon( + engagement.id, taxon.id, new_values_2) + # now the array should be longer + assert len(metadata_updated_2) == 5 + # and the values should be updated again + for i, meta in enumerate(metadata_updated_2): + assert meta['value'] == new_values_2[i], f'{meta}, {new_values_2[i]}' + + def test_delete_engagement_metadata(session): """Assert that engagement metadata can be deleted.""" taxon, engagement, _, _ = factory_metadata_requirements() @@ -110,8 +135,10 @@ def test_delete_engagement_metadata(session): 'engagement_id': engagement.id, 'taxon_id': taxon.id, }) - existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) assert any(em['id'] == eng_meta.id for em in existing_metadata) engagement_metadata_service.delete(eng_meta.id) - existing_metadata = engagement_metadata_service.get_by_engagement(engagement.id) + existing_metadata = engagement_metadata_service.get_by_engagement( + engagement.id) assert not any(em['id'] == eng_meta.id for em in existing_metadata) diff --git a/met-api/tests/unit/services/test_metadata_taxa.py b/met-api/tests/unit/services/test_metadata_taxa.py index 54bdae840..7c87ca7eb 100644 --- a/met-api/tests/unit/services/test_metadata_taxa.py +++ b/met-api/tests/unit/services/test_metadata_taxa.py @@ -61,8 +61,10 @@ def test_get_by_tenant(session): tenant, _ = factory_taxon_requirements() taxon_service = MetadataTaxonService() # Create multiple taxa for the tenant - taxon1 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) - taxon2 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + taxon1 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + taxon2 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.taxon2) # Retrieve taxa for tenant and assert tenant_taxa = taxon_service.get_by_tenant(tenant.id) assert taxon1 in tenant_taxa and taxon2 in tenant_taxa @@ -94,6 +96,27 @@ def test_update_taxon(session): assert taxon_updated['name'] == 'Updated Taxon' +def test_modify_presets(session): + """Assert that taxon preset values can be updated.""" + taxon_service = MetadataTaxonService() + tenant, _ = factory_taxon_requirements() + taxon = taxon_service.create(tenant.id, + TestEngagementMetadataTaxonInfo.taxon1) + assert taxon.get('id') is not None + assert taxon['preset_values'] == [] + taxon_existing = taxon_service.get_by_id(taxon['id']) + assert taxon_existing is not None + assert taxon['preset_values'] == taxon_existing['preset_values'] + taxon['preset_values'] = ['foo', 'bar', 'baz'] + taxon_updated = taxon_service.update( + taxon['id'], {'preset_values': ['foo', 'bar', 'baz']}) + assert taxon_updated['preset_values'] == ['foo', 'bar', 'baz'] + taxon['preset_values'] = ['foo', 'baz'] + taxon_updated = taxon_service.update( + taxon['id'], {'preset_values': ['foo', 'baz']}) + assert taxon_updated['preset_values'] == ['foo', 'baz'] + + def test_delete_taxon(session): """Assert that taxa can be deleted.""" taxon_service = MetadataTaxonService() @@ -110,8 +133,10 @@ def test_reorder_tenant(session): tenant, _ = factory_taxon_requirements() taxon_service = MetadataTaxonService() # Create multiple taxa - taxon1 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon2) - taxon2 = taxon_service.create(tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + taxon1 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.taxon2) + taxon2 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.taxon1) assert taxon1['position'] == 1 and taxon2['position'] == 2 # Reorder taxa new_order = [taxon2['id'], taxon1['id']] diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 1b26c8f8f..2349521c9 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -287,44 +287,33 @@ class TestEngagementMetadataTaxonInfo(dict, Enum): taxon1 = { 'name': fake.name(), 'description': fake.text(max_nb_chars=256), - 'data_type': 'string', + 'data_type': 'text', 'freeform': True, 'one_per_engagement': False, - 'default_value': None, - 'position': 1 } taxon2 = { 'name': fake.name(), 'description': fake.text(max_nb_chars=256), - 'tenant_id': 1, - 'data_type': 'url', + 'data_type': 'long_text', 'freeform': True, 'one_per_engagement': False, - 'default_value': None, - 'position': 2 } taxon3 = { 'name': fake.name(), 'description': fake.text(max_nb_chars=256), - 'tenant_id': 1, - 'data_type': 'string', + 'data_type': 'text', 'freeform': False, 'one_per_engagement': True, - 'default_value': None, - 'position': 3 } taxon4 = { 'name': fake.name(), 'description': fake.text(max_nb_chars=256), - 'tenant_id': 1, - 'data_type': 'string', + 'data_type': 'url', 'freeform': False, 'one_per_engagement': False, - 'default_value': fake.name(), - 'position': 4 } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index bd0cc1e1d..d2c3b43cf 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -22,7 +22,6 @@ from met_api.auth import Auth from met_api.config import get_named_config -from met_api.constants.email_verification import EmailVerificationType from met_api.constants.engagement_status import Status from met_api.constants.widget import WidgetType from met_api.models import Tenant @@ -56,6 +55,7 @@ from met_api.models.widget_video import WidgetVideo as WidgetVideoModel from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import MembershipStatus +from met_api.constants.email_verification import EmailVerificationType from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, TestEngagementSlugInfo, TestEngagementTranslationInfo, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, @@ -244,7 +244,6 @@ def factory_metadata_taxon_model( description=taxon_info.get('description'), freeform=taxon_info.get('freeform'), data_type=taxon_info.get('data_type'), - default_value=taxon_info.get('default_value'), one_per_engagement=taxon_info.get('one_per_engagement'), position=taxon_info.get('position'), ) @@ -609,7 +608,8 @@ def factory_survey_translation_and_engagement_model(): survey_id=survey.id, language_id=lang.id, name=TestSurveyTranslationInfo.survey_translation1.get('name'), - form_json=TestSurveyTranslationInfo.survey_translation1.get('form_json'), + form_json=TestSurveyTranslationInfo.survey_translation1.get( + 'form_json'), ) translation.save() return translation, survey, lang diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 80e30cfe6..1d438ced2 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -11,16 +11,18 @@ const Endpoints = { GET_BY_ENG: `${AppConfig.apiUrl}/engagements/engagement_id/metadata`, GET_BY_KEY: `${AppConfig.apiUrl}/engagements/engagement_id/tenant/tenant_id/key`, CREATE: `${AppConfig.apiUrl}/engagements/engagement_id/metadata`, + BULK_UPDATE: `${AppConfig.apiUrl}/engagements/engagement_id/metadata`, UPDATE: `${AppConfig.apiUrl}/engagements/engagement_id/metadata/key`, DELETE: `${AppConfig.apiUrl}/engagements/engagement_id/metadata/key`, }, MetadataTaxa: { - GET_BY_TENANT: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/`, - REORDER: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/`, - CREATE: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/tenant_id`, - GET: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/taxon_id`, - UPDATE: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/taxon_id`, - DELETE: `${AppConfig.apiUrl}/tenants/tenant_id/taxa/taxon_id`, + GET_BY_TENANT: `${AppConfig.apiUrl}/engagement_metadata/taxa`, + REORDER: `${AppConfig.apiUrl}/engagement_metadata/taxa`, + CREATE: `${AppConfig.apiUrl}/engagement_metadata/taxa`, + GET: `${AppConfig.apiUrl}/engagement_metadata/taxon/taxon_id`, + UPDATE: `${AppConfig.apiUrl}/engagement_metadata/taxon/taxon_id`, + DELETE: `${AppConfig.apiUrl}/engagement_metadata/taxon/taxon_id`, + PRESET_VALUES: `${AppConfig.apiUrl}/engagement_metadata/taxon/taxon_id/preset_values`, }, EngagementSettings: { CREATE: `${AppConfig.apiUrl}/engagementsettings/`, diff --git a/met-web/src/components/common/Dragdrop/index.tsx b/met-web/src/components/common/Dragdrop/index.tsx index d9fb9c6c9..4eaad841d 100644 --- a/met-web/src/components/common/Dragdrop/index.tsx +++ b/met-web/src/components/common/Dragdrop/index.tsx @@ -25,9 +25,9 @@ interface MetDraggableProps { index: number; children: React.ReactNode; draggableId: string; - marginBottom?: string | number; + sx?: object; } -export const MetDraggable = ({ children, draggableId, index, marginBottom }: MetDraggableProps) => { +export const MetDraggable = ({ children, draggableId, index, sx }: MetDraggableProps) => { return ( {(provided: DraggableProvided) => ( @@ -36,8 +36,9 @@ export const MetDraggable = ({ children, draggableId, index, marginBottom }: Met {...provided.draggableProps} {...provided.dragHandleProps} sx={{ + marginBottom: '1em', ...provided.draggableProps.style, - marginBottom: marginBottom || '1em', + ...sx, }} > {children} diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index 917154df9..ab320d7d7 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,14 +1,9 @@ -import React, { createContext, useState, useEffect } from 'react'; +import React, { createContext, useState, useEffect, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; -import { getEngagementMetadata } from '../../../services/engagementMetadataService'; +import { getEngagementMetadata, getMetadataTaxa } from '../../../services/engagementMetadataService'; import { useNavigate, useParams } from 'react-router-dom'; import { EngagementContext, EngagementForm, EngagementFormUpdate, EngagementParams } from './types'; -import { - createDefaultEngagement, - createDefaultEngagementMetadata, - Engagement, - EngagementMetadata, -} from '../../../models/engagement'; +import { createDefaultEngagement, Engagement, EngagementMetadata, MetadataTaxon } from '../../../models/engagement'; import { saveObject } from 'services/objectStorageService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch, useAppSelector } from 'hooks'; @@ -20,25 +15,26 @@ import { EngagementStatus } from 'constants/engagementStatus'; const CREATE = 'create'; export const ActionContext = createContext({ - // TODO: Reimplement handle*MetadataRequest methods using the new engagement metadata API handleCreateEngagementRequest: (_engagement: EngagementForm): Promise => { return Promise.reject(); }, handleUpdateEngagementRequest: (_engagement: EngagementFormUpdate): Promise => { return Promise.reject(); }, - handleCreateEngagementMetadataRequest: (_engagement: EngagementMetadata): Promise => { - return Promise.reject(); + tenantTaxa: [], + setTenantTaxa: () => { + throw new Error('setTenantTaxa is unimplemented'); }, - handleUpdateEngagementMetadataRequest: (_engagement: EngagementMetadata): Promise => { + setEngagementMetadata() { return Promise.reject(); }, + taxonMetadata: new Map(), isSaving: false, setSaving: () => { /* empty default method */ }, savedEngagement: createDefaultEngagement(), - engagementMetadata: createDefaultEngagementMetadata(), + engagementMetadata: [], engagementId: CREATE, loadingSavedEngagement: true, handleAddBannerImage: (_files: File[]) => { @@ -68,13 +64,13 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { const [loadingSavedEngagement, setLoadingSavedEngagement] = useState(true); const [loadingAuthorization, setLoadingAuthorization] = useState(true); + const [tenantTaxa, setTenantTaxa] = useState([]); const [savedEngagement, setSavedEngagement] = useState(createDefaultEngagement()); const [isNewEngagement, setIsNewEngagement] = useState(!savedEngagement.id); - const [engagementMetadata, setEngagementMetadata] = useState({ - ...createDefaultEngagementMetadata(), - }); + const [engagementMetadata, setEngagementMetadata] = useState([]); const [bannerImage, setBannerImage] = useState(); const [savedBannerImageFileName, setSavedBannerImageFileName] = useState(''); + const isCreate = window.location.pathname.includes(CREATE); const handleAddBannerImage = (files: File[]) => { @@ -110,15 +106,37 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { if (isCreate) { return; } - try { const engagementMetaData = await getEngagementMetadata(Number(engagementId)); setEngagementMetadata(engagementMetaData); + const taxaData = await getMetadataTaxa(); + engagementMetadata.forEach((metadata) => { + const taxon = taxaData[metadata.taxon_id]; + if (taxon) { + if (taxon.entries === undefined) { + taxon.entries = []; + } + taxon.entries.push(metadata); + } + }); + setTenantTaxa(Object.values(taxaData)); } catch (err) { console.log(err); dispatch(openNotification({ severity: 'error', text: 'Error Fetching Engagement Metadata' })); } }; + + const taxonMetadata = useMemo(() => { + const taxonMetadataMap = new Map(); + engagementMetadata.forEach((metadata) => { + if (!taxonMetadataMap.has(metadata.taxon_id)) { + taxonMetadataMap.set(metadata.taxon_id, []); + } + taxonMetadataMap.get(metadata.taxon_id)?.push(metadata.value); + }); + return taxonMetadataMap; + }, [engagementMetadata]); + const setEngagement = (engagement: Engagement) => { setSavedEngagement({ ...engagement }); setIsNewEngagement(!savedEngagement.id); @@ -236,22 +254,13 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { } }; - // TODO: replace these stubs with new handlers - const handleCreateEngagementMetadataRequest = async (): Promise => { - return Promise.reject(); - }; - - const handleUpdateEngagementMetadataRequest = async (): Promise => { - return Promise.reject(); - }; - return ( { handleAddBannerImage, fetchEngagement, fetchEngagementMetadata, + setEngagementMetadata, + taxonMetadata, loadingAuthorization, isNewEngagement, setIsNewEngagement, diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx index 6597e6942..13c723dab 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx @@ -2,14 +2,14 @@ import React, { useContext } from 'react'; import { Divider, Grid, Box } from '@mui/material'; import { MetPaper, PrimaryButton, SecondaryButton } from 'components/common'; import ConsentMessage from './ConsentMessage'; -import EngagementInformation from './EngagementInformation'; +import EngagementMetadata from './Metadata/EngagementMetadata'; import { EngagementTabsContext } from '../EngagementTabsContext'; import { ActionContext } from '../../ActionContext'; const AdditionalTabContent = () => { const { isSaving } = useContext(ActionContext); - const { handleSaveAndContinueEngagement, handleSaveAndExitEngagement, handlePreviewEngagement } = + const { handleSaveAndContinueEngagement, handleSaveAndExitEngagement, handlePreviewEngagement, metadataFormRef } = useContext(EngagementTabsContext); return ( @@ -23,7 +23,7 @@ const AdditionalTabContent = () => { sx={{ padding: '2em' }} > - + diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/EngagementInformation.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/EngagementInformation.tsx deleted file mode 100644 index e348700ab..000000000 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/EngagementInformation.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import React, { useContext } from 'react'; -import { Grid, MenuItem, TextField, Select, SelectChangeEvent } from '@mui/material'; -import { MetLabel, MetHeader4 } from 'components/common'; -import { EngagementTabsContext } from '../EngagementTabsContext'; -import { AppConfig } from 'config'; - -const EngagementInformation = () => { - const { engagementFormData, setEngagementFormData } = useContext(EngagementTabsContext); - - const handleChange = (e: React.ChangeEvent) => { - setEngagementFormData({ - ...engagementFormData, - [e.target.name]: e.target.value, - }); - }; - - const handleChangeMetadata = (e: React.ChangeEvent | SelectChangeEvent) => { - setEngagementFormData({ - ...engagementFormData, - }); - }; - - return ( - - - Engagement Metadata - - - ); -}; - -export default EngagementInformation; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx new file mode 100644 index 000000000..8de662c08 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -0,0 +1,201 @@ +import React, { forwardRef, useContext, useEffect, useImperativeHandle, useMemo } from 'react'; +import { Grid, Divider, Typography, Avatar, Chip } from '@mui/material'; +import { useForm, Controller, SubmitHandler } from 'react-hook-form'; +import { MetHeader4 } from 'components/common'; +import { EngagementTabsContext } from '../../EngagementTabsContext'; +import { EngagementMetadata as EngagementMetadataModel, MetadataTaxon } from 'models/engagement'; +import { TaxonTypes } from 'components/metadataManagement/TaxonTypes'; +import { TaxonFormValues } from 'components/metadataManagement/types'; +import { useTheme } from '@mui/material/styles'; +import { ActionContext } from '../../../ActionContext'; +import * as yup from 'yup'; +import { DefaultAutocomplete } from './TaxonInputComponents'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { bulkPatchEngagementMetadata } from 'services/engagementMetadataService'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useAppDispatch } from 'hooks'; + +const EngagementMetadata = forwardRef((_props, ref) => { + const { metadataFormRef } = useContext(EngagementTabsContext); + const { tenantTaxa, engagementMetadata, setEngagementMetadata, engagementId, taxonMetadata } = + useContext(ActionContext); + const dispatch = useAppDispatch(); + + const validationSchema = useMemo(() => { + const schemaShape: { [key: string]: yup.AnySchema } = tenantTaxa.reduce((acc, taxon) => { + const taxonType = TaxonTypes[taxon.data_type as keyof typeof TaxonTypes]; + if (taxonType.yupValidator) { + if (taxon.one_per_engagement) { + acc[taxon.id.toString()] = taxonType.yupValidator.nullable(); + } else { + acc[taxon.id.toString()] = yup.array().of(taxonType.yupValidator).nullable(); + } + } + return acc; + }, {} as { [key: string]: yup.AnySchema }); // Add index signature to the initial value of acc + return yup.object().shape(schemaShape); + }, [tenantTaxa]); + + const initialValues = useMemo(() => { + // Use tenantTaxa and taxonMetadata to find initial values + // tenantTaxa is a list of Taxon objects and taxonMetadata is a map of taxonIds to values + return tenantTaxa.reduce((values: TaxonFormValues, taxon) => { + values[taxon.id.toString()] = taxonMetadata.get(taxon.id) ?? (taxon.one_per_engagement ? null : []); + if (taxon.one_per_engagement) { + values[taxon.id.toString()] = values[taxon.id.toString()]?.[0] ?? null; + } + return values; // Return the updated values object + }, {}); + }, [tenantTaxa, taxonMetadata]); + + useEffect(() => { + // Reset the form when the initialValues change + // (e.g. when the engagement is updated from the server) + reset(initialValues); + }, [initialValues]); + // Initialize react-hook-form + const { + control, + handleSubmit, + setValue, + reset, + getValues, + trigger, + watch, + formState: { errors }, + } = useForm({ + defaultValues: initialValues, + resolver: yupResolver(validationSchema), + }); + + const cleanArray = (arr: string[]) => arr.map((v) => v.toString().trim()).filter(Boolean); + + const onSubmit: SubmitHandler = async () => { + const data = getValues(); + const updatedEntries = new Map(); + for (const [id, value] of Object.entries(data)) { + const taxonId = Number(id); + const taxonMeta = taxonMetadata.get(taxonId) ?? []; + let taxonValue = value ?? []; + // Normalize and clean the arrays + const comparator = (a: string, b: string) => a.localeCompare(b); + taxonValue = cleanArray(Array.isArray(taxonValue) ? taxonValue : [taxonValue]).sort(comparator); + const normalizedTaxonMeta = cleanArray(taxonMeta).sort(comparator); + if (JSON.stringify(taxonValue) === JSON.stringify(normalizedTaxonMeta)) continue; + // If we reach here, arrays are not equal, proceed with update + try { + const updatedMetadata = await bulkPatchEngagementMetadata(taxonId, Number(engagementId), taxonValue); + updatedEntries.set(taxonId, updatedMetadata); + } catch (err) { + console.error(err); + dispatch(openNotification({ severity: 'error', text: 'Error Updating Taxon Metadata' })); + } + } + // filter out all old data with taxon IDs that were updated + const result: EngagementMetadataModel[] = engagementMetadata + .filter((metadata) => !updatedEntries.has(metadata.taxon_id)) + // and add the new updated entries back in + .concat(...updatedEntries.values()); + setEngagementMetadata(result); + }; + + useImperativeHandle(ref, () => ({ + submitForm: async () => { + // validate the form + await handleSubmit(onSubmit)(); // manually trigger form submission + // After submission, check if there are any errors + const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); + return isValid; + }, + })); + + const TaxonTile = (taxon: MetadataTaxon, index: number) => { + const taxonType = TaxonTypes[taxon.data_type as keyof typeof TaxonTypes]; + const theme = useTheme(); + const taxonValue = watch(taxon.id.toString()); + const TaxonInput = taxonType.customInput ?? DefaultAutocomplete; + return ( + + + + + + + + {taxon.name} + + + + {taxon.description} + + {taxon.one_per_engagement ? ' ' : ' (Enter one or more)'} + {taxon.freeform ? '' : ' (Options limited)'} + + + + + {taxonType.externalResource && taxon.one_per_engagement && ( + + } + onClick={() => { + taxonType.externalResource && + window.open(taxonType.externalResource(taxonValue), '_blank'); + }} + /> + + )} + + TaxonInput({ field, taxon, taxonType, setValue, errors, trigger })} + /> + + + ); + }; + + return ( + +
+ + Engagement Metadata + + + {tenantTaxa.map(TaxonTile)} + +
+
+ ); +}); + +export default EngagementMetadata; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx new file mode 100644 index 000000000..4f6df57d0 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -0,0 +1,188 @@ +import { GenericInputProps as TaxonInputProps } from '../../../../../metadataManagement/types'; +import { DatePicker, TimePicker, DateTimePicker, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import React, { ReactElement, useState } from 'react'; +import { + FormControlLabel, + Switch, + Typography, + TextField, + Autocomplete, + Chip, + Stack, + AutocompleteRenderGetTagProps, +} from '@mui/material'; +import { FieldError } from 'react-hook-form'; + +export const DefaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, trigger }: TaxonInputProps) => { + const [inputValue, setInputValue] = useState(''); + + const valueErrors = (errors[taxon.id.toString()] as unknown as Array | FieldError) ?? []; + const errorIndices = new Set(); + let errorMessage: string | ReactElement[] | undefined; + if (taxon.one_per_engagement) { + if (Array.isArray(valueErrors)) { + errorMessage = valueErrors[0]?.message; + } else { + errorMessage = (valueErrors as FieldError)?.message; + } + } else { + errorMessage = (valueErrors as Array)?.map((error: FieldError, index: number) => { + errorIndices.add(index); + return ( + + Entry #{index + 1}: {error.message} +
+
+ ); + }); + } + + const handleChipClick = (option: string) => () => { + if (taxonType.externalResource) { + window.open(taxonType.externalResource(option), '_blank'); + } + }; + const renderTags = (value: string[], getTagProps: AutocompleteRenderGetTagProps) => { + // Define the handleChipClick function + return ( + + {value.map((option, index) => ( + : undefined} + /> + ))} + + ); + }; + return ( + { + if (taxon.one_per_engagement) { + setValue(taxon.id.toString(), newValue); + field.onChange(newValue); + } else { + if (!Array.isArray(newValue)) newValue = []; + newValue = newValue ?? [...field.value, inputValue]; + newValue = newValue.map((v: string) => v.trim()).filter(Boolean); + field.onChange(newValue); + setInputValue(''); // Clear the input value after change + } + trigger(taxon.id.toString()); + }} + onInputChange={(_event, newInputValue) => { + setInputValue(newInputValue); + if (taxon.one_per_engagement) { + field.onChange(newInputValue); + } + }} + onBlur={() => { + trigger(taxon.id.toString()); + }} + getOptionLabel={(option) => option.toString()} + // always show the dropdown handle when there are options + forcePopupIcon={(taxon.preset_values?.length ?? 0) > 0} + renderTags={renderTags} + renderInput={(params) => ( + + )} + /> + ); +}; + +export const taxonSwitch = ({ taxon, field, setValue, errors }: TaxonInputProps) => ( + { + setValue(taxon.id.toString(), e.target.checked); + }} + inputProps={{ 'aria-label': 'controlled' }} + /> + } + label={ + <> + {taxon.name} + {errors[taxon.id.toString()] && ( + + {errors[taxon.id.toString()]?.message?.toString() ?? ''} + + )} + + } + color={errors[taxon.id.toString()] ? 'error' : 'primary'} + /> +); + +// Unified component for different types of pickers +export const PickerTypes = { + DATE: 'DATE', + TIME: 'TIME', + DATETIME: 'DATETIME', +}; + +export const inputFormats = { + [PickerTypes.DATE]: 'yyyy-MM-dd', + [PickerTypes.TIME]: 'hh:mm a', + [PickerTypes.DATETIME]: 'yyyy-MM-dd hh:mm a', +}; + +export const TaxonPicker = ({ + taxon, + field, + setValue, + errors, + pickerType, +}: TaxonInputProps & { pickerType: string }) => { + const PickerComponent = { + [PickerTypes.DATE]: DatePicker, + [PickerTypes.TIME]: TimePicker, + [PickerTypes.DATETIME]: DateTimePicker, + }[pickerType]; + + return ( + + { + setValue(taxon.id.toString(), e); + }} + PaperProps={{ sx: { background: '#eee' } }} + renderInput={(params) => ( + + )} + /> + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index 342728b02..b3c97ab9a 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useContext, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { SubmissionStatusTypes, SUBMISSION_STATUS } from 'constants/engagementStatus'; import { User } from 'models/user'; @@ -72,6 +72,7 @@ const initialFormError = { export interface EngagementTabsContextState { engagementFormData: EngagementFormData; setEngagementFormData: React.Dispatch>; + metadataFormRef: React.RefObject | null; handleSaveAndContinueEngagement: () => Promise; handlePreviewEngagement: () => Promise; handleSaveAndExitEngagement: () => Promise; @@ -108,6 +109,7 @@ export const EngagementTabsContext = createContext({ setEngagementFormData: () => { throw new Error('setEngagementFormData is unimplemented'); }, + metadataFormRef: null, handleSaveAndContinueEngagement: async () => { /* empty default method for engagement save and continue */ }, @@ -193,6 +195,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re const [richContent, setRichContent] = useState(savedEngagement?.rich_content || ''); const [richConsentMessage, setRichConsentMessage] = useState(savedEngagement?.consent_message || ''); const [engagementFormError, setEngagementFormError] = useState(initialFormError); + const metadataFormRef = useRef(null); // Survey block const [surveyBlockText, setSurveyBlockText] = useState<{ [key in SubmissionStatusTypes]: string }>({ @@ -378,6 +381,19 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re return Object.values(errors).some((isError: unknown) => isError); }; + const handleSaveEngagementMetadata = async () => { + const result = await metadataFormRef.current?.submitForm(); + if (!result) { + dispatch( + openNotification({ + severity: 'error', + text: 'Error saving metadata: Please correct the highlighted fields and try again.', + }), + ); + } + return result; + }; + const handleSaveAndContinueEngagement = async () => { const hasErrors = validateForm(); @@ -402,6 +418,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re if (!isNewEngagement) { await updateEngagementSettings(sendReport); await handleSaveSlug(slug); + await handleSaveEngagementMetadata(); } return engagement; @@ -439,6 +456,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re value={{ engagementFormData, setEngagementFormData, + metadataFormRef, handleSaveAndContinueEngagement, handlePreviewEngagement, handleSaveAndExitEngagement, diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index 29ae16695..3f3b1c225 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -1,15 +1,18 @@ -import { Engagement, EngagementMetadata } from '../../../models/engagement'; +import React from 'react'; +import { Engagement, EngagementMetadata, MetadataTaxon } from '../../../models/engagement'; import { EngagementStatusBlock } from '../../../models/engagementStatusBlock'; export interface EngagementContext { handleCreateEngagementRequest: (_engagement: EngagementForm) => Promise; handleUpdateEngagementRequest: (_engagement: EngagementFormUpdate) => Promise; - handleCreateEngagementMetadataRequest: (_engagement: EngagementMetadata) => Promise; - handleUpdateEngagementMetadataRequest: (_engagement: EngagementMetadata) => Promise; + setEngagementMetadata: React.Dispatch>; + taxonMetadata: Map; + tenantTaxa: MetadataTaxon[]; + setTenantTaxa: React.Dispatch>; isSaving: boolean; setSaving: React.Dispatch>; savedEngagement: Engagement; - engagementMetadata: EngagementMetadata; + engagementMetadata: EngagementMetadata[]; engagementId: string | undefined; loadingSavedEngagement: boolean; handleAddBannerImage: (_files: File[]) => void; diff --git a/met-web/src/components/engagement/view/ActionContext.tsx b/met-web/src/components/engagement/view/ActionContext.tsx index e1e3f6bd8..a205405c9 100644 --- a/met-web/src/components/engagement/view/ActionContext.tsx +++ b/met-web/src/components/engagement/view/ActionContext.tsx @@ -1,13 +1,7 @@ import React, { createContext, useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { getEngagement, patchEngagement } from '../../../services/engagementService'; -import { getEngagementMetadata } from '../../../services/engagementMetadataService'; -import { - createDefaultEngagement, - createDefaultEngagementMetadata, - Engagement, - EngagementMetadata, -} from '../../../models/engagement'; +import { createDefaultEngagement, Engagement } from '../../../models/engagement'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; import { Widget } from 'models/widget'; @@ -30,10 +24,8 @@ interface UnpublishEngagementParams { export interface EngagementViewContext { savedEngagement: Engagement; - engagementMetadata: EngagementMetadata; isEngagementLoading: boolean; isWidgetsLoading: boolean; - isEngagementMetadataLoading: boolean; scheduleEngagement: (_engagement: EngagementSchedule) => Promise; unpublishEngagement: ({ id, status_id }: UnpublishEngagementParams) => Promise; widgets: Widget[]; @@ -55,10 +47,8 @@ export const ActionContext = createContext({ return Promise.reject(Error('not implemented')); }, savedEngagement: createDefaultEngagement(), - engagementMetadata: createDefaultEngagementMetadata(), isEngagementLoading: true, isWidgetsLoading: true, - isEngagementMetadataLoading: true, widgets: [], mockStatus: SubmissionStatus.Upcoming, updateMockStatus: (status: SubmissionStatus) => { @@ -74,12 +64,10 @@ export const ActionProvider = ({ children }: { children: JSX.Element | JSX.Eleme engagementIdParam ? Number(engagementIdParam) : null, ); const [savedEngagement, setSavedEngagement] = useState(createDefaultEngagement()); - const [engagementMetadata, setEngagementMetadata] = useState(createDefaultEngagementMetadata()); const [mockStatus, setMockStatus] = useState(savedEngagement.submission_status); const [widgets, setWidgets] = useState([]); const [isEngagementLoading, setEngagementLoading] = useState(true); const [isWidgetsLoading, setIsWidgetsLoading] = useState(true); - const [isEngagementMetadataLoading, setIsEngagementMetadataLoading] = useState(true); const [getWidgetsTrigger] = useLazyGetWidgetsQuery(); @@ -192,25 +180,6 @@ export const ActionProvider = ({ children }: { children: JSX.Element | JSX.Eleme } }; - const fetchEngagementMetadata = async () => { - if (!savedEngagement.id) { - return; - } - try { - const result = await getEngagementMetadata(Number(engagementId)); - setEngagementMetadata(result); - setIsEngagementMetadataLoading(false); - } catch (error) { - setIsEngagementMetadataLoading(false); - dispatch( - openNotification({ - severity: 'error', - text: 'Error occurred while fetching Engagement Metadata', - }), - ); - } - }; - const handleFetchEngagementIdBySlug = async () => { if (!slug) { return; @@ -233,19 +202,16 @@ export const ActionProvider = ({ children }: { children: JSX.Element | JSX.Eleme useEffect(() => { fetchWidgets(); - fetchEngagementMetadata(); }, [savedEngagement]); return ( ({ + metadataTaxa: [], + selectedTaxon: null, + isLoading: true, + setSelectedTaxonId: () => { + throw new Error('setSelectedTaxonId called without a provider'); + }, + reorderMetadataTaxa: () => { + return []; + }, + createMetadataTaxon: () => { + throw new Error('createMetadataTaxon called without a provider'); + }, + updateMetadataTaxon: () => { + throw new Error('updateMetadataTaxon called without a provider'); + }, + removeMetadataTaxon: () => { + throw new Error('removeMetadataTaxon called without a provider'); + }, +}); + +const ActionProvider = ({ children }: { children: JSX.Element }) => { + const dispatch = useAppDispatch(); + const [metadataTaxa, setMetadataTaxa] = useState([]); + const [selectedTaxonId, setSelectedTaxonId] = useState(-1); + + const selectedTaxon = metadataTaxa.find((taxon) => taxon.id === selectedTaxonId) || null; + + const fetchMetadataTaxa = async () => { + try { + setMetadataTaxa(await getMetadataTaxa()); + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error while retrieving data.' })); + } + }; + + const createMetadataTaxon = async (taxonData: MetadataTaxonModify) => { + try { + const taxon = await postMetadataTaxon(taxonData); + setMetadataTaxa((prev) => [...prev, taxon]); + return taxon; + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error while creating taxon.' })); + return null; + } + }; + + const updateMetadataTaxon = async (taxonData: MetadataTaxon) => { + try { + const taxon = await patchMetadataTaxon(taxonData.id, taxonData); + setMetadataTaxa((prev) => { + const index = prev.findIndex((t) => t.id === taxon.id); + prev[index] = taxon; + return [...prev]; + }); + return taxon; + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error while saving taxon.' })); + return null; + } + }; + + const removeMetadataTaxon = async (taxonId: number) => { + try { + if (selectedTaxon && selectedTaxon.id === taxonId) { + const nextTaxon = metadataTaxa.find( + (taxon) => + taxon.position === selectedTaxon.position + 1 || taxon.position === selectedTaxon.position - 1, + ); + setSelectedTaxonId(nextTaxon?.id || -1); + } + setMetadataTaxa((prev) => prev.filter((taxon) => taxon.id !== taxonId)); + await deleteMetadataTaxon(taxonId); + setMetadataTaxa(await getMetadataTaxa()); + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error while deleting taxon.' })); + } + }; + + const reorderMetadataTaxa = async (taxonIds: number[]) => { + try { + // Client side reorder to prevent flicker + const orderedTaxa = taxonIds.map((id) => metadataTaxa.find((taxon) => taxon.id === id)); + setMetadataTaxa(orderedTaxa.filter((taxon) => taxon !== undefined) as MetadataTaxon[]); + // Send to API + setMetadataTaxa(await patchMetadataTaxaOrder(taxonIds)); + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error while reordering taxa.' })); + } + }; + + const [isLoading, setLoading] = useState(true); + + useEffect(() => { + async function loadData() { + await fetchMetadataTaxa(); + setLoading(false); + } + loadData(); + }, []); + + return ( + + {children} + + ); +}; + +export default ActionProvider; diff --git a/met-web/src/components/metadataManagement/TaxonCard.tsx b/met-web/src/components/metadataManagement/TaxonCard.tsx new file mode 100644 index 000000000..37c60c230 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonCard.tsx @@ -0,0 +1,284 @@ +import { + Grid, + Paper, + Tooltip, + Chip, + IconButton, + Collapse, + Avatar, + Badge, + Typography, + Skeleton, + Stack, + Divider, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { ExpandMore, DragIndicator, FormatQuote, EditAttributes, InsertDriveFile, FileCopy } from '@mui/icons-material'; +import React from 'react'; +import { MetHeader4 } from 'components/common'; +import { TaxonTypes } from './TaxonTypes'; +import { TaxonCardProps } from './types'; +import { Draggable, DraggableProvided } from '@hello-pangea/dnd'; + +const DetailsRow = ({ name, icon, children }: { name: string; icon: React.ReactNode; children: React.ReactNode }) => { + const theme = useTheme(); + return ( + <> + + + + + + + {icon} + + + + + {children} + + + ); +}; + +export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpand, isSelected, onSelect, index }) => { + const theme = useTheme(); + const cardStyle = isSelected + ? { + backgroundColor: theme.palette.primary.light, + color: theme.palette.primary.contrastText, + border: `1px solid ${theme.palette.primary.dark}`, + } + : { border: '1px solid transparent' }; + + const handleExpand = (clickEvent: React.MouseEvent) => { + clickEvent.stopPropagation(); + onExpand(taxon); + }; + + const taxonType = TaxonTypes[taxon.data_type ?? 'text']; + + const taxonTypeIcon = () => { + return ( + + + + + + + + ); + }; + + return ( + + {(provided: DraggableProvided) => ( + onSelect(taxon)} + component={Paper} + style={{ + display: 'flex', + alignItems: 'center', // Center items vertically + padding: '10px', + width: '100%', + marginBottom: '1em', + cursor: 'pointer', + ...cardStyle, + ...provided.draggableProps.style, + }} + elevation={isSelected ? 3 : 1} + container + direction="row" + justifyContent="space-between" + alignItems="center" + aria-label="A card representing a taxon in the engagement metadata." + role="gridcell" + id={`taxon-${taxon.id}`} + > + + + + + + + + + {taxonTypeIcon()} + + + {taxon.name} + + + + + theme.transitions.create('transform', { + duration: theme.transitions.duration.shortest, + }), + transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', + + color: 'inherit', + }} + size="small" + aria-label="expand" + onClick={handleExpand} + aria-expanded={isExpanded} + aria-controls={`taxon-${taxon.id}-content`} + > + + + + + + + + {/* Description */} + }> + + {taxon.description || 'No description provided.'} + + + + {/* Validation */} + }> + + {taxon.freeform + ? `Any ${taxonType.name.toLowerCase()} value can be added to this field. ` + : 'Users must select from the following options:'} + + {taxon.freeform && (taxon.preset_values?.length ?? 0) > 0 && ( + + These preset values will be offered as suggestions: + + )} + {(taxon.preset_values?.length ?? 0) > 0 && ( + + {taxon.preset_values?.map((chip) => ( + + ))} + + )} + + + {/* Multi-select */} + : } + > + + {taxon.one_per_engagement + ? 'One value per engagement.' + : 'Unlimited values per engagement.'} + + + + + + + )} + + ); +}; + +export default TaxonCard; + +export const TaxonCardSkeleton: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx new file mode 100644 index 000000000..5fdbc01b0 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -0,0 +1,346 @@ +import { + TextField, + Switch, + FormControlLabel, + FormControl, + FormGroup, + Select, + Grid, + InputLabel, + Button, + MenuItem, + Tooltip, + Avatar, + Collapse, + Box, +} from '@mui/material'; +import { Save, Check, Edit, Close, Delete, Error, VerifiedUser, ShieldMoon, Queue, AddBox } from '@mui/icons-material'; +import * as yup from 'yup'; +import React, { useContext, useEffect } from 'react'; +import { MetadataTaxon } from 'models/engagement'; +import { ActionContext } from './ActionContext'; +import { useAppDispatch } from 'hooks'; +import { If, Then, Else } from 'react-if'; +import { TaxonTypes } from './TaxonTypes'; +import { TaxonType } from './types'; +import PresetValuesEditor from './presetFieldsEditor/PresetValuesEditor'; +import { useForm, SubmitHandler, Controller, FormProvider } from 'react-hook-form'; +import { MetHeader3 } from 'components/common'; +import { openNotification } from 'services/notificationService/notificationSlice'; + +const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { + const { setSelectedTaxonId, updateMetadataTaxon, removeMetadataTaxon } = useContext(ActionContext); + const dispatch = useAppDispatch(); + const methods = useForm({ + defaultValues: { + name: taxon.name, + description: taxon.description ?? '', + freeform: taxon.freeform, + one_per_engagement: taxon.one_per_engagement, + data_type: taxon.data_type, + preset_values: taxon.preset_values, + }, + }); + const { + handleSubmit, + control, + watch, + formState: { isDirty }, + reset, + setError, + } = methods; + + useEffect(() => { + reset({ + name: taxon.name ?? '', + description: taxon.description ?? '', + freeform: taxon.freeform ?? false, + one_per_engagement: taxon.one_per_engagement ?? false, + data_type: taxon.data_type ?? 'text', + preset_values: taxon.preset_values ?? [], + }); + }, [taxon, reset]); + + // Watch data_type to update taxonType + const dataType = watch('data_type'); + const taxonType = TaxonTypes[dataType as keyof typeof TaxonTypes]; + // Watch freeform to update label + const isFreeform = watch('freeform'); + const isMulti = !watch('one_per_engagement'); + const presetValues = watch('preset_values'); + + const schema = yup.object().shape({ + name: yup.string().required('Name is required').max(64, 'Name is too long! Limit: 64 characters.'), + description: yup.string().max(255, 'Description is too long! Limit: 255 characters.'), + freeform: yup.boolean().oneOf([taxonType.supportsFreeform, false]), + one_per_engagement: taxonType.supportsMulti ? yup.boolean() : yup.boolean().oneOf([true]), + data_type: yup.string().required('Type is required'), + preset_values: yup.mixed().when('data_type', { + is: (value: string) => TaxonTypes[value as keyof typeof TaxonTypes].supportsPresetValues, + then: yup.mixed().when('freeform', { + is: false, + then: yup + .array() + .of(taxonType.yupValidator ?? yup.mixed()) + .required('Preset value is required'), + otherwise: yup.array().of(taxonType.yupValidator ?? yup.mixed()), + }), + otherwise: yup.mixed().strip(), + }), + }); + + const onSubmit: SubmitHandler = async (data, event) => { + let formErrors: { [key: string]: string } = {}; + // These fields don't always apply to all taxon types, so + // if they are unsupported, we set them to the defaults + if (!taxonType.supportsFreeform) data.freeform = false; + if (!taxonType.supportsMulti) data.one_per_engagement = true; + if (!taxonType.supportsPresetValues) data.preset_values = []; + try { + await schema.validate(data, { abortEarly: false }); + // Catch clause variable type annotation must be 'any' or 'unknown' if specified + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: yup.ValidationError | any) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formErrors = error.inner.reduce((errors: { [key: string]: string }, innerError: any) => { + errors[innerError.path] = innerError.message; + return errors; + }, {}); + // Set errors for each field in formState + Object.keys(formErrors).forEach((fieldName) => { + setError(fieldName as keyof MetadataTaxon, { + type: 'validate', + message: formErrors[fieldName], + }); + }); + } + + if (Object.keys(formErrors).length) { + dispatch( + openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), + ); + return false; + } + + const updatedTaxon: MetadataTaxon = { + ...taxon, + name: data.name, + description: data.description, + freeform: data.freeform || data.preset_values?.length === 0, + one_per_engagement: data.one_per_engagement || !TaxonTypes[data.data_type ?? 'text'].supportsMulti, + data_type: data.data_type, + preset_values: TaxonTypes[data.data_type ?? 'text'].supportsPresetValues ? data.preset_values : [], + }; + + updateMetadataTaxon(updatedTaxon); + }; + + const handleKeys = (event: React.KeyboardEvent) => { + // Handle as many key combinations as possible + if ((event.ctrlKey || event.metaKey || event.altKey) && event.key === 'Enter') { + event.nativeEvent.stopImmediatePropagation(); + event.preventDefault(); // Prevent default to stop any native form submission + handleSubmit(onSubmit)(); + } + }; + + const isMac = () => { + return /Mac|iPod|iPhone|iPad/.test(navigator.platform); + }; + const modifierKey = !isMac() ? 'Ctrl' : '⌘'; + + // Whether the options can be limited to preset values + const allowLimiting = taxonType.supportsFreeform && (presetValues?.length ?? 0) > 0; + + return ( + + + + + + + + + Edit taxon + + + + + + + ( + + )} + /> + + + ( + + )} + /> + + + + Type + ( + + )} + /> + + + + + + + + + + { + return ( + onChange(!e.target.checked)} + /> + ); + }} + /> + } + label={ + + {isFreeform ? : } + Limit to preset values + + } + /> + + + + + ( + onChange(!e.target.checked)} /> + )} + /> + } + label={ + + {isMulti ? : } + Allow multiple values + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default TaxonEditForm; diff --git a/met-web/src/components/metadataManagement/TaxonEditor.tsx b/met-web/src/components/metadataManagement/TaxonEditor.tsx new file mode 100644 index 000000000..759d3d563 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -0,0 +1,351 @@ +import { Grid, Box, Paper, IconButton, Modal, Button, Typography, Chip } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { Close, UnfoldMore, UnfoldLess, AddCircle, KeyboardArrowDown, KeyboardArrowUp } from '@mui/icons-material'; +import { DragDropContext, DropResult } from '@hello-pangea/dnd'; +import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { reorder } from 'utils'; +import { MetadataTaxon } from 'models/engagement'; +import { MetDroppable } from 'components/common/Dragdrop'; +import { ActionContext } from './ActionContext'; +import TaxonEditForm from './TaxonEditForm'; +import { Else, If, Then } from 'react-if'; +import { TaxonCard, TaxonCardSkeleton } from './TaxonCard'; + +export const TaxonEditor = () => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')); + const { metadataTaxa, reorderMetadataTaxa, createMetadataTaxon, selectedTaxon, setSelectedTaxonId, isLoading } = + useContext(ActionContext); + const orderedMetadataTaxa = useMemo(() => metadataTaxa, [metadataTaxa]); + const [expandedCards, setExpandedCards] = useState(new Array(metadataTaxa.length).fill(false)); + + const setCardExpanded = (index: number, state: boolean) => { + setExpandedCards((prevExpandedCards) => { + const newExpandedCards = [...prevExpandedCards]; // create a copy + newExpandedCards[index] = state; // update the copy + return newExpandedCards; // return the updated copy + }); + }; + + const expandAll = () => { + setExpandedCards(new Array(metadataTaxa.length).fill(true)); + }; + + const collapseAll = () => { + setExpandedCards(new Array(metadataTaxa.length).fill(false)); + setSelectedTaxonId(-1); + }; + + const repositionTaxon = (result: DropResult) => { + if (!result.destination) { + return; + } + const items = reorder(metadataTaxa, result.source.index, result.destination.index); + reorderMetadataTaxa(items.map((taxon) => taxon.id)); + }; + + const handleSelectTaxon = (taxon: MetadataTaxon) => { + if (taxon.id === selectedTaxon?.id) { + setSelectedTaxonId(-1); + } else { + setSelectedTaxonId(taxon.id); + } + }; + + const handleExpandTaxon = (taxon: MetadataTaxon) => { + const index = orderedMetadataTaxa.findIndex((t) => t.id === taxon.id); + if (index === -1) { + return; + } + setCardExpanded(index, !expandedCards[index]); + }; + + const addTaxon = async () => { + const newTaxon = await createMetadataTaxon({ + name: 'New Taxon', + data_type: 'text', + freeform: true, + one_per_engagement: true, + }); + if (newTaxon) { + setSelectedTaxonId(newTaxon.id); + } + setTimeout(() => { + scrollableRef.current?.scrollTo({ top: scrollableRef.current?.scrollHeight, behavior: 'smooth' }); + }, 1); // Wait for the new taxon to be rendered before scrolling + }; + + const [showScrollIndicators, setShowScrollIndicators] = useState({ + top: false, + bottom: true, + }); + + const scrollableRef = useRef(null); + + useEffect(() => { + if (!scrollableRef.current) { + return; + } + const currentRef = scrollableRef.current; + const checkScroll = () => { + if (!currentRef) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; + const scrollMargin = 20; + setShowScrollIndicators({ + top: scrollTop > scrollMargin, + bottom: scrollTop < scrollHeight - clientHeight - scrollMargin, + }); + }; + + currentRef.addEventListener('scroll', checkScroll); + + // Initial check + checkScroll(); + + return () => currentRef.removeEventListener('scroll', checkScroll); + }, [orderedMetadataTaxa]); + + const scroll = (amount: number) => { + const scrollableDiv = scrollableRef.current; + if (scrollableDiv) { + scrollableDiv.scrollBy({ top: amount, behavior: 'smooth' }); + } + }; + + return ( + + + + Manage the ways metadata is collected and organized for your engagements. + + + + + + + + + } + onClick={() => { + scroll(-400); + }} + /> + + + + + + {!isLoading && + orderedMetadataTaxa.map((taxon: MetadataTaxon, index) => { + return ( + + ); + })} + {isLoading && [...Array(9)].map(() => )} + {!isLoading && orderedMetadataTaxa.length === 0 && ( + <> + + No taxa found + + + Add a new taxon above to get started. + + + )} + + + + + + } + onClick={() => { + scroll(400); + }} + /> + + + + + {selectedTaxon && ( + setSelectedTaxonId(-1)}> + + setSelectedTaxonId(-1)} + sx={{ + position: 'relative', + left: '-1em', + top: '0.3em', + }} + > + + + + + + )} + + + {selectedTaxon && ( + + + + + + )} + + + + + ); +}; diff --git a/met-web/src/components/metadataManagement/TaxonTypes.tsx b/met-web/src/components/metadataManagement/TaxonTypes.tsx new file mode 100644 index 000000000..a4df174e8 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonTypes.tsx @@ -0,0 +1,135 @@ +import { + AlternateEmail, + Event, + EventNote, + Flaky, + Link, + Article, + ChatBubbleOutline, + PinOutlined, + Phone, + Schedule, +} from '@mui/icons-material'; +import { TaxonType, GenericInputProps as TaxonInputProps } from './types'; +import * as yup from 'yup'; +import React from 'react'; +import { TextField } from '@mui/material'; +import { + TaxonPicker, + PickerTypes, + taxonSwitch, +} from 'components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents'; + +export const TaxonTypes: { [key: string]: TaxonType } = { + text: { + name: 'Text', + icon: ChatBubbleOutline, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup.string(), + }, + long_text: { + name: 'Multiline Text', + icon: Article, + supportsPresetValues: false, + supportsFreeform: true, + supportsMulti: false, + yupValidator: yup.string(), + customInput: ({ taxon, field, setValue, errors }: TaxonInputProps) => ( + { + setValue(taxon.id.toString(), e.target.value); + }} + /> + ), + }, + number: { + name: 'Number', + icon: PinOutlined, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup.number().typeError('This value must be a number.'), + }, + boolean: { + name: 'True/False', + icon: Flaky, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup.boolean(), + customInput: taxonSwitch, + }, + date: { + name: 'Date', + icon: Event, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup.date().typeError('This value must be a valid date.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: PickerTypes.DATE }), + }, + time: { + name: 'Time', + icon: Schedule, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup.date().typeError('This value must be a valid time.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: PickerTypes.TIME }), + }, + datetime: { + name: 'Date and Time', + icon: EventNote, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup.date().typeError('This value must consist of a valid date and time.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: PickerTypes.DATETIME }), + }, + url: { + name: 'Web Link', + icon: Link, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup.string().url('This value must be a valid web URL.'), + externalResource: (value: string) => value, + externalResourceLabel: 'Open', + }, + email: { + name: 'Email Address', + icon: AlternateEmail, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup.string().email('This value must be a valid email address.'), + externalResource: (value: string) => `mailto:${value}`, + externalResourceLabel: 'Email', + }, + phone: { + name: 'Phone Number', + icon: Phone, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup + .string() + .matches( + /^(\+?\d{1,3}[\s-]?)?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}$/, + 'This value must be a valid phone number.', + ), + externalResource: (value: string) => `tel:${value}`, + externalResourceLabel: 'Call', + }, +}; diff --git a/met-web/src/components/metadataManagement/index.tsx b/met-web/src/components/metadataManagement/index.tsx new file mode 100644 index 000000000..c0bb959c9 --- /dev/null +++ b/met-web/src/components/metadataManagement/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import ActionProvider from './ActionContext'; +import { TaxonEditor } from './TaxonEditor'; + +const MetadataManagement = () => { + return ( + + + + ); +}; + +export default MetadataManagement; diff --git a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx new file mode 100644 index 000000000..7a3c7cb34 --- /dev/null +++ b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx @@ -0,0 +1,106 @@ +import React, { SyntheticEvent, useState } from 'react'; +import { Autocomplete, TextField, Chip, IconButton, Stack } from '@mui/material'; +import { Control, Controller, FieldError } from 'react-hook-form'; +import { ArrowCircleUp, HighlightOff } from '@mui/icons-material'; + +const PresetValuesEditor = ({ + control, // The control object (from react-hook-form) + name, // The name of the field in the form +}: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: Control; + name: string; +}) => { + // State to manage the input value of the Autocomplete component + const [inputValue, setInputValue] = useState(''); + + return ( + { + const valueErrors = (errors.preset_values as unknown as Array) ?? []; + const errorIndices = new Set(); + const errorMessage = valueErrors?.map((error: FieldError, index: number) => { + errorIndices.add(index); + return ( + + Entry #{index + 1}: {error.message} +
+
+ ); + }); + + const onArrayChange = (_event: SyntheticEvent | null, newValue: string[] | null) => { + newValue = newValue ?? [...value, inputValue]; + newValue = newValue.map((v: string) => v.trim()).filter(Boolean); + onChange(newValue); + setInputValue(''); // Clear the input value after change + }; + + return ( + { + setInputValue(newInputValue); + }} + onChange={onArrayChange} + renderTags={(value, getTagProps) => ( + + {value.map((option, index) => ( + + ))} + + )} + renderInput={(params) => ( + + {inputValue && ( + { + onArrayChange(null, [...value, inputValue]); + }} + > + + + )} + onChange([])} + > + + + + ), + }} + /> + )} + /> + ); + }} + /> + ); +}; + +export default PresetValuesEditor; diff --git a/met-web/src/components/metadataManagement/types.ts b/met-web/src/components/metadataManagement/types.ts new file mode 100644 index 000000000..03d9df105 --- /dev/null +++ b/met-web/src/components/metadataManagement/types.ts @@ -0,0 +1,70 @@ +import { SvgIconComponent } from '@mui/icons-material'; +import { MetadataTaxon, MetadataTaxonModify } from 'models/engagement'; +import { ControllerRenderProps, FieldErrorsImpl, FieldValues } from 'react-hook-form'; +import * as yup from 'yup'; + +export type TaxonFormValues = { + [key: string]: string[]; +} & FieldValues; + +export interface IProps { + errorMessage?: string; + errorCode?: string; +} + +export interface ActionContextProps { + metadataTaxa: MetadataTaxon[]; + selectedTaxon: MetadataTaxon | null; + setSelectedTaxonId: (taxonId: number) => void; + reorderMetadataTaxa: (taxonIds: number[]) => void; + createMetadataTaxon: (taxon: MetadataTaxonModify) => Promise; + updateMetadataTaxon: (taxon: MetadataTaxon) => void; + removeMetadataTaxon: (taxonId: number) => void; + isLoading: boolean; +} + +export interface GenericInputProps { + field: ControllerRenderProps; + taxon: MetadataTaxon; + taxonType: TaxonType; + trigger: (name?: string | string[] | undefined) => Promise; + errors: Partial< + FieldErrorsImpl<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [x: string]: any; // This is the type specified by react-hook-form for formState.errors + }> + >; + setValue: ( + name: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + options?: + | Partial<{ + shouldValidate: boolean; + shouldDirty: boolean; + shouldTouch: boolean; + }> + | undefined, + ) => void; +} + +export interface TaxonType { + name: string; + icon: SvgIconComponent; + supportsPresetValues: boolean; + supportsFreeform: boolean; + supportsMulti: boolean; + yupValidator: yup.AnySchema; + customInput?: (props: GenericInputProps) => JSX.Element; + externalResource?: (value: string) => string; + externalResourceLabel?: string; +} + +export interface TaxonCardProps { + taxon: MetadataTaxon; + isExpanded: boolean; + onSelect: (taxon: MetadataTaxon) => void; + onExpand: (taxon: MetadataTaxon) => void; + isSelected: boolean; + index: number; +} diff --git a/met-web/src/components/survey/building/index.tsx b/met-web/src/components/survey/building/index.tsx index 9ab2bf4fc..1ef2cba8a 100644 --- a/met-web/src/components/survey/building/index.tsx +++ b/met-web/src/components/survey/building/index.tsx @@ -263,12 +263,7 @@ const SurveyFormBuilder = () => { ) : ( <> - setName(event.target.value)} - onBlur={(event) => setIsNamedFocused(false)} - /> + setName(event.target.value)} /> { setIsNamedFocused(!isNameFocused); diff --git a/met-web/src/models/engagement.ts b/met-web/src/models/engagement.ts index 70f6ee069..e78db3304 100644 --- a/met-web/src/models/engagement.ts +++ b/met-web/src/models/engagement.ts @@ -33,8 +33,26 @@ export interface Status { status_name: string; } +export interface MetadataTaxonModify { + name?: string; // The name of the taxon, optional + description?: string; // The description of the taxon, optional + freeform?: boolean; // Whether the taxon is freeform, optional + data_type?: string; // The data type for the taxon, optional + one_per_engagement?: boolean; // Whether the taxon is limited to one entry per engagement, optional + preset_values?: string[]; // The preset values for the taxon +} + +export interface MetadataTaxon extends MetadataTaxonModify { + id: number; // The id of the taxon + tenant_id: number; // The tenant id + position: number; // The taxon's position within the tenant + entries?: EngagementMetadata[]; // The content of the taxon +} + export interface EngagementMetadata { - engagement_id: number; + value: string; // The content of the metadata + taxon_id: number; // ID of the taxon this metadata is for + engagement_id?: number; // The ID of the relevant engagement } export interface EngagementSettings { @@ -76,12 +94,6 @@ export const createDefaultEngagement = (): Engagement => { }; }; -export const createDefaultEngagementMetadata = (): EngagementMetadata => { - return { - engagement_id: 0, - }; -}; - export const createDefaultEngagementSettings = (): EngagementSettings => { return { engagement_id: 0, diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index be1740c52..03605e52f 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -8,6 +8,7 @@ import SurveyListing from 'components/survey/listing'; import CreateSurvey from 'components/survey/create'; import SurveyFormBuilder from 'components/survey/building'; import SurveySubmit from 'components/survey/submit'; +import MetadataManagement from 'components/metadataManagement'; import CommentReview from 'components/comments/admin/review/CommentReview'; import CommentReviewListing from 'components/comments/admin/reviewListing'; import CommentTextListing from 'components/comments/admin/textListing'; @@ -59,6 +60,7 @@ const AuthenticatedRoutes = () => { } /> } /> } /> + } /> }> } /> diff --git a/met-web/src/services/engagementMetadataService/index.ts b/met-web/src/services/engagementMetadataService/index.ts index 1ac1ea3fd..2ee373ac9 100644 --- a/met-web/src/services/engagementMetadataService/index.ts +++ b/met-web/src/services/engagementMetadataService/index.ts @@ -1,18 +1,18 @@ import http from 'apiManager/httpRequestHandler'; -import { EngagementMetadata } from 'models/engagement'; +import { EngagementMetadata, MetadataTaxonModify, MetadataTaxon } from 'models/engagement'; import Endpoints from 'apiManager/endpoints'; import { replaceUrl } from 'helper'; -export const getEngagementMetadata = async (engagementId: number): Promise => { +export const getEngagementMetadata = async (engagementId: number): Promise => { const url = replaceUrl(Endpoints.EngagementMetadata.GET_BY_ENG, 'engagement_id', String(engagementId)); if (!engagementId || isNaN(Number(engagementId))) { - return Promise.reject('Invalid Engagement Id ' + engagementId); + throw new Error('Invalid Engagement ID ' + engagementId); } - const response = await http.GetRequest(url); + const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject('Failed to fetch engagement'); + throw new Error('Failed to fetch engagement'); }; export const postEngagementMetadata = async (data: EngagementMetadata): Promise => { @@ -20,7 +20,7 @@ export const postEngagementMetadata = async (data: EngagementMetadata): Promise< if (response.data) { return response.data; } - return Promise.reject('Failed to create engagement metadata'); + throw new Error('Failed to create engagement metadata'); }; export const patchEngagementMetadata = async (data: EngagementMetadata): Promise => { @@ -28,5 +28,75 @@ export const patchEngagementMetadata = async (data: EngagementMetadata): Promise if (response.data) { return response.data; } - return Promise.reject('Failed to update engagement metadata'); + throw new Error('Failed to update engagement metadata'); +}; + +export const bulkPatchEngagementMetadata = async ( + taxon_id: number, + engagement_id: number, + values: Array, +): Promise> => { + const url = replaceUrl(Endpoints.EngagementMetadata.BULK_UPDATE, 'engagement_id', String(engagement_id)); + const response = await http.PatchRequest>(url, { taxon_id, values }); + if (response.data) { + return response.data; + } + throw new Error('Failed to update engagement metadata'); +}; + +export const getMetadataTaxa = async (): Promise> => { + const response = await http.GetRequest>(Endpoints.MetadataTaxa.GET_BY_TENANT); + if (response.data) { + return response.data; + } + throw new Error('Failed to fetch metadata taxa'); +}; + +export const getMetadataTaxon = async (taxonId: number): Promise => { + const url = replaceUrl(Endpoints.MetadataTaxa.GET, 'taxon_id', String(taxonId)); + if (!taxonId || isNaN(Number(taxonId))) { + throw new Error('Invalid Taxon Id ' + taxonId); + } + const response = await http.GetRequest(url); + if (response.data) { + return response.data; + } + throw new Error('Failed to fetch metadata taxon'); +}; + +export const postMetadataTaxon = async (data: MetadataTaxonModify): Promise => { + const response = await http.PostRequest(Endpoints.MetadataTaxa.CREATE, data); + if (response.data) { + return response.data; + } + throw new Error('Failed to create metadata taxon'); +}; + +export const patchMetadataTaxon = async (id: number, data: MetadataTaxonModify): Promise => { + const url = replaceUrl(Endpoints.MetadataTaxa.UPDATE, 'taxon_id', String(id)); + const response = await http.PatchRequest(url, data); + if (response.data) { + return response.data; + } + throw new Error('Failed to update metadata taxon'); +}; + +export const deleteMetadataTaxon = async (taxonId: number): Promise => { + const url = replaceUrl(Endpoints.MetadataTaxa.DELETE, 'taxon_id', String(taxonId)); + const response = await http.DeleteRequest(url); + if (response.status === 204) { + return; + } + throw new Error('Failed to delete metadata taxon'); +}; + +export const patchMetadataTaxaOrder = async (taxonIds: Array): Promise> => { + const data = { + taxon_ids: taxonIds, + }; + const response = await http.PatchRequest>(Endpoints.MetadataTaxa.REORDER, data); + if (response.data) { + return response.data; + } + throw new Error('Failed to reorder metadata taxa'); }; diff --git a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx index 0c25ef950..52fb5d1de 100644 --- a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx +++ b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx @@ -88,7 +88,9 @@ describe('Engagement form page tests', () => { const useParamsMock = jest.spyOn(reactRouter, 'useParams'); jest.spyOn(engagementService, 'getEngagement').mockReturnValue(Promise.resolve(draftEngagement)); jest.spyOn(widgetService, 'getWidgets').mockReturnValue(Promise.resolve([])); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); beforeEach(() => { diff --git a/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx b/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx index 88dff7a8c..d98f5218b 100644 --- a/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx +++ b/met-web/tests/unit/components/engagement/form/create/EngagementForm.Create.test.tsx @@ -74,7 +74,7 @@ describe('Engagement form page tests', () => { const useParamsMock = jest.spyOn(reactRouter, 'useParams'); const getEngagementMetadataMock = jest .spyOn(engagementMetadataService, 'getEngagementMetadata') - .mockReturnValue(Promise.resolve(engagementMetadata)); + .mockReturnValue(Promise.resolve([engagementMetadata])); jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').mockReturnValue( Promise.resolve(engagementMetadata), ); diff --git a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx index 8b8373156..a67edb906 100644 --- a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx +++ b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.One.test.tsx @@ -91,7 +91,7 @@ describe('Engagement form page tests', () => { const useParamsMock = jest.spyOn(reactRouter, 'useParams'); const getEngagementMetadataMock = jest .spyOn(engagementMetadataService, 'getEngagementMetadata') - .mockReturnValue(Promise.resolve(engagementMetadata)); + .mockReturnValue(Promise.resolve([engagementMetadata])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); jest.spyOn(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').mockReturnValue( @@ -184,17 +184,12 @@ describe('Engagement form page tests', () => { surveys: surveys, }), ); - getEngagementMetadataMock.mockReturnValueOnce( - Promise.resolve({ - ...engagementMetadata, - }), - ); render(); await waitFor(() => { expect(screen.getByDisplayValue('Test Engagement')).toBeInTheDocument(); }); - + getEngagementMetadataMock.mockReturnValueOnce(Promise.resolve([engagementMetadata])); expect(screen.getByText('Add Survey')).toBeDisabled(); }); diff --git a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.Two.test.tsx b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.Two.test.tsx index 9f5e2e065..d47693129 100644 --- a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.Two.test.tsx +++ b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.Two.test.tsx @@ -89,7 +89,9 @@ describe('Engagement form page tests', () => { jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').mockReturnValue( Promise.resolve(engagementMetadata), ); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); const getEngagementMock = jest .spyOn(engagementService, 'getEngagement') diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index c7a117b6b..33510f790 100644 --- a/met-web/tests/unit/components/factory.ts +++ b/met-web/tests/unit/components/factory.ts @@ -2,11 +2,11 @@ import '@testing-library/jest-dom'; import { createDefaultSurvey, Survey } from 'models/survey'; import { createDefaultEngagement, - createDefaultEngagementMetadata, createDefaultEngagementSettings, Engagement, EngagementMetadata, EngagementSettings, + MetadataTaxon, } from 'models/engagement'; import { EngagementStatus } from 'constants/engagementStatus'; import { WidgetType, Widget, WidgetItem } from 'models/widget'; @@ -252,8 +252,20 @@ const mockTimeLine: TimelineWidget = { events: [mockTimeLineEvent1], }; const engagementMetadata: EngagementMetadata = { - ...createDefaultEngagementMetadata(), engagement_id: 1, + taxon_id: 1, + value: 'test', +}; + +const engagementMetadataTaxon: MetadataTaxon = { + tenant_id: 1, + id: 1, + name: 'test', + data_type: 'text', + one_per_engagement: false, + freeform: true, + preset_values: ['test'], + position: 1, }; const engagementSetting: EngagementSettings = { @@ -278,6 +290,7 @@ export { eventWidgetItem, eventWidget, engagementMetadata, + engagementMetadataTaxon, engagementSlugData, engagementSetting, mockPoll, diff --git a/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx b/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx index a37cbea52..f6e314724 100644 --- a/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx @@ -113,7 +113,9 @@ describe('Document widget in engagement page tests', () => { jest.spyOn(notificationSlice, 'openNotification').mockImplementation(jest.fn()); jest.spyOn(engagementService, 'getEngagement').mockReturnValue(Promise.resolve(engagement)); jest.spyOn(documentService, 'fetchDocuments').mockReturnValue(Promise.resolve([mockFolder])); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(membershipService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue( Promise.resolve(mockEngagementSettings), diff --git a/met-web/tests/unit/components/widgets/EventsWidget.test.tsx b/met-web/tests/unit/components/widgets/EventsWidget.test.tsx index 18c894bda..4174c182d 100644 --- a/met-web/tests/unit/components/widgets/EventsWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/EventsWidget.test.tsx @@ -93,7 +93,9 @@ describe('Event Widget tests', () => { .spyOn(engagementService, 'getEngagement') .mockReturnValue(Promise.resolve(draftEngagement)); const getWidgetsMock = jest.spyOn(widgetService, 'getWidgets').mockReturnValue(Promise.resolve([eventWidget])); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(membershipService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue( Promise.resolve(mockEngagementSettings), diff --git a/met-web/tests/unit/components/widgets/MapWidget.test.tsx b/met-web/tests/unit/components/widgets/MapWidget.test.tsx index 915b81795..3cc5a2603 100644 --- a/met-web/tests/unit/components/widgets/MapWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/MapWidget.test.tsx @@ -86,7 +86,7 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => jest.fn(), })); -jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); +jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve([engagementMetadata])); describe('Map Widget tests', () => { jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); diff --git a/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx b/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx index 04e5e0bc6..a2c23f1a0 100644 --- a/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx @@ -97,7 +97,9 @@ describe('Phases widget tests', () => { .spyOn(engagementService, 'getEngagement') .mockReturnValue(Promise.resolve(draftEngagement)); const getWidgetsMock = jest.spyOn(widgetService, 'getWidgets').mockReturnValue(Promise.resolve([phasesWidget])); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(membershipService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue( Promise.resolve(mockEngagementSettings), diff --git a/met-web/tests/unit/components/widgets/VideoWidget.test.tsx b/met-web/tests/unit/components/widgets/VideoWidget.test.tsx index 128b7e977..5bdf9144a 100644 --- a/met-web/tests/unit/components/widgets/VideoWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/VideoWidget.test.tsx @@ -32,7 +32,7 @@ jest.mock('apiManager/apiSlices/widgets', () => ({ useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); -jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); +jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve([engagementMetadata])); // Mock the necessary services and contexts jest.mock('services/widgetService/VideoService', () => ({ diff --git a/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx b/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx index a56f4f647..8ab61ac0c 100644 --- a/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx @@ -138,11 +138,13 @@ jest.mock('apiManager/apiSlices/widgets', () => ({ useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); -jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); +jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve([engagementMetadata])); describe('Who is Listening widget tests', () => { jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(membershipService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue( Promise.resolve(mockEngagementSettings), diff --git a/met-web/tests/unit/components/widgets/setupWidgetTestEnv.tsx b/met-web/tests/unit/components/widgets/setupWidgetTestEnv.tsx index fc6b1d34d..c4de6063e 100644 --- a/met-web/tests/unit/components/widgets/setupWidgetTestEnv.tsx +++ b/met-web/tests/unit/components/widgets/setupWidgetTestEnv.tsx @@ -43,7 +43,9 @@ export const setupWidgetTestEnvMock = (): void => { export const setupWidgetTestEnvSpy = (): void => { setupEnv(); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => ({ roles: [USER_ROLES.VIEW_PRIVATE_ENGAGEMENTS, USER_ROLES.EDIT_ENGAGEMENT, USER_ROLES.CREATE_ENGAGEMENT], @@ -52,7 +54,9 @@ export const setupWidgetTestEnvSpy = (): void => { jest.spyOn(reactRouter, 'useParams').mockReturnValue({ projectId: '' }); jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(jest.fn()); jest.spyOn(engagementService, 'getEngagement').mockReturnValue(Promise.resolve(draftEngagement)); - jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue(Promise.resolve(engagementMetadata)); + jest.spyOn(engagementMetadataService, 'getEngagementMetadata').mockReturnValue( + Promise.resolve([engagementMetadata]), + ); jest.spyOn(membershipService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue( Promise.resolve(mockEngagementSettings),