From 7be03fcc4dc240f419d2a81c5cf98784e17c0e33 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Fri, 9 Feb 2024 12:27:40 -0800 Subject: [PATCH 01/27] Engagement Metadata Management - API changes --- CHANGELOG.MD | 7 +++ ...f4f_remove_taxon_default_add_constraint.py | 31 ++++++++++ met-api/src/met_api/auth.py | 10 +++- .../src/met_api/models/engagement_metadata.py | 41 +++++++++----- met-api/src/met_api/resources/__init__.py | 2 +- .../met_api/resources/engagement_metadata.py | 8 ++- .../src/met_api/resources/metadata_taxon.py | 38 ++++++------- .../met_api/schemas/engagement_metadata.py | 30 +++++++--- .../services/engagement_metadata_service.py | 30 ++++------ .../services/metadata_taxon_service.py | 19 ++++--- met-api/tests/unit/api/test_metadata_taxa.py | 56 +++++++++++++------ .../unit/models/test_engagement_metadata.py | 5 +- .../tests/unit/models/test_metadata_taxa.py | 21 ++++++- .../unit/services/test_engagement_metadata.py | 44 +++++++-------- .../tests/unit/services/test_metadata_taxa.py | 33 +++++++++-- met-api/tests/utilities/factory_scenarios.py | 19 ++----- met-api/tests/utilities/factory_utils.py | 1 - 17 files changed, 258 insertions(+), 137 deletions(-) create mode 100644 met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 80c4e5899..55f58f603 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,10 @@ +## Current: feature/DESENG-443: Engagement Metadata Management +- **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. + ## 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 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..8312c3f36 --- /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: ec0128056a33 +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 = 'ec0128056a33' +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..63497c1bc 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 1f31982bd..1d9aa6672 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -81,7 +81,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..c29ffe212 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', @@ -76,7 +77,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.""" @@ -95,7 +97,7 @@ def post(engagement_id: int): @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') diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py index ec36203d2..b378665bc 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,17 @@ def ensure_tenant_access(): """ - Ensure that the user is authorized to access the tenant specified in the request. - - This decorator should be used on any endpoint that requires - access to a tenant's data. Makes the tenant accessible via kwargs. + Provide access to the tenant as a DB model for the decorated function. + 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 +96,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 +107,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 +131,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 +148,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..5611ca50c 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,8 +1,9 @@ -"""Engagement Metadata schema class. - -Manages the Engagement Metadata +""" +Schemas for serializing and deserializing classes related to engagement metadata. """ +from met_api.models.engagement_metadata import (EngagementMetadata, + MetadataTaxon, MetadataTaxonDataType) from marshmallow import ValidationError, fields, pre_load, validate from marshmallow_sqlalchemy import SQLAlchemyAutoSchema from marshmallow_sqlalchemy.fields import Nested @@ -35,7 +36,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 +51,26 @@ 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): + # This method is used by Marshmallow to serialize the preset_values property + 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..7723f846d 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -26,7 +26,8 @@ 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 @@ -43,7 +44,8 @@ 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.') + raise KeyError( + f'Engagement with id {engagement_id} does not exist.') return EngagementMetadataSchema(many=True).dump(engagement_model.metadata) @staticmethod @@ -61,7 +63,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 +83,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 +116,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: 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..ca3344d86 100644 --- a/met-api/src/met_api/services/metadata_taxon_service.py +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -1,12 +1,12 @@ """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 -from met_api.models.engagement_metadata import MetadataTaxon +from met_api.models.engagement_metadata import MetadataTaxon, EngagementMetadata from met_api.models.tenant import Tenant -from met_api.schemas.engagement_metadata import MetadataTaxonSchema +from met_api.schemas.engagement_metadata import EngagementMetadataSchema, MetadataTaxonSchema class MetadataTaxonService: @@ -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) diff --git a/met-api/tests/unit/api/test_metadata_taxa.py b/met-api/tests/unit/api/test_metadata_taxa.py index b45ea0413..153c1c296 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 @@ -57,13 +60,15 @@ 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', + 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..c4cead08d 100644 --- a/met-api/tests/unit/models/test_engagement_metadata.py +++ b/met-api/tests/unit/models/test_engagement_metadata.py @@ -11,9 +11,8 @@ # 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 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..07b9a1711 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,11 +86,14 @@ 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) @@ -110,8 +104,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 8e3acf1b3..b2ac4d66e 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -286,44 +286,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 7d2b8809c..9da5696cf 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -216,7 +216,6 @@ def factory_metadata_taxon_model(tenant_id: int = 1, 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'), ) From f8bc36300b58222b9c38ad354585e9e1331602b4 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R <108045773+ratheesh-aot@users.noreply.github.com> Date: Fri, 9 Feb 2024 13:55:24 -0800 Subject: [PATCH 02/27] DESENG-452 : Applying pending migrations (#2378) * DESENG-452 : Applying pending migrations * Updating unit test * Updated changelog * Fixed lint issue --- CHANGELOG.MD | 36 ++++++-- met-api/migrations/versions/37176ea4708d_.py | 91 +++++++++++++++++++ met-api/src/met_api/models/submission.py | 2 +- met-api/src/met_api/models/timeline_event.py | 6 +- met-api/src/met_api/models/widget_timeline.py | 4 +- .../api/test_email_verification_service.py | 5 +- .../test_email_verification_service.py | 7 +- met-api/tests/utilities/factory_utils.py | 10 +- 8 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 met-api/migrations/versions/37176ea4708d_.py diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 55f58f603..2f99686c8 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,54 +1,73 @@ ## Current: feature/DESENG-443: Engagement Metadata Management + - **Task**: Remove "default_values" from metadata taxa. -Replace with "preset values", metadata entries that are not assigned to an engagement. + 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. + metadata management to rely on normal authorization check functions. - **Task**: Clean up metadata management code and tests. +## February 08, 2024 + +- **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 + ## 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 @@ -88,6 +107,7 @@ metadata management to rely on normal authorization check functions. ## 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 @@ -136,9 +156,10 @@ metadata management to rely on normal authorization check functions. ## 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 @@ -159,7 +180,6 @@ metadata management to rely on normal authorization check functions. - 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/37176ea4708d_.py b/met-api/migrations/versions/37176ea4708d_.py new file mode 100644 index 000000000..3ae7a8545 --- /dev/null +++ b/met-api/migrations/versions/37176ea4708d_.py @@ -0,0 +1,91 @@ +"""empty message + +Revision ID: 37176ea4708d +Revises: ec0128056a33 +Create Date: 2024-02-08 12:40:09.456210 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '37176ea4708d' +down_revision = 'ec0128056a33' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('email_verification', 'type', + existing_type=postgresql.ENUM('Survey', 'RejectedComment', 'Subscribe', name='emailverificationtype'), + nullable=False) + op.create_index(op.f('ix_engagement_metadata_engagement_id'), 'engagement_metadata', ['engagement_id'], unique=False) + op.create_index(op.f('ix_engagement_metadata_taxon_id'), 'engagement_metadata', ['taxon_id'], unique=False) + op.create_index(op.f('ix_engagement_metadata_value'), 'engagement_metadata', ['value'], unique=False) + op.create_index(op.f('ix_engagement_metadata_taxa_tenant_id'), 'engagement_metadata_taxa', ['tenant_id'], unique=False) + op.create_unique_constraint(None, 'engagement_metadata_taxa', ['id']) + op.execute('UPDATE membership_status_codes SET created_date = CURRENT_TIMESTAMP WHERE created_date IS NULL;') + op.alter_column('membership_status_codes', 'created_date', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + op.drop_index('ix_participant_email_address', table_name='participant') + op.alter_column('timeline_event', 'widget_id', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('timeline_event', 'status', + existing_type=postgresql.ENUM('Pending', 'InProgress', 'Completed', name='timelineeventstatus'), + nullable=False) + op.alter_column('timeline_event', 'position', + existing_type=sa.INTEGER(), + nullable=False) + op.alter_column('widget_documents', 'is_uploaded', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text('false')) + op.alter_column('widget_timeline', 'widget_id', + existing_type=sa.INTEGER(), + nullable=False) + op.execute('UPDATE widget_type SET created_date = CURRENT_TIMESTAMP WHERE created_date IS NULL;') + op.alter_column('widget_type', 'created_date', + existing_type=postgresql.TIMESTAMP(), + nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('widget_type', 'created_date', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.alter_column('widget_timeline', 'widget_id', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('widget_documents', 'is_uploaded', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text('false')) + op.alter_column('timeline_event', 'position', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('timeline_event', 'status', + existing_type=postgresql.ENUM('Pending', 'InProgress', 'Completed', name='timelineeventstatus'), + nullable=True) + op.alter_column('timeline_event', 'widget_id', + existing_type=sa.INTEGER(), + nullable=True) + op.create_index('ix_participant_email_address', 'participant', ['email_address'], unique=False) + + op.alter_column('membership_status_codes', 'created_date', + existing_type=postgresql.TIMESTAMP(), + nullable=True) + op.drop_constraint(None, 'engagement_metadata_taxa', type_='unique') + op.drop_index(op.f('ix_engagement_metadata_taxa_tenant_id'), table_name='engagement_metadata_taxa') + op.drop_index(op.f('ix_engagement_metadata_value'), table_name='engagement_metadata') + op.drop_index(op.f('ix_engagement_metadata_taxon_id'), table_name='engagement_metadata') + op.drop_index(op.f('ix_engagement_metadata_engagement_id'), table_name='engagement_metadata') + op.alter_column('email_verification', 'type', + existing_type=postgresql.ENUM('Survey', 'RejectedComment', 'Subscribe', name='emailverificationtype'), + nullable=True) + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/submission.py b/met-api/src/met_api/models/submission.py index 40a0cb559..0698b9ff3 100644 --- a/met-api/src/met_api/models/submission.py +++ b/met-api/src/met_api/models/submission.py @@ -35,7 +35,7 @@ class Submission(BaseModel): # pylint: disable=too-few-public-methods comment_status_id = db.Column(db.Integer, ForeignKey('comment_status.id', ondelete='SET NULL')) has_personal_info = db.Column(db.Boolean, nullable=True) has_profanity = db.Column(db.Boolean, nullable=True) - rejected_reason_other = db.Column(db.String(500), nullable=False) + rejected_reason_other = db.Column(db.String(500), nullable=True) has_threat = db.Column(db.Boolean, nullable=True) notify_email = db.Column(db.Boolean(), default=True) comments = db.relationship('Comment', backref='submission', cascade='all, delete') diff --git a/met-api/src/met_api/models/timeline_event.py b/met-api/src/met_api/models/timeline_event.py index 7a80a12dc..cdd6e9b8f 100644 --- a/met-api/src/met_api/models/timeline_event.py +++ b/met-api/src/met_api/models/timeline_event.py @@ -16,9 +16,9 @@ class TimelineEvent(BaseModel): __tablename__ = 'timeline_event' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) - widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) - timeline_id = db.Column(db.Integer, ForeignKey('widget_timeline.id', ondelete='CASCADE'), nullable=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=False) + timeline_id = db.Column(db.Integer, ForeignKey('widget_timeline.id', ondelete='CASCADE'), nullable=False) status = db.Column(db.Enum(TimelineEventStatus), nullable=False) position = db.Column(db.Integer, nullable=False) description = db.Column(db.Text(), nullable=True) diff --git a/met-api/src/met_api/models/widget_timeline.py b/met-api/src/met_api/models/widget_timeline.py index 18cb732be..7de9743ef 100755 --- a/met-api/src/met_api/models/widget_timeline.py +++ b/met-api/src/met_api/models/widget_timeline.py @@ -14,8 +14,8 @@ class WidgetTimeline(BaseModel): # pylint: disable=too-few-public-methods, too- __tablename__ = 'widget_timeline' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) - widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=False) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=False) title = db.Column(db.String(255), nullable=True) description = db.Column(db.Text(), nullable=True) diff --git a/met-api/tests/unit/api/test_email_verification_service.py b/met-api/tests/unit/api/test_email_verification_service.py index 87bef2173..325425485 100644 --- a/met-api/tests/unit/api/test_email_verification_service.py +++ b/met-api/tests/unit/api/test_email_verification_service.py @@ -41,7 +41,8 @@ def test_email_verification(client, jwt, session, notify_mock, ): # pylint:disa survey, eng = factory_survey_and_eng_model() to_dict = { 'email_address': fake.email(), - 'survey_id': survey.id + 'survey_id': survey.id, + 'type': EmailVerificationType.Survey, } headers = factory_auth_header(jwt=jwt, claims=claims) rv = client.post('/api/email_verification/', data=json.dumps(to_dict), @@ -87,7 +88,7 @@ def test_patch_email_verification_by_token(client, jwt, session): # pylint:disa claims = TestJwtClaims.public_user_role set_global_tenant() survey, eng = factory_survey_and_eng_model() - email_verification = factory_email_verification(survey.id) + email_verification = factory_email_verification(survey.id, EmailVerificationType.Subscribe) headers = factory_auth_header(jwt=jwt, claims=claims) rv = client.put(f'/api/email_verification/{email_verification.verification_token}', diff --git a/met-api/tests/unit/services/test_email_verification_service.py b/met-api/tests/unit/services/test_email_verification_service.py index 2f96cdd14..813cd1b27 100644 --- a/met-api/tests/unit/services/test_email_verification_service.py +++ b/met-api/tests/unit/services/test_email_verification_service.py @@ -23,6 +23,7 @@ from met_api.exceptions.business_exception import BusinessException from met_api.services.email_verification_service import EmailVerificationService +from met_api.constants.email_verification import EmailVerificationType from met_api.utils import notification from tests.utilities.factory_scenarios import TestEngagementSlugInfo from tests.utilities.factory_utils import factory_engagement_slug_model, factory_survey_and_eng_model, set_global_tenant @@ -43,7 +44,8 @@ def test_create_email_verification(client, jwt, session, ): # pylint:disable=un email = fake.email() to_dict = { 'email_address': email, - 'survey_id': survey.id + 'survey_id': survey.id, + 'type': EmailVerificationType.Survey } with patch.object(notification, 'send_email', return_value=False) as mock_mail: EmailVerificationService().create(to_dict) @@ -66,7 +68,8 @@ def test_create_email_verification_exception(client, jwt, session, ): # pylint: email = fake.email() to_dict = { 'email_address': email, - 'survey_id': survey.id + 'survey_id': survey.id, + 'type': EmailVerificationType.Survey } with pytest.raises(BusinessException) as exception: with patch.object(notification, 'send_email', side_effect=Exception('mocked error')): diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 9da5696cf..dbd661c3f 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -50,6 +50,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, TestFeedbackInfo, TestJwtClaims, TestParticipantInfo, TestPollAnswerInfo, @@ -121,12 +122,17 @@ def factory_subscription_model(): return subscription -def factory_email_verification(survey_id): +def factory_email_verification(survey_id, type=None): """Produce a EmailVerification model.""" email_verification = EmailVerificationModel( verification_token=fake.uuid4(), - is_active=True + is_active=True, ) + if type: + email_verification.type = type + else: + email_verification.type = EmailVerificationType.Survey + if survey_id: email_verification.survey_id = survey_id From f764cb14a5d02d13c3719ead6811a0242858ecf5 Mon Sep 17 00:00:00 2001 From: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Date: Mon, 12 Feb 2024 10:25:17 -0800 Subject: [PATCH 03/27] DESENG-484: Adding max age for cors (#2379) * DESENG-484: Adding max age for cors (#2377) --- CHANGELOG.MD | 5 +++++ met-api/src/met_api/config.py | 6 ++++++ met-api/src/met_api/utils/util.py | 8 +++++++- met-api/tests/unit/utils/test_util_cors.py | 4 ++-- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 2f99686c8..7720e48f3 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -8,6 +8,11 @@ ## 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. + - **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 diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index e47321e4a..630c86e27 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -163,6 +163,12 @@ def SQLALCHEMY_DATABASE_URI(self) -> str: # CORS settings CORS_ORIGINS = os.getenv('CORS_ORIGINS', '').split(',') + # CORS_MAX_AGE defines the maximum age (in seconds) for Cross-Origin Resource Sharing (CORS) settings. + # This value is used to indicate how long the results of a preflight request (OPTIONS) can be cached + # by the client, reducing the frequency of preflight requests for the specified HTTP methods. + # Adjust this value based on security considerations. + CORS_MAX_AGE = os.getenv('CORS_MAX_AGE', None) # Default: 0 seconds + EPIC_CONFIG = { 'ENABLED': env_truthy('EPIC_INTEGRATION_ENABLED'), 'JWT_OIDC_ISSUER': os.getenv('EPIC_JWT_OIDC_ISSUER'), diff --git a/met-api/src/met_api/utils/util.py b/met-api/src/met_api/utils/util.py index b6367d6fc..d6e8cbb85 100644 --- a/met-api/src/met_api/utils/util.py +++ b/met-api/src/met_api/utils/util.py @@ -29,12 +29,18 @@ def cors_preflight(methods): def wrapper(f): def options(self, *args, **kwargs): # pylint: disable=unused-argument - return {'Allow': 'GET, DELETE, PUT, POST'}, 200, { + headers = { + 'Allow': 'GET, DELETE, PUT, POST', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': methods, 'Access-Control-Allow-Headers': 'Authorization, Content-Type, ' 'registries-trace-id, invitation_token' } + max_age = os.getenv('CORS_MAX_AGE') + if max_age is not None: + headers['Access-Control-Max-Age'] = str(max_age) + + return headers, 200, {} setattr(f, 'options', options) return f diff --git a/met-api/tests/unit/utils/test_util_cors.py b/met-api/tests/unit/utils/test_util_cors.py index 681b27f13..672229129 100644 --- a/met-api/tests/unit/utils/test_util_cors.py +++ b/met-api/tests/unit/utils/test_util_cors.py @@ -40,5 +40,5 @@ class TestCors(): pass rv = TestCors().options() # pylint: disable=no-member - assert rv[2]['Access-Control-Allow-Origin'] == '*' - assert rv[2]['Access-Control-Allow-Methods'] == methods + assert rv[0]['Access-Control-Allow-Origin'] == '*' + assert rv[0]['Access-Control-Allow-Methods'] == methods From 6c946ba538a87f9063f748b80d886dda77185bf1 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Fri, 9 Feb 2024 12:27:40 -0800 Subject: [PATCH 04/27] Engagement Metadata Management - API changes --- CHANGELOG.MD | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 7720e48f3..00b90cbef 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -9,10 +9,8 @@ ## 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. - - **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 From 21ba1e261856a65a5e6384abe42b3a8cdfc02538 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 13 Feb 2024 13:11:49 -0800 Subject: [PATCH 05/27] edit migration order to merge with migration changes --- .../dbe023373f4f_remove_taxon_default_add_constraint.py | 4 ++-- met-api/src/met_api/services/metadata_taxon_service.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 8312c3f36..e35389979 100644 --- a/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py +++ b/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py @@ -1,7 +1,7 @@ """Remove default_value from engagement_metadata_taxa and add unique constraint Revision ID: dbe023373f4f -Revises: ec0128056a33 +Revises: 37176ea4708d Create Date: 2024-01-30 17:05:25.313222 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = 'dbe023373f4f' -down_revision = 'ec0128056a33' +down_revision = '37176ea4708d' branch_labels = None depends_on = 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 ca3344d86..8be9af5e7 100644 --- a/met-api/src/met_api/services/metadata_taxon_service.py +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -100,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() From 4ff5307e21319f93ae4a4e786608f1b037399f66 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 6 Mar 2024 20:43:34 -0800 Subject: [PATCH 06/27] Backend changes for DESENG-443 --- .../met_api/resources/engagement_metadata.py | 28 ++++++++++++- .../services/engagement_metadata_service.py | 38 +++++++++++++++++- .../unit/api/test_engagement_metadata.py | 40 ++++++++++++++++++- .../unit/services/test_engagement_metadata.py | 31 ++++++++++++++ 4 files changed, 133 insertions(+), 4 deletions(-) diff --git a/met-api/src/met_api/resources/engagement_metadata.py b/met-api/src/met_api/resources/engagement_metadata.py index c29ffe212..f1c446abf 100644 --- a/met-api/src/met_api/resources/engagement_metadata.py +++ b/met-api/src/met_api/resources/engagement_metadata.py @@ -18,6 +18,7 @@ """ from http import HTTPStatus +import re from flask import request from flask_cors import cross_origin @@ -43,6 +44,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 @@ -58,7 +65,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): @@ -95,6 +102,24 @@ 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('/') # /metadata/{metadata.id} @@ -126,6 +151,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/services/engagement_metadata_service.py b/met-api/src/met_api/services/engagement_metadata_service.py index 7723f846d..5525d65c2 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -31,7 +31,7 @@ def get(metadata_id) -> dict: 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. @@ -46,7 +46,10 @@ def get_by_engagement(engagement_id) -> List[dict]: if not engagement_model: raise KeyError( f'Engagement with id {engagement_id} does not exist.') - return EngagementMetadataSchema(many=True).dump(engagement_model.metadata) + 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: @@ -135,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/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/services/test_engagement_metadata.py b/met-api/tests/unit/services/test_engagement_metadata.py index 07b9a1711..90f92384a 100644 --- a/met-api/tests/unit/services/test_engagement_metadata.py +++ b/met-api/tests/unit/services/test_engagement_metadata.py @@ -97,6 +97,37 @@ def test_update_engagement_metadata(session): 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() From 9871f579706fce1ba0b9ca4d8a26734bebebfc65 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 6 Mar 2024 20:46:14 -0800 Subject: [PATCH 07/27] Frontend changes for deseng-443 --- met-web/src/apiManager/endpoints/index.ts | 14 +- .../src/components/common/Dragdrop/index.tsx | 7 +- .../engagement/form/ActionContext.tsx | 64 ++-- .../AdditionalDetailsContext.tsx | 27 +- .../AdditionalTabContent.tsx | 6 +- .../EngagementInformation.tsx | 33 -- .../Metadata/EngagementMetadata.tsx | 187 ++++++++++ .../Metadata/TaxonInputComponents.tsx | 184 ++++++++++ .../EngagementTabsContext.tsx | 60 +++- .../Settings/EngagementSettingsContext.tsx | 11 +- .../src/components/engagement/form/types.ts | 8 +- .../engagement/view/ActionContext.tsx | 35 +- .../layout/SideNav/SideNavElements.tsx | 7 + .../metadataManagement/ActionContext.tsx | 140 ++++++++ .../metadataManagement/TaxonCard.tsx | 282 +++++++++++++++ .../metadataManagement/TaxonEditForm.tsx | 323 +++++++++++++++++ .../metadataManagement/TaxonEditor.tsx | 331 ++++++++++++++++++ .../metadataManagement/TaxonTypes.tsx | 158 +++++++++ .../components/metadataManagement/index.tsx | 13 + .../presetFieldsEditor/PresetValuesEditor.tsx | 105 ++++++ .../components/metadataManagement/types.ts | 69 ++++ .../src/components/survey/building/index.tsx | 7 +- met-web/src/models/engagement.ts | 26 +- met-web/src/routes/AuthenticatedRoutes.tsx | 2 + .../engagementMetadataService/index.ts | 76 +++- met-web/tests/unit/components/factory.ts | 17 +- 26 files changed, 2048 insertions(+), 144 deletions(-) delete mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/EngagementInformation.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx create mode 100644 met-web/src/components/metadataManagement/ActionContext.tsx create mode 100644 met-web/src/components/metadataManagement/TaxonCard.tsx create mode 100644 met-web/src/components/metadataManagement/TaxonEditForm.tsx create mode 100644 met-web/src/components/metadataManagement/TaxonEditor.tsx create mode 100644 met-web/src/components/metadataManagement/TaxonTypes.tsx create mode 100644 met-web/src/components/metadataManagement/index.tsx create mode 100644 met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx create mode 100644 met-web/src/components/metadataManagement/types.ts diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 95666efa6..d77a00db2 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 24a8651d4..7bada6241 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, useRef, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; -import { getEngagementMetadata } from '../../../services/engagementMetadataService'; +import { getEngagementMetadata, bulkPatchEngagementMetadata } 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'; @@ -27,15 +22,13 @@ export const ActionContext = createContext({ handleUpdateEngagementRequest: (_engagement: EngagementFormUpdate): Promise => { return Promise.reject(); }, - handleCreateEngagementMetadataRequest: (_engagement: EngagementMetadata): Promise => { - return Promise.reject(); - }, - handleUpdateEngagementMetadataRequest: (_engagement: EngagementMetadata): Promise => { + setTaxonMetadata(_taxonId, _values) { return Promise.reject(); }, + taxonMetadata: new Map(), isSaving: false, savedEngagement: createDefaultEngagement(), - engagementMetadata: createDefaultEngagementMetadata(), + engagementMetadata: [], engagementId: CREATE, loadingSavedEngagement: true, handleAddBannerImage: (_files: File[]) => { @@ -62,11 +55,10 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { const [loadingAuthorization, setLoadingAuthorization] = useState(true); const [savedEngagement, setSavedEngagement] = useState(createDefaultEngagement()); - 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[]) => { @@ -111,6 +103,33 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { 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 setTaxonMetadata = async (taxonId: number, values: Array): Promise => { + try { + const updatedMetadata = await bulkPatchEngagementMetadata(taxonId, Number(engagementId), values); + const result = engagementMetadata + .filter((metadata) => metadata.taxon_id !== taxonId) + .concat(updatedMetadata); + setEngagementMetadata(result); + return Promise.resolve(result); + } catch (err) { + console.log(err); + dispatch(openNotification({ severity: 'error', text: 'Error Updating Taxon Metadata' })); + return Promise.reject(err); + } + }; + const setEngagement = (engagement: Engagement) => { setSavedEngagement({ ...engagement }); setSavedBannerImageFileName(engagement.banner_filename); @@ -226,22 +245,11 @@ 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, + setTaxonMetadata, + taxonMetadata, loadingAuthorization, }} > diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalDetailsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalDetailsContext.tsx index 19cc4a973..1d0c58609 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalDetailsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalDetailsContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useMemo } from 'react'; +import React, { createContext, useContext, useState, useMemo, useRef } from 'react'; import { ActionContext } from '../../ActionContext'; import { EngagementTabsContext } from '../EngagementTabsContext'; import { useAppDispatch } from 'hooks'; @@ -13,6 +13,7 @@ export interface AdditionalDetailsContextState { handleSaveAdditional: () => void; updatingAdditional: boolean; hasBeenOpened: boolean; + metadataFormRef: React.RefObject | null; } export const AdditionalDetailsContext = createContext({ @@ -29,6 +30,7 @@ export const AdditionalDetailsContext = createContext { @@ -36,6 +38,8 @@ export const AdditionalDetailsContextProvider = ({ children }: { children: React const { engagementFormData } = useContext(EngagementTabsContext); const dispatch = useAppDispatch(); + const metadataFormRef = useRef(null); + const [updatingAdditional, setUpdatingAdditional] = useState(false); const [consentMessage, setConsentMessage] = useState(savedEngagement?.consent_message || ''); const [initialConsentMessage, setInitialConsentMessage] = useState(savedEngagement?.consent_message || ''); @@ -50,16 +54,30 @@ export const AdditionalDetailsContextProvider = ({ children }: { children: React const handleSaveAdditional = async () => { try { if (!savedEngagement.id) { - dispatch(openNotification({ text: 'Must create engagement first', severity: 'error' })); + dispatch(openNotification({ text: 'Engagement Content must be saved first', severity: 'error' })); return; } setUpdatingAdditional(true); + if (!metadataFormRef) dispatch(openNotification({ text: 'Metadata Form not found', severity: 'error' })); + const metadataResult = await metadataFormRef?.current?.submitForm(); + + if (metadataResult === false) { + dispatch( + openNotification({ + text: 'Please correct the highlighted errors before saving', + severity: 'error', + }), + ); + setUpdatingAdditional(false); + return; + } await handleUpdateEngagementAdditional(); setUpdatingAdditional(false); - dispatch(openNotification({ text: 'Engagement additional details saved', severity: 'success' })); + dispatch(openNotification({ text: 'Additional Details saved', severity: 'success' })); } catch (error) { setUpdatingAdditional(false); - dispatch(openNotification({ text: 'Error saving engagement additional details', severity: 'error' })); + console.log('Error saving Additional Details', error); + dispatch(openNotification({ text: 'Error saving Additional Details', severity: 'error' })); } }; @@ -76,6 +94,7 @@ export const AdditionalDetailsContextProvider = ({ children }: { children: React handleSaveAdditional, updatingAdditional, hasBeenOpened, + metadataFormRef, }), [initialConsentMessage, consentMessage, updatingAdditional, hasBeenOpened], ); 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 344b13dcf..198f2f267 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/AdditionalTabContent.tsx @@ -2,12 +2,12 @@ import React, { useContext } from 'react'; import { Divider, Grid } from '@mui/material'; import { MetPaper, PrimaryButton } from 'components/common'; import ConsentMessage from './ConsentMessage'; -import EngagementInformation from './EngagementInformation'; +import EngagementMetadata from './Metadata/EngagementMetadata'; import { AdditionalDetailsContext } from './AdditionalDetailsContext'; const AdditionalTabContent = () => { - const { handleSaveAdditional, updatingAdditional } = useContext(AdditionalDetailsContext); + const { handleSaveAdditional, updatingAdditional, metadataFormRef } = useContext(AdditionalDetailsContext); return ( @@ -20,7 +20,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..bba714515 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -0,0 +1,187 @@ +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 { MetadataTaxon } from 'models/engagement'; +import { TaxonTypes } from 'components/metadataManagement/TaxonTypes'; +import { TaxonFormValues } from 'components/metadataManagement/types'; +import { useTheme } from '@mui/material/styles'; +import { AdditionalDetailsContext } from '../AdditionalDetailsContext'; +import { ActionContext } from '../../../ActionContext'; +import * as yup from 'yup'; +import { defaultAutocomplete } from './TaxonInputComponents'; +import { yupResolver } from '@hookform/resolvers/yup'; + +const EngagementMetadata = forwardRef((_props, ref) => { + const { tenantTaxa } = useContext(EngagementTabsContext); + const { metadataFormRef } = useContext(AdditionalDetailsContext); + const { setTaxonMetadata, taxonMetadata } = useContext(ActionContext); + + const validationSchema = useMemo(() => { + const schemaShape: { [key: string]: yup.SchemaOf } = 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.SchemaOf }); // 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 onSubmit: SubmitHandler = () => { + const data = getValues(); + Object.entries(data).forEach(([id, value]) => { + const taxonId = Number(id); + const taxonMeta = taxonMetadata.get(taxonId) ?? []; + value = value ?? []; + // Normalize and clean the arrays + value = Array.isArray(value) + ? value.map((v) => v.toString().trim()).filter(Boolean) + : value.toString().trim(); + const normalizedTaxonMeta = taxonMeta.map((v) => v.toString().trim()).filter(Boolean); + value = Array.isArray(value) ? value : [value]; + if (JSON.stringify(value.sort()) === JSON.stringify(taxonMeta.sort())) return; + // If we reach here, arrays are not equal, proceed with update + console.log('Updating taxon metadata', normalizedTaxonMeta, taxonId, value); + setTaxonMetadata(taxonId, value); + }); + }; + + useImperativeHandle(ref, () => ({ + submitForm: async () => { + // validate the form + await handleSubmit(onSubmit)(); // manually trigger form submission + const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); // && (await validationSchema.isValid(getValues())); + console.log('Form is valid:', isValid); + // After submission, check if there are any errors + return isValid; + }, + })); + + const renderTaxonTile = (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(renderTaxonTile)} + +
+
+ ); +}); + +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..414f4eeef --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -0,0 +1,184 @@ +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, { 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(); + const errorMessage = taxon.one_per_engagement + ? Array.isArray(valueErrors) + ? valueErrors[0]?.message + : (valueErrors as FieldError)?.message + : (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 +const PickerTypes = { + DATE: 'DATE', + TIME: 'TIME', + DATETIME: 'DATETIME', +}; + +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 6b77d9c68..b82d44932 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, useMemo, useRef, useState } from 'react'; import { SubmissionStatusTypes, SUBMISSION_STATUS } from 'constants/engagementStatus'; import { User } from 'models/user'; import { ActionContext } from '../ActionContext'; @@ -6,7 +6,12 @@ import { EngagementTeamMember } from 'models/engagementTeamMember'; import { getTeamMembers } from 'services/membershipService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; -import { EngagementSettings, createDefaultEngagementSettings } from 'models/engagement'; +import { + EngagementMetadata, + EngagementSettings, + MetadataTaxon, + createDefaultEngagementSettings, +} from 'models/engagement'; import { updatedDiff } from 'deep-object-diff'; import { getSlugByEngagementId } from 'services/engagementSlugService'; import { @@ -14,6 +19,7 @@ import { getEngagementSettings, patchEngagementSettings, } from 'services/engagementSettingService'; +import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; interface EngagementFormData { name: string; @@ -56,6 +62,10 @@ const initialFormError = { export interface EngagementTabsContextState { engagementFormData: EngagementFormData; setEngagementFormData: React.Dispatch>; + tenantTaxa: MetadataTaxon[]; + setTenantTaxa: React.Dispatch>; + // engagementMetadata: EngagementMetadata[]; + metadataFormRef: React.RefObject | null; richDescription: string; setRichDescription: React.Dispatch>; richContent: string; @@ -84,6 +94,12 @@ export const EngagementTabsContext = createContext({ setEngagementFormData: () => { throw new Error('setEngagementFormData is unimplemented'); }, + // engagementMetadata: [], + metadataFormRef: null, + tenantTaxa: [], + setTenantTaxa: () => { + throw new Error('setTenantTaxa is unimplemented'); + }, richDescription: '', setRichDescription: () => { throw new Error('setRichDescription is unimplemented'); @@ -143,9 +159,11 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re is_internal: savedEngagement.is_internal || false, consent_message: savedEngagement.consent_message || '', }); + const [tenantTaxa, setTenantTaxa] = useState([]); const [richDescription, setRichDescription] = useState(savedEngagement?.rich_description || ''); const [richContent, setRichContent] = useState(savedEngagement?.rich_content || ''); const [engagementFormError, setEngagementFormError] = useState(initialFormError); + const metadataFormRef = useRef(null); // Survey block const [surveyBlockText, setSurveyBlockText] = useState<{ [key in SubmissionStatusTypes]: string }>({ @@ -243,6 +261,41 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re } }; + const fetchMetadata = async () => { + try { + const taxaData = await getMetadataTaxa(); + const engagementMetadata = await getEngagementMetadata(savedEngagement.id); + 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 (error) { + console.error('Error fetching taxa:', error); + } + }; + + useEffect(() => { + fetchMetadata(); + }, []); + + const updateMetadata = (taxonId: number, value: MetadataTaxon) => { + setTenantTaxa((prev) => { + const index = prev.findIndex((taxon) => taxon.id === taxonId); + if (index === -1) { + return prev; + } + const newTaxa = [...prev]; + newTaxa[index] = value; + return newTaxa; + }); + }; + const [savedSlug, setSavedSlug] = useState(''); const handleGetSlug = async () => { @@ -270,6 +323,9 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re value={{ engagementFormData, setEngagementFormData, + tenantTaxa, + setTenantTaxa, + metadataFormRef, richDescription, setRichDescription, richContent, diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx index 9412c5823..4731794dd 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Settings/EngagementSettingsContext.tsx @@ -32,8 +32,7 @@ export const EngagementSettingsContext = createContext { - const { handleUpdateEngagementMetadataRequest, engagementId, handleUpdateEngagementRequest, savedEngagement } = - useContext(ActionContext); + const { engagementId, handleUpdateEngagementRequest, savedEngagement } = useContext(ActionContext); const { engagementFormData, updateEngagementSettings, settings } = useContext(EngagementTabsContext); const dispatch = useAppDispatch(); @@ -42,13 +41,6 @@ export const EngagementSettingsContextProvider = ({ children }: { children: Reac const [sendReport, setSendReport] = useState(Boolean(settings.send_report)); const [updatingSettings, setUpdatingSettings] = useState(false); - const handleUpdateEngagementMetadata = () => { - return handleUpdateEngagementMetadataRequest({ - ...engagementFormData, - engagement_id: Number(engagementId), - }); - }; - const handleUpdateEngagementSettings = () => { return handleUpdateEngagementRequest({ ...engagementFormData, @@ -69,7 +61,6 @@ export const EngagementSettingsContextProvider = ({ children }: { children: Reac return; } setUpdatingSettings(true); - await handleUpdateEngagementMetadata(); await handleUpdateEngagementSettings(); await handleUpdateSettings(); setUpdatingSettings(false); diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index b389dca29..9beb72b0c 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -1,14 +1,14 @@ -import { Engagement, EngagementMetadata } from '../../../models/engagement'; +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; + setTaxonMetadata: (_taxonId: number, _values: Array) => Promise; + taxonMetadata: Map; isSaving: boolean; 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..fdfb126b8 100644 --- a/met-web/src/components/engagement/view/ActionContext.tsx +++ b/met-web/src/components/engagement/view/ActionContext.tsx @@ -2,12 +2,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, EngagementMetadata } from '../../../models/engagement'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; import { Widget } from 'models/widget'; @@ -30,10 +25,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 +48,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 +65,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 +181,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 +203,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 + setMetadataTaxa((prev) => { + const orderedTaxa = taxonIds.map((id) => prev.find((taxon) => taxon.id === id)); + return 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..7ca8884ef --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonCard.tsx @@ -0,0 +1,282 @@ +import { + Grid, + Paper, + Tooltip, + Chip, + IconButton, + Collapse, + Avatar, + Badge, + Typography, + Skeleton, + Stack, + Divider, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import { ExpandMore, DragIndicator, FormatQuote, EditAttributes, InsertDriveFile, FileCopy } from '@mui/icons-material'; +import React, { useContext } from 'react'; +import { MetHeader4 } from 'components/common'; +import { ActionContext } from './ActionContext'; +import { TaxonTypes } from './TaxonTypes'; +import { TaxonCardProps } from './types'; +import { Draggable, DraggableProvided } from '@hello-pangea/dnd'; + +export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpand, isSelected, onSelect, index }) => { + const theme = useTheme(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const { removeMetadataTaxon } = useContext(ActionContext); + 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 ( + + + + + + + + ); + }; + + const DetailsRow = ({ + name, + icon, + children, + }: { + name: string; + icon: React.ReactNode; + children: React.ReactNode; + }) => { + return ( + <> + + + + + + + {icon} + + + + + {children} + + + ); + }; + + return ( + + {(provided: DraggableProvided) => ( + onSelect(taxon)} + component={Paper} + style={{ + display: 'flex', + // boxSizing: 'border-box', + 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" + > + + + + + + + + + {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} + > + + + + + + + + {/* 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, index) => ( + + ))} + + )} + + + {/* 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..bd1444995 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -0,0 +1,323 @@ +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!'), + description: yup.string().max(255, 'Description is too long!').strip(), + 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 (error: 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) => { + console.log('Setting error for', fieldName); + setError(fieldName as keyof MetadataTaxon, { + type: 'validate', + message: formErrors[fieldName], + }); + }); + } + + if (!!Object.keys(formErrors).length) { + console.log('Form errors:', formErrors); + 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 : [], + }; + + const result = 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 && Boolean(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..50be84713 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -0,0 +1,331 @@ +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 { MetHeader2 } from 'components/common'; +import { MetDraggable, 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 ( + + + {/* Metadata Management */} + + 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..9695abeb6 --- /dev/null +++ b/met-web/src/components/metadataManagement/TaxonTypes.tsx @@ -0,0 +1,158 @@ +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 { FormControlLabel, Switch, TextField, Typography } from '@mui/material'; +import { TaxonPicker } 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: ({ 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'} + /> + ), + }, + 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: 'DATE' }), + }, + time: { + name: 'Time', + icon: Schedule, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup + .string() + // .uppercase() + .matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]( ?[AaPp][Mm])?$/, 'This field must be a valid time.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: 'TIME' }), + }, + datetime: { + name: 'Date and Time', + icon: EventNote, + supportsPresetValues: false, + supportsFreeform: false, + supportsMulti: false, + yupValidator: yup.date().typeError('This field must be a valid date and time.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: 'DATETIME' }), + }, + url: { + name: 'Web Link', + icon: Link, + supportsPresetValues: true, + supportsFreeform: true, + supportsMulti: true, + yupValidator: yup.string().url('This field 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 field 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 field 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..798f72758 --- /dev/null +++ b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { Autocomplete, TextField, Chip, IconButton, Stack } from '@mui/material'; +import { 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 +}: { + control: any; + 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: any, 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..7ad0360e7 --- /dev/null +++ b/met-web/src/components/metadataManagement/types.ts @@ -0,0 +1,69 @@ +import { SvgIconComponent } from '@mui/icons-material'; +import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete'; +import { EngagementMetadata, 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<{ + [x: string]: any; + }> + >; + setValue: ( + name: string, + 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..de937a4bd 100644 --- a/met-web/src/services/engagementMetadataService/index.ts +++ b/met-web/src/services/engagementMetadataService/index.ts @@ -1,14 +1,14 @@ 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); } - const response = await http.GetRequest(url); + const response = await http.GetRequest(url); if (response.data) { return response.data; } @@ -30,3 +30,73 @@ export const patchEngagementMetadata = async (data: EngagementMetadata): Promise } return Promise.reject('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; + } + return Promise.reject('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; + } + return Promise.reject('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))) { + return Promise.reject('Invalid Taxon Id ' + taxonId); + } + const response = await http.GetRequest(url); + if (response.data) { + return response.data; + } + return Promise.reject('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; + } + return Promise.reject('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; + } + return Promise.reject('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 Promise.resolve(); + } + return Promise.reject('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; + } + return Promise.reject('Failed to reorder metadata taxa'); +}; diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index aec9698e1..359f8120b 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'; @@ -163,8 +163,20 @@ const mockMap: WidgetMap = { }; 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 = { @@ -189,6 +201,7 @@ export { eventWidgetItem, eventWidget, engagementMetadata, + engagementMetadataTaxon, engagementSlugData, engagementSetting, }; From 202ade8c3fd0a10bf7cdfba05068445675552422 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Thu, 7 Mar 2024 11:18:04 -0800 Subject: [PATCH 08/27] Remove metadata references from frontend unit tests --- .../components/engagement/EngagementFormUserTab.test.tsx | 1 - .../form/edit/EngagementForm.Edit.One.test.tsx | 9 --------- .../form/edit/EngagementForm.Edit.Two.test.tsx | 1 - 3 files changed, 11 deletions(-) diff --git a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx index 8316feef2..b79890df1 100644 --- a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx +++ b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx @@ -88,7 +88,6 @@ 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(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); beforeEach(() => { 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 fde69dd1b..d82aca3c9 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 @@ -89,9 +89,6 @@ describe('Engagement form page tests', () => { .spyOn(notificationModalSlice, 'openNotificationModal') .mockImplementation(jest.fn()); const useParamsMock = jest.spyOn(reactRouter, 'useParams'); - const getEngagementMetadataMock = jest - .spyOn(engagementMetadataService, 'getEngagementMetadata') - .mockReturnValue(Promise.resolve(engagementMetadata)); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); jest.spyOn(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').mockReturnValue( @@ -134,7 +131,6 @@ describe('Engagement form page tests', () => { }); expect(getEngagementMock).toHaveBeenCalledOnce(); - expect(getEngagementMetadataMock).toHaveBeenCalledOnce(); expect(screen.getByTestId('update-engagement-button')).toBeVisible(); expect(screen.getByDisplayValue('2022-09-01')).toBeInTheDocument(); expect(screen.getByDisplayValue('2022-09-30')).toBeInTheDocument(); @@ -184,11 +180,6 @@ describe('Engagement form page tests', () => { surveys: surveys, }), ); - getEngagementMetadataMock.mockReturnValueOnce( - Promise.resolve({ - ...engagementMetadata, - }), - ); render(); await waitFor(() => { 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..b68f38d52 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,6 @@ describe('Engagement form page tests', () => { jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').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') From 95b095c97f8d975a05168281c57dae3883039e31 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Thu, 7 Mar 2024 11:18:20 -0800 Subject: [PATCH 09/27] Update Changelog --- CHANGELOG.MD | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 00b90cbef..997406dc2 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,10 +1,14 @@ -## Current: feature/DESENG-443: Engagement Metadata Management +## 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). +- **Feature**: Add editor for metadata entries (available to anyone who can + edit an engagement). ## February 08, 2024 From 85f7233d54cb575aa68ead5e5144f87bf964de73 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Thu, 7 Mar 2024 11:38:03 -0800 Subject: [PATCH 10/27] Update unit tests to handle metadata as an array --- .../engagement/EngagementFormUserTab.test.tsx | 3 +++ .../form/create/EngagementForm.Create.test.tsx | 2 +- .../form/edit/EngagementForm.Edit.One.test.tsx | 10 +++++++++- .../form/edit/EngagementForm.Edit.Two.test.tsx | 3 +++ .../unit/components/widgets/DocumentWidget.test.tsx | 4 +++- .../unit/components/widgets/EventsWidget.test.tsx | 4 +++- .../tests/unit/components/widgets/MapWidget.test.tsx | 2 +- .../unit/components/widgets/PhasesWidget.test.tsx | 4 +++- .../components/widgets/WhoIsListeningWidget.test.tsx | 6 ++++-- 9 files changed, 30 insertions(+), 8 deletions(-) diff --git a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx index b79890df1..cc88e77a0 100644 --- a/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx +++ b/met-web/tests/unit/components/engagement/EngagementFormUserTab.test.tsx @@ -88,6 +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(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 843e88b1a..241b7fa24 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 d82aca3c9..15620ad15 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 @@ -89,6 +89,9 @@ describe('Engagement form page tests', () => { .spyOn(notificationModalSlice, 'openNotificationModal') .mockImplementation(jest.fn()); const useParamsMock = jest.spyOn(reactRouter, 'useParams'); + const getEngagementMetadataMock = jest + .spyOn(engagementMetadataService, 'getEngagementMetadata') + .mockReturnValue(Promise.resolve([engagementMetadata])); jest.spyOn(engagementSettingService, 'getEngagementSettings').mockReturnValue(Promise.resolve(engagementSetting)); jest.spyOn(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); jest.spyOn(engagementMetadataService, 'patchEngagementMetadata').mockReturnValue( @@ -131,6 +134,7 @@ describe('Engagement form page tests', () => { }); expect(getEngagementMock).toHaveBeenCalledOnce(); + expect(getEngagementMetadataMock).toHaveBeenCalledOnce(); expect(screen.getByTestId('update-engagement-button')).toBeVisible(); expect(screen.getByDisplayValue('2022-09-01')).toBeInTheDocument(); expect(screen.getByDisplayValue('2022-09-30')).toBeInTheDocument(); @@ -185,7 +189,11 @@ describe('Engagement form page tests', () => { 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 b68f38d52..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,6 +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(teamMemberService, 'getTeamMembers').mockReturnValue(Promise.resolve([])); const getEngagementMock = jest .spyOn(engagementService, 'getEngagement') 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/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), From 3fd43ea85c80f98205590fed595f88bb5e862894 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Thu, 7 Mar 2024 11:38:33 -0800 Subject: [PATCH 11/27] Clean up unused imports from frontend files --- met-web/src/components/engagement/form/ActionContext.tsx | 6 ++---- met-web/src/components/engagement/view/ActionContext.tsx | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index 7bada6241..acb02fc6f 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,9 +1,9 @@ -import React, { createContext, useState, useEffect, useRef, useMemo } from 'react'; +import React, { createContext, useState, useEffect, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; import { getEngagementMetadata, bulkPatchEngagementMetadata } from '../../../services/engagementMetadataService'; import { useNavigate, useParams } from 'react-router-dom'; import { EngagementContext, EngagementForm, EngagementFormUpdate, EngagementParams } from './types'; -import { createDefaultEngagement, Engagement, EngagementMetadata, MetadataTaxon } from '../../../models/engagement'; +import { createDefaultEngagement, Engagement, EngagementMetadata } from '../../../models/engagement'; import { saveObject } from 'services/objectStorageService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch, useAppSelector } from 'hooks'; @@ -15,7 +15,6 @@ 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(); }, @@ -94,7 +93,6 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { if (isCreate) { return; } - try { const engagementMetaData = await getEngagementMetadata(Number(engagementId)); setEngagementMetadata(engagementMetaData); diff --git a/met-web/src/components/engagement/view/ActionContext.tsx b/met-web/src/components/engagement/view/ActionContext.tsx index fdfb126b8..a205405c9 100644 --- a/met-web/src/components/engagement/view/ActionContext.tsx +++ b/met-web/src/components/engagement/view/ActionContext.tsx @@ -1,8 +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, 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'; From 942a009622d7ca6be103457d1607790e060799a2 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Thu, 7 Mar 2024 17:01:31 -0800 Subject: [PATCH 12/27] Add ticket links to changelog --- CHANGELOG.MD | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 997406dc2..c8643d54c 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -6,9 +6,9 @@ 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). +- **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). + edit an engagement). [🎟️DESENG-443](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-443) ## February 08, 2024 From b933803b6f8b0a83812336d234fb0289de00f4cd Mon Sep 17 00:00:00 2001 From: NatSquared Date: Fri, 8 Mar 2024 17:37:22 -0800 Subject: [PATCH 13/27] Adapt metadata to changes from main --- .../engagement/form/ActionContext.tsx | 26 ++++- .../Metadata/EngagementMetadata.tsx | 26 ++--- .../Metadata/TaxonInputComponents.tsx | 2 +- .../EngagementTabsContext.tsx | 105 +++--------------- .../src/components/engagement/form/types.ts | 2 + .../metadataManagement/TaxonTypes.tsx | 31 +----- 6 files changed, 61 insertions(+), 131 deletions(-) diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index 82b0c005f..d1c0793af 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,9 +1,13 @@ import React, { createContext, useState, useEffect, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; -import { getEngagementMetadata, bulkPatchEngagementMetadata } from '../../../services/engagementMetadataService'; +import { + getEngagementMetadata, + bulkPatchEngagementMetadata, + getMetadataTaxa, +} from '../../../services/engagementMetadataService'; import { useNavigate, useParams } from 'react-router-dom'; import { EngagementContext, EngagementForm, EngagementFormUpdate, EngagementParams } from './types'; -import { createDefaultEngagement, 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'; @@ -21,6 +25,10 @@ export const ActionContext = createContext({ handleUpdateEngagementRequest: (_engagement: EngagementFormUpdate): Promise => { return Promise.reject(); }, + tenantTaxa: [], + setTenantTaxa: () => { + throw new Error('setTenantTaxa is unimplemented'); + }, setTaxonMetadata(_taxonId, _values) { return Promise.reject(); }, @@ -60,6 +68,7 @@ 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([]); @@ -104,6 +113,17 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { 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' })); @@ -258,6 +278,8 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { value={{ handleCreateEngagementRequest, handleUpdateEngagementRequest, + tenantTaxa, + setTenantTaxa, isSaving, setSaving, savedEngagement, 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 index bba714515..dfb1c5915 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -7,16 +7,14 @@ import { MetadataTaxon } from 'models/engagement'; import { TaxonTypes } from 'components/metadataManagement/TaxonTypes'; import { TaxonFormValues } from 'components/metadataManagement/types'; import { useTheme } from '@mui/material/styles'; -import { AdditionalDetailsContext } from '../AdditionalDetailsContext'; import { ActionContext } from '../../../ActionContext'; import * as yup from 'yup'; import { defaultAutocomplete } from './TaxonInputComponents'; import { yupResolver } from '@hookform/resolvers/yup'; const EngagementMetadata = forwardRef((_props, ref) => { - const { tenantTaxa } = useContext(EngagementTabsContext); - const { metadataFormRef } = useContext(AdditionalDetailsContext); - const { setTaxonMetadata, taxonMetadata } = useContext(ActionContext); + const { metadataFormRef } = useContext(EngagementTabsContext); + const { tenantTaxa, setTaxonMetadata, taxonMetadata } = useContext(ActionContext); const validationSchema = useMemo(() => { const schemaShape: { [key: string]: yup.SchemaOf } = tenantTaxa.reduce((acc, taxon) => { @@ -65,22 +63,24 @@ const EngagementMetadata = forwardRef((_props, ref) => { resolver: yupResolver(validationSchema), }); - const onSubmit: SubmitHandler = () => { + const cleanArray = (arr: string[]) => arr.map((v) => v.trim()).filter(Boolean); + + const onSubmit: SubmitHandler = async () => { const data = getValues(); - Object.entries(data).forEach(([id, value]) => { + console.log('Submitting form', data); + Object.entries(data).forEach(async ([id, value]) => { const taxonId = Number(id); const taxonMeta = taxonMetadata.get(taxonId) ?? []; value = value ?? []; // Normalize and clean the arrays - value = Array.isArray(value) - ? value.map((v) => v.toString().trim()).filter(Boolean) - : value.toString().trim(); - const normalizedTaxonMeta = taxonMeta.map((v) => v.toString().trim()).filter(Boolean); + value = Array.isArray(value) ? cleanArray(value) : value.toString().trim(); + const normalizedTaxonMeta = cleanArray(taxonMeta); value = Array.isArray(value) ? value : [value]; + console.log('Comparing taxon metadata', normalizedTaxonMeta, taxonId, value); if (JSON.stringify(value.sort()) === JSON.stringify(taxonMeta.sort())) return; // If we reach here, arrays are not equal, proceed with update console.log('Updating taxon metadata', normalizedTaxonMeta, taxonId, value); - setTaxonMetadata(taxonId, value); + await setTaxonMetadata(taxonId, value); }); }; @@ -88,8 +88,8 @@ const EngagementMetadata = forwardRef((_props, ref) => { submitForm: async () => { // validate the form await handleSubmit(onSubmit)(); // manually trigger form submission - const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); // && (await validationSchema.isValid(getValues())); - console.log('Form is valid:', isValid); + const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); + // After submission, check if there are any errors return isValid; }, 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 index 414f4eeef..55d93daa2 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -112,7 +112,7 @@ export const taxonSwitch = ({ taxon, field, setValue, errors }: TaxonInputProps) control={ { setValue(taxon.id.toString(), e.target.checked); }} diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index d25febb2e..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, useMemo, useRef, 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'; @@ -7,12 +7,7 @@ import { EngagementTeamMember } from 'models/engagementTeamMember'; import { getTeamMembers } from 'services/membershipService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; -import { - EngagementMetadata, - EngagementSettings, - MetadataTaxon, - createDefaultEngagementSettings, -} from 'models/engagement'; +import { EngagementSettings, createDefaultEngagementSettings } from 'models/engagement'; import { updatedDiff } from 'deep-object-diff'; import { getSlugByEngagementId } from 'services/engagementSlugService'; import { @@ -20,7 +15,6 @@ import { getEngagementSettings, patchEngagementSettings, } from 'services/engagementSettingService'; -import { getEngagementMetadata, getMetadataTaxa } from 'services/engagementMetadataService'; import { EngagementSlugPatchRequest, patchEngagementSlug } from 'services/engagementSlugService'; import { EngagementForm } from '../types'; import { SubmissionStatus } from 'constants/engagementStatus'; @@ -78,9 +72,6 @@ const initialFormError = { export interface EngagementTabsContextState { engagementFormData: EngagementFormData; setEngagementFormData: React.Dispatch>; - tenantTaxa: MetadataTaxon[]; - setTenantTaxa: React.Dispatch>; - // engagementMetadata: EngagementMetadata[]; metadataFormRef: React.RefObject | null; handleSaveAndContinueEngagement: () => Promise; handlePreviewEngagement: () => Promise; @@ -118,12 +109,7 @@ export const EngagementTabsContext = createContext({ setEngagementFormData: () => { throw new Error('setEngagementFormData is unimplemented'); }, - // engagementMetadata: [], metadataFormRef: null, - tenantTaxa: [], - setTenantTaxa: () => { - throw new Error('setTenantTaxa is unimplemented'); - }, handleSaveAndContinueEngagement: async () => { /* empty default method for engagement save and continue */ }, @@ -205,7 +191,6 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re is_internal: savedEngagement.is_internal || false, consent_message: savedEngagement.consent_message || '', }); - const [tenantTaxa, setTenantTaxa] = useState([]); const [richDescription, setRichDescription] = useState(savedEngagement?.rich_description || ''); const [richContent, setRichContent] = useState(savedEngagement?.rich_content || ''); const [richConsentMessage, setRichConsentMessage] = useState(savedEngagement?.consent_message || ''); @@ -316,76 +301,6 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re } }; - const fetchMetadata = async () => { - try { - const taxaData = await getMetadataTaxa(); - const engagementMetadata = await getEngagementMetadata(savedEngagement.id); - 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 (error) { - console.error('Error fetching taxa:', error); - } - }; - - useEffect(() => { - fetchMetadata(); - }, []); - - const updateMetadata = (taxonId: number, value: MetadataTaxon) => { - setTenantTaxa((prev) => { - const index = prev.findIndex((taxon) => taxon.id === taxonId); - if (index === -1) { - return prev; - } - const newTaxa = [...prev]; - newTaxa[index] = value; - return newTaxa; - }); - }; - - const fetchMetadata = async () => { - try { - const taxaData = await getMetadataTaxa(); - const engagementMetadata = await getEngagementMetadata(savedEngagement.id); - 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 (error) { - console.error('Error fetching taxa:', error); - } - }; - - useEffect(() => { - fetchMetadata(); - }, []); - - const updateMetadata = (taxonId: number, value: MetadataTaxon) => { - setTenantTaxa((prev) => { - const index = prev.findIndex((taxon) => taxon.id === taxonId); - if (index === -1) { - return prev; - } - const newTaxa = [...prev]; - newTaxa[index] = value; - return newTaxa; - }); - }; - const [savedSlug, setSavedSlug] = useState(''); const [slug, setSlug] = useState(initialEngagementSettingsSlugData); @@ -466,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(); @@ -490,6 +418,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re if (!isNewEngagement) { await updateEngagementSettings(sendReport); await handleSaveSlug(slug); + await handleSaveEngagementMetadata(); } return engagement; @@ -527,8 +456,6 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re value={{ engagementFormData, setEngagementFormData, - tenantTaxa, - setTenantTaxa, metadataFormRef, handleSaveAndContinueEngagement, handlePreviewEngagement, diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index c393c0424..4160b9a2a 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -6,6 +6,8 @@ export interface EngagementContext { handleUpdateEngagementRequest: (_engagement: EngagementFormUpdate) => Promise; setTaxonMetadata: (_taxonId: number, _values: Array) => Promise; taxonMetadata: Map; + tenantTaxa: MetadataTaxon[]; + setTenantTaxa: React.Dispatch>; isSaving: boolean; setSaving: React.Dispatch>; savedEngagement: Engagement; diff --git a/met-web/src/components/metadataManagement/TaxonTypes.tsx b/met-web/src/components/metadataManagement/TaxonTypes.tsx index 9695abeb6..6edc8a82b 100644 --- a/met-web/src/components/metadataManagement/TaxonTypes.tsx +++ b/met-web/src/components/metadataManagement/TaxonTypes.tsx @@ -14,7 +14,10 @@ import { TaxonType, GenericInputProps as TaxonInputProps } from './types'; import * as yup from 'yup'; import React from 'react'; import { FormControlLabel, Switch, TextField, Typography } from '@mui/material'; -import { TaxonPicker } from 'components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents'; +import { + TaxonPicker, + taxonSwitch, +} from 'components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents'; export const TaxonTypes: { [key: string]: TaxonType } = { text: { @@ -64,31 +67,7 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsFreeform: false, supportsMulti: false, yupValidator: yup.boolean(), - customInput: ({ 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'} - /> - ), + customInput: taxonSwitch, }, date: { name: 'Date', From 09462ed55cfae7a0438e6bd40b60f48e38e24af0 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 03:22:45 -0700 Subject: [PATCH 14/27] Format and save date properly --- .../Metadata/TaxonInputComponents.tsx | 4 ++-- .../metadataManagement/TaxonTypes.tsx | 22 +++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) 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 index 55d93daa2..39e0f4455 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -134,13 +134,13 @@ export const taxonSwitch = ({ taxon, field, setValue, errors }: TaxonInputProps) ); // Unified component for different types of pickers -const PickerTypes = { +export const PickerTypes = { DATE: 'DATE', TIME: 'TIME', DATETIME: 'DATETIME', }; -const inputFormats = { +export const inputFormats = { [PickerTypes.DATE]: 'yyyy-MM-dd', [PickerTypes.TIME]: 'hh:mm a', [PickerTypes.DATETIME]: 'yyyy-MM-dd hh:mm a', diff --git a/met-web/src/components/metadataManagement/TaxonTypes.tsx b/met-web/src/components/metadataManagement/TaxonTypes.tsx index 6edc8a82b..a4df174e8 100644 --- a/met-web/src/components/metadataManagement/TaxonTypes.tsx +++ b/met-web/src/components/metadataManagement/TaxonTypes.tsx @@ -13,9 +13,10 @@ import { import { TaxonType, GenericInputProps as TaxonInputProps } from './types'; import * as yup from 'yup'; import React from 'react'; -import { FormControlLabel, Switch, TextField, Typography } from '@mui/material'; +import { TextField } from '@mui/material'; import { TaxonPicker, + PickerTypes, taxonSwitch, } from 'components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents'; @@ -76,7 +77,7 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsFreeform: false, supportsMulti: false, yupValidator: yup.date().typeError('This value must be a valid date.'), - customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: 'DATE' }), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: PickerTypes.DATE }), }, time: { name: 'Time', @@ -84,11 +85,8 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsPresetValues: false, supportsFreeform: false, supportsMulti: false, - yupValidator: yup - .string() - // .uppercase() - .matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]( ?[AaPp][Mm])?$/, 'This field must be a valid time.'), - customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: 'TIME' }), + yupValidator: yup.date().typeError('This value must be a valid time.'), + customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: PickerTypes.TIME }), }, datetime: { name: 'Date and Time', @@ -96,8 +94,8 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsPresetValues: false, supportsFreeform: false, supportsMulti: false, - yupValidator: yup.date().typeError('This field must be a valid date and time.'), - customInput: ({ ...props }: TaxonInputProps) => TaxonPicker({ ...props, pickerType: 'DATETIME' }), + 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', @@ -105,7 +103,7 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsPresetValues: true, supportsFreeform: true, supportsMulti: true, - yupValidator: yup.string().url('This field must be a valid web URL.'), + yupValidator: yup.string().url('This value must be a valid web URL.'), externalResource: (value: string) => value, externalResourceLabel: 'Open', }, @@ -115,7 +113,7 @@ export const TaxonTypes: { [key: string]: TaxonType } = { supportsPresetValues: true, supportsFreeform: true, supportsMulti: true, - yupValidator: yup.string().email('This field must be a valid email address.'), + yupValidator: yup.string().email('This value must be a valid email address.'), externalResource: (value: string) => `mailto:${value}`, externalResourceLabel: 'Email', }, @@ -129,7 +127,7 @@ export const TaxonTypes: { [key: string]: TaxonType } = { .string() .matches( /^(\+?\d{1,3}[\s-]?)?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}$/, - 'This field must be a valid phone number.', + 'This value must be a valid phone number.', ), externalResource: (value: string) => `tel:${value}`, externalResourceLabel: 'Call', From 4e2bfccdd343f3c59c1ec17f773fa7533ece9f67 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 03:26:54 -0700 Subject: [PATCH 15/27] Save changes stably without re-renders --- .../engagement/form/ActionContext.tsx | 19 +-------- .../Metadata/EngagementMetadata.tsx | 40 +++++++++++++------ .../src/components/engagement/form/types.ts | 3 +- .../metadataManagement/TaxonEditForm.tsx | 1 - 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index d1c0793af..c4f2e6b3c 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -29,7 +29,7 @@ export const ActionContext = createContext({ setTenantTaxa: () => { throw new Error('setTenantTaxa is unimplemented'); }, - setTaxonMetadata(_taxonId, _values) { + setEngagementMetadata() { return Promise.reject(); }, taxonMetadata: new Map(), @@ -141,21 +141,6 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { return taxonMetadataMap; }, [engagementMetadata]); - const setTaxonMetadata = async (taxonId: number, values: Array): Promise => { - try { - const updatedMetadata = await bulkPatchEngagementMetadata(taxonId, Number(engagementId), values); - const result = engagementMetadata - .filter((metadata) => metadata.taxon_id !== taxonId) - .concat(updatedMetadata); - setEngagementMetadata(result); - return Promise.resolve(result); - } catch (err) { - console.log(err); - dispatch(openNotification({ severity: 'error', text: 'Error Updating Taxon Metadata' })); - return Promise.reject(err); - } - }; - const setEngagement = (engagement: Engagement) => { setSavedEngagement({ ...engagement }); setIsNewEngagement(!savedEngagement.id); @@ -289,7 +274,7 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { handleAddBannerImage, fetchEngagement, fetchEngagementMetadata, - setTaxonMetadata, + setEngagementMetadata, taxonMetadata, loadingAuthorization, isNewEngagement, 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 index dfb1c5915..6e76ef371 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -3,7 +3,7 @@ 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 { MetadataTaxon } from 'models/engagement'; +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'; @@ -11,10 +11,15 @@ 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, setTaxonMetadata, taxonMetadata } = useContext(ActionContext); + const { tenantTaxa, engagementMetadata, setEngagementMetadata, engagementId, taxonMetadata } = + useContext(ActionContext); + const dispatch = useAppDispatch(); const validationSchema = useMemo(() => { const schemaShape: { [key: string]: yup.SchemaOf } = tenantTaxa.reduce((acc, taxon) => { @@ -67,21 +72,31 @@ const EngagementMetadata = forwardRef((_props, ref) => { const onSubmit: SubmitHandler = async () => { const data = getValues(); - console.log('Submitting form', data); - Object.entries(data).forEach(async ([id, value]) => { + const updatedEntries = new Map(); + for (const [id, value] of Object.entries(data)) { const taxonId = Number(id); const taxonMeta = taxonMetadata.get(taxonId) ?? []; - value = value ?? []; + let taxonValue = value ?? []; // Normalize and clean the arrays - value = Array.isArray(value) ? cleanArray(value) : value.toString().trim(); + taxonValue = cleanArray(Array.isArray(taxonValue) ? taxonValue : [taxonValue]); const normalizedTaxonMeta = cleanArray(taxonMeta); - value = Array.isArray(value) ? value : [value]; - console.log('Comparing taxon metadata', normalizedTaxonMeta, taxonId, value); - if (JSON.stringify(value.sort()) === JSON.stringify(taxonMeta.sort())) return; + if (JSON.stringify(taxonValue.sort()) === JSON.stringify(taxonMeta.sort())) continue; // If we reach here, arrays are not equal, proceed with update - console.log('Updating taxon metadata', normalizedTaxonMeta, taxonId, value); - await setTaxonMetadata(taxonId, value); - }); + console.log('Updating taxon metadata', normalizedTaxonMeta, taxonId, taxonValue); + try { + const updatedMetadata = await bulkPatchEngagementMetadata(taxonId, Number(engagementId), taxonValue); + updatedEntries.set(taxonId, updatedMetadata); + } catch (err) { + console.log(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, () => ({ @@ -89,7 +104,6 @@ const EngagementMetadata = forwardRef((_props, ref) => { // validate the form await handleSubmit(onSubmit)(); // manually trigger form submission const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); - // After submission, check if there are any errors return isValid; }, diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index 4160b9a2a..3f3b1c225 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -1,10 +1,11 @@ +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; - setTaxonMetadata: (_taxonId: number, _values: Array) => Promise; + setEngagementMetadata: React.Dispatch>; taxonMetadata: Map; tenantTaxa: MetadataTaxon[]; setTenantTaxa: React.Dispatch>; diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx index bd1444995..fec3c62ba 100644 --- a/met-web/src/components/metadataManagement/TaxonEditForm.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -105,7 +105,6 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { }, {}); // Set errors for each field in formState Object.keys(formErrors).forEach((fieldName) => { - console.log('Setting error for', fieldName); setError(fieldName as keyof MetadataTaxon, { type: 'validate', message: formErrors[fieldName], From a2e61620882291b6f1bf4a3365858c6bfeae5823 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 10:22:06 -0700 Subject: [PATCH 16/27] Python: fix lint errors --- met-api/src/met_api/resources/engagement_metadata.py | 1 - met-api/src/met_api/services/engagement_metadata_service.py | 2 +- met-api/src/met_api/services/metadata_taxon_service.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/met-api/src/met_api/resources/engagement_metadata.py b/met-api/src/met_api/resources/engagement_metadata.py index f1c446abf..b9baa9f75 100644 --- a/met-api/src/met_api/resources/engagement_metadata.py +++ b/met-api/src/met_api/resources/engagement_metadata.py @@ -18,7 +18,6 @@ """ from http import HTTPStatus -import re from flask import request from flask_cors import cross_origin 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 5525d65c2..9436c55e7 100644 --- a/met-api/src/met_api/services/engagement_metadata_service.py +++ b/met-api/src/met_api/services/engagement_metadata_service.py @@ -47,7 +47,7 @@ def get_by_engagement(engagement_id, taxon_id=None) -> List[dict]: raise KeyError( f'Engagement with id {engagement_id} does not exist.') results = engagement_model.metadata - if (taxon_id): + if taxon_id: results = [item for item in results if item.taxon_id == taxon_id] return EngagementMetadataSchema(many=True).dump(results) 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 8be9af5e7..7615b4391 100644 --- a/met-api/src/met_api/services/metadata_taxon_service.py +++ b/met-api/src/met_api/services/metadata_taxon_service.py @@ -4,9 +4,9 @@ from met_api.models import db from met_api.models.db import transactional -from met_api.models.engagement_metadata import MetadataTaxon, EngagementMetadata +from met_api.models.engagement_metadata import MetadataTaxon from met_api.models.tenant import Tenant -from met_api.schemas.engagement_metadata import EngagementMetadataSchema, MetadataTaxonSchema +from met_api.schemas.engagement_metadata import MetadataTaxonSchema class MetadataTaxonService: From 331a5402d0ee5d139de55792dae0d0edf1a0ee56 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 11:40:22 -0700 Subject: [PATCH 17/27] Apply suggestions from JS linter; update migration file --- ...f4f_remove_taxon_default_add_constraint.py | 4 +- .../engagement/form/ActionContext.tsx | 6 +- .../Metadata/EngagementMetadata.tsx | 7 +- .../Metadata/TaxonInputComponents.tsx | 35 ++++++---- .../metadataManagement/ActionContext.tsx | 6 +- .../metadataManagement/TaxonCard.tsx | 69 ++++++++----------- .../metadataManagement/TaxonEditForm.tsx | 21 +++--- .../metadataManagement/TaxonEditor.tsx | 3 +- .../presetFieldsEditor/PresetValuesEditor.tsx | 9 +-- .../engagementMetadataService/index.ts | 24 +++---- 10 files changed, 88 insertions(+), 96 deletions(-) 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 index bb98e4dd9..2743b2d9c 100644 --- a/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py +++ b/met-api/migrations/versions/dbe023373f4f_remove_taxon_default_add_constraint.py @@ -1,7 +1,7 @@ """Remove default_value from engagement_metadata_taxa and add unique constraint Revision ID: dbe023373f4f -Revises: e6c320c178fc +Revises: 35124d2e41cb Create Date: 2024-01-30 17:05:25.313222 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = 'dbe023373f4f' -down_revision = 'e6c320c178fc' +down_revision = '35124d2e41cb' branch_labels = None depends_on = None diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index c4f2e6b3c..ab320d7d7 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,10 +1,6 @@ import React, { createContext, useState, useEffect, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; -import { - getEngagementMetadata, - bulkPatchEngagementMetadata, - getMetadataTaxa, -} 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, Engagement, EngagementMetadata, MetadataTaxon } from '../../../models/engagement'; 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 index 6e76ef371..422634549 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -22,7 +22,8 @@ const EngagementMetadata = forwardRef((_props, ref) => { const dispatch = useAppDispatch(); const validationSchema = useMemo(() => { - const schemaShape: { [key: string]: yup.SchemaOf } = tenantTaxa.reduce((acc, taxon) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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) { @@ -32,7 +33,7 @@ const EngagementMetadata = forwardRef((_props, ref) => { } } return acc; - }, {} as { [key: string]: yup.SchemaOf }); // Add index signature to the initial value of acc + }, {} as { [key: string]: yup.AnySchema }); // Add index signature to the initial value of acc return yup.object().shape(schemaShape); }, [tenantTaxa]); @@ -122,7 +123,7 @@ const EngagementMetadata = forwardRef((_props, ref) => { justifyContent="flex-start" alignItems="center" flexBasis="auto" - key={index} + key={taxon.id} spacing={1} padding={2} xs={12} 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 index 39e0f4455..49aebf546 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -1,7 +1,7 @@ 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, { useState } from 'react'; +import React, { ReactElement, useState } from 'react'; import { FormControlLabel, Switch, @@ -19,19 +19,24 @@ export const defaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, const valueErrors = (errors[taxon.id.toString()] as unknown as Array | FieldError) ?? []; const errorIndices = new Set(); - const errorMessage = taxon.one_per_engagement - ? Array.isArray(valueErrors) - ? valueErrors[0]?.message - : (valueErrors as FieldError)?.message - : (valueErrors as Array)?.map((error: FieldError, index: number) => { - errorIndices.add(index); - return ( - - Entry #{index + 1}: {error.message} -
-
- ); - }); + 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) { @@ -47,7 +52,7 @@ export const defaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, variant="outlined" label={option} {...getTagProps({ index })} - key={index} + key={option} color={errorIndices.has(index) ? 'error' : 'default'} disabled={errorIndices.has(index) && !!taxonType.externalResource} onClick={taxonType.externalResource ? handleChipClick(option) : undefined} diff --git a/met-web/src/components/metadataManagement/ActionContext.tsx b/met-web/src/components/metadataManagement/ActionContext.tsx index 1e7553a6e..97c3cf0b4 100644 --- a/met-web/src/components/metadataManagement/ActionContext.tsx +++ b/met-web/src/components/metadataManagement/ActionContext.tsx @@ -97,10 +97,8 @@ const ActionProvider = ({ children }: { children: JSX.Element }) => { const reorderMetadataTaxa = async (taxonIds: number[]) => { try { // Client side reorder to prevent flicker - setMetadataTaxa((prev) => { - const orderedTaxa = taxonIds.map((id) => prev.find((taxon) => taxon.id === id)); - return orderedTaxa.filter((taxon) => taxon !== undefined) as MetadataTaxon[]; - }); + 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) { diff --git a/met-web/src/components/metadataManagement/TaxonCard.tsx b/met-web/src/components/metadataManagement/TaxonCard.tsx index 7ca8884ef..55097cfd8 100644 --- a/met-web/src/components/metadataManagement/TaxonCard.tsx +++ b/met-web/src/components/metadataManagement/TaxonCard.tsx @@ -13,19 +13,40 @@ import { Divider, } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import { ExpandMore, DragIndicator, FormatQuote, EditAttributes, InsertDriveFile, FileCopy } from '@mui/icons-material'; -import React, { useContext } from 'react'; +import React from 'react'; import { MetHeader4 } from 'components/common'; -import { ActionContext } from './ActionContext'; 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 isSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); - const { removeMetadataTaxon } = useContext(ActionContext); const cardStyle = isSelected ? { backgroundColor: theme.palette.primary.light, @@ -65,38 +86,6 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan ); }; - const DetailsRow = ({ - name, - icon, - children, - }: { - name: string; - icon: React.ReactNode; - children: React.ReactNode; - }) => { - return ( - <> - - - - - - - {icon} - - - - - {children} - - - ); - }; - return ( {(provided: DraggableProvided) => ( @@ -154,7 +143,7 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan + transition: (theme) => theme.transitions.create('transform', { duration: theme.transitions.duration.shortest, }), @@ -198,9 +187,9 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan )} {(taxon.preset_values?.length ?? 0) > 0 && ( - {taxon.preset_values?.map((chip, index) => ( + {taxon.preset_values?.map((chip) => ( { 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 || [], + 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]); @@ -98,7 +98,10 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { if (!taxonType.supportsPresetValues) data.preset_values = []; try { await schema.validate(data, { abortEarly: false }); - } catch (error: any) { + // 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; @@ -130,7 +133,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { preset_values: TaxonTypes[data.data_type ?? 'text'].supportsPresetValues ? data.preset_values : [], }; - const result = updateMetadataTaxon(updatedTaxon); + updateMetadataTaxon(updatedTaxon); }; const handleKeys = (event: React.KeyboardEvent) => { @@ -148,7 +151,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { const modifierKey = !isMac() ? 'Ctrl' : '⌘'; // Whether the options can be limited to preset values - const allowLimiting = taxonType.supportsFreeform && Boolean(presetValues?.length ?? 0 > 0); + const allowLimiting = taxonType.supportsFreeform && (presetValues?.length ?? 0) > 0; return ( diff --git a/met-web/src/components/metadataManagement/TaxonEditor.tsx b/met-web/src/components/metadataManagement/TaxonEditor.tsx index 50be84713..2e601f3a7 100644 --- a/met-web/src/components/metadataManagement/TaxonEditor.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -6,8 +6,7 @@ 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 { MetHeader2 } from 'components/common'; -import { MetDraggable, MetDroppable } from 'components/common/Dragdrop'; +import { MetDroppable } from 'components/common/Dragdrop'; import { ActionContext } from './ActionContext'; import TaxonEditForm from './TaxonEditForm'; import { Else, If, Then } from 'react-if'; diff --git a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx index 798f72758..65eda8bf7 100644 --- a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx +++ b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx @@ -1,13 +1,14 @@ -import React, { useState } from 'react'; +import React, { SyntheticEvent, useState } from 'react'; import { Autocomplete, TextField, Chip, IconButton, Stack } from '@mui/material'; -import { Controller, FieldError } from 'react-hook-form'; +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 }: { - control: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + control: Control; name: string; }) => { // State to manage the input value of the Autocomplete component @@ -30,7 +31,7 @@ const PresetValuesEditor = ({ ); }); - const onArrayChange = (_event: any, newValue: string[] | null) => { + const onArrayChange = (_event: SyntheticEvent | null, newValue: string[] | null) => { newValue = newValue ?? [...value, inputValue]; newValue = newValue.map((v: string) => v.trim()).filter(Boolean); onChange(newValue); diff --git a/met-web/src/services/engagementMetadataService/index.ts b/met-web/src/services/engagementMetadataService/index.ts index de937a4bd..744b7378e 100644 --- a/met-web/src/services/engagementMetadataService/index.ts +++ b/met-web/src/services/engagementMetadataService/index.ts @@ -6,13 +6,13 @@ import { replaceUrl } from 'helper'; 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); + return Promise.reject(new Error('Invalid Engagement Id ' + engagementId)); } const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject('Failed to fetch engagement'); + return Promise.reject(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'); + return Promise.reject(new Error('Failed to create engagement metadata')); }; export const patchEngagementMetadata = async (data: EngagementMetadata): Promise => { @@ -28,7 +28,7 @@ export const patchEngagementMetadata = async (data: EngagementMetadata): Promise if (response.data) { return response.data; } - return Promise.reject('Failed to update engagement metadata'); + return Promise.reject(new Error('Failed to update engagement metadata')); }; export const bulkPatchEngagementMetadata = async ( @@ -41,7 +41,7 @@ export const bulkPatchEngagementMetadata = async ( if (response.data) { return response.data; } - return Promise.reject('Failed to update engagement metadata'); + return Promise.reject(new Error('Failed to update engagement metadata')); }; export const getMetadataTaxa = async (): Promise> => { @@ -49,19 +49,19 @@ export const getMetadataTaxa = async (): Promise> => { if (response.data) { return response.data; } - return Promise.reject('Failed to fetch metadata taxa'); + return Promise.reject(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))) { - return Promise.reject('Invalid Taxon Id ' + taxonId); + return Promise.reject(new Error('Invalid Taxon Id ' + taxonId)); } const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject('Failed to fetch metadata taxon'); + return Promise.reject(new Error('Failed to fetch metadata taxon')); }; export const postMetadataTaxon = async (data: MetadataTaxonModify): Promise => { @@ -69,7 +69,7 @@ export const postMetadataTaxon = async (data: MetadataTaxonModify): Promise => { @@ -78,7 +78,7 @@ export const patchMetadataTaxon = async (id: number, data: MetadataTaxonModify): if (response.data) { return response.data; } - return Promise.reject('Failed to update metadata taxon'); + return Promise.reject(new Error('Failed to update metadata taxon')); }; export const deleteMetadataTaxon = async (taxonId: number): Promise => { @@ -87,7 +87,7 @@ export const deleteMetadataTaxon = async (taxonId: number): Promise => { if (response.status === 204) { return Promise.resolve(); } - return Promise.reject('Failed to delete metadata taxon'); + return Promise.reject(new Error('Failed to delete metadata taxon')); }; export const patchMetadataTaxaOrder = async (taxonIds: Array): Promise> => { @@ -98,5 +98,5 @@ export const patchMetadataTaxaOrder = async (taxonIds: Array): Promise Date: Mon, 11 Mar 2024 11:49:13 -0700 Subject: [PATCH 18/27] More lint errors fixed --- .../AdditionalDetails/Metadata/EngagementMetadata.tsx | 8 ++++---- .../AdditionalDetails/Metadata/TaxonInputComponents.tsx | 4 ++-- met-web/src/components/metadataManagement/TaxonEditor.tsx | 1 + .../presetFieldsEditor/PresetValuesEditor.tsx | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) 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 index 422634549..2c453a02a 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -9,7 +9,7 @@ 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 { DefaultAutocomplete } from './TaxonInputComponents'; import { yupResolver } from '@hookform/resolvers/yup'; import { bulkPatchEngagementMetadata } from 'services/engagementMetadataService'; import { openNotification } from 'services/notificationService/notificationSlice'; @@ -110,11 +110,11 @@ const EngagementMetadata = forwardRef((_props, ref) => { }, })); - const renderTaxonTile = (taxon: MetadataTaxon, index: number) => { + 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; + const TaxonInput = taxonType.customInput ?? DefaultAutocomplete; return ( { spacing={3} padding={2} > - {tenantTaxa.map(renderTaxonTile)} + {tenantTaxa.map(TaxonTile)} 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 index 49aebf546..d54f36978 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -14,7 +14,7 @@ import { } from '@mui/material'; import { FieldError } from 'react-hook-form'; -export const defaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, trigger }: TaxonInputProps) => { +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) ?? []; @@ -30,7 +30,7 @@ export const defaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, errorMessage = (valueErrors as Array)?.map((error: FieldError, index: number) => { errorIndices.add(index); return ( - + Entry #{index + 1}: {error.message}
diff --git a/met-web/src/components/metadataManagement/TaxonEditor.tsx b/met-web/src/components/metadataManagement/TaxonEditor.tsx index 2e601f3a7..95865262c 100644 --- a/met-web/src/components/metadataManagement/TaxonEditor.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -208,6 +208,7 @@ export const TaxonEditor = () => { orderedMetadataTaxa.map((taxon: MetadataTaxon, index) => { return ( { errorIndices.add(index); return ( - + Entry #{index + 1}: {error.message}
@@ -56,7 +56,7 @@ const PresetValuesEditor = ({ variant="outlined" label={option} {...getTagProps({ index })} - key={index} + key={option} color={errorIndices.has(index) ? 'error' : 'default'} /> ))} From b649ae7967c010474b398ad50cd79d6b2aa0c038 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 12:03:05 -0700 Subject: [PATCH 19/27] Hopefully the final lint errors fixed --- met-api/tests/unit/api/test_metadata_taxa.py | 2 +- .../AdditionalDetails/Metadata/TaxonInputComponents.tsx | 2 +- met-web/src/components/metadataManagement/TaxonEditForm.tsx | 4 ++-- .../presetFieldsEditor/PresetValuesEditor.tsx | 2 +- met-web/src/components/metadataManagement/types.ts | 3 +-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/met-api/tests/unit/api/test_metadata_taxa.py b/met-api/tests/unit/api/test_metadata_taxa.py index 153c1c296..a6128f28b 100644 --- a/met-api/tests/unit/api/test_metadata_taxa.py +++ b/met-api/tests/unit/api/test_metadata_taxa.py @@ -59,7 +59,7 @@ 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) + _, headers = factory_taxon_requirements(jwt) response = client.post(TENANT_TAXA_ENDPOINT, headers=headers, data=json.dumps( 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 index d54f36978..5d2ecaeea 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -30,7 +30,7 @@ export const DefaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, errorMessage = (valueErrors as Array)?.map((error: FieldError, index: number) => { errorIndices.add(index); return ( - + Entry #{index + 1}: {error.message}
diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx index f71eb7fae..e633293be 100644 --- a/met-web/src/components/metadataManagement/TaxonEditForm.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -34,7 +34,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { const methods = useForm({ defaultValues: { name: taxon.name, - description: taxon.description || '', + description: taxon.description ?? '', freeform: taxon.freeform, one_per_engagement: taxon.one_per_engagement, data_type: taxon.data_type, @@ -115,7 +115,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { }); } - if (!!Object.keys(formErrors).length) { + if (Object.keys(formErrors).length) { console.log('Form errors:', formErrors); dispatch( openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), diff --git a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx index d642c03fa..7a3c7cb34 100644 --- a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx +++ b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx @@ -24,7 +24,7 @@ const PresetValuesEditor = ({ const errorMessage = valueErrors?.map((error: FieldError, index: number) => { errorIndices.add(index); return ( - + Entry #{index + 1}: {error.message}
diff --git a/met-web/src/components/metadataManagement/types.ts b/met-web/src/components/metadataManagement/types.ts index 7ad0360e7..dc35bb28b 100644 --- a/met-web/src/components/metadataManagement/types.ts +++ b/met-web/src/components/metadataManagement/types.ts @@ -1,6 +1,5 @@ import { SvgIconComponent } from '@mui/icons-material'; -import { AutocompleteRenderInputParams } from '@mui/material/Autocomplete'; -import { EngagementMetadata, MetadataTaxon, MetadataTaxonModify } from 'models/engagement'; +import { MetadataTaxon, MetadataTaxonModify } from 'models/engagement'; import { ControllerRenderProps, FieldErrorsImpl, FieldValues } from 'react-hook-form'; import * as yup from 'yup'; From 9c303f796a08c95a858d4363ad996b08ff7179f1 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 15:15:00 -0700 Subject: [PATCH 20/27] String conversion hotfix --- .../AdditionalDetails/Metadata/EngagementMetadata.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 2c453a02a..9819dcae6 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -69,7 +69,7 @@ const EngagementMetadata = forwardRef((_props, ref) => { resolver: yupResolver(validationSchema), }); - const cleanArray = (arr: string[]) => arr.map((v) => v.trim()).filter(Boolean); + const cleanArray = (arr: string[]) => arr.map((v) => v.toString().trim()).filter(Boolean); const onSubmit: SubmitHandler = async () => { const data = getValues(); From bcbdddb33a25272f74037623da9d5e9b72d2b54e Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 15:54:16 -0700 Subject: [PATCH 21/27] Flake8 validation - attempt 1 --- .../src/met_api/models/engagement_metadata.py | 3 ++- met-api/src/met_api/resources/metadata_taxon.py | 1 + .../src/met_api/schemas/engagement_metadata.py | 14 ++++++-------- .../unit/models/test_engagement_metadata.py | 16 ++++++++-------- .../unit/services/test_engagement_metadata.py | 2 +- met-api/tests/utilities/factory_utils.py | 5 ++--- 6 files changed, 20 insertions(+), 21 deletions(-) diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index 63497c1bc..a1c3139e5 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -144,7 +144,8 @@ def __repr__(self) -> str: @property def preset_values(self) -> list[str]: - # Get preset values - any metadata entries with no specific engagement + """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 diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py index b378665bc..c4fb68b73 100644 --- a/met-api/src/met_api/resources/metadata_taxon.py +++ b/met-api/src/met_api/resources/metadata_taxon.py @@ -79,6 +79,7 @@ def ensure_tenant_access(): """ Provide access to the tenant as a DB model for the decorated function. + This does not provide security; that is handled by @require_role. """ def wrapper(f: Callable): diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index 5611ca50c..12468ea6a 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,9 +1,5 @@ -""" -Schemas for serializing and deserializing classes related to engagement metadata. -""" +"""Schemas for serializing and deserializing classes related to engagement metadata.""" -from met_api.models.engagement_metadata import (EngagementMetadata, - MetadataTaxon, MetadataTaxonDataType) from marshmallow import ValidationError, fields, pre_load, validate from marshmallow_sqlalchemy import SQLAlchemyAutoSchema from marshmallow_sqlalchemy.fields import Nested @@ -64,12 +60,14 @@ class Meta: 'get_preset_values', deserialize='set_preset_values') def get_preset_values(self, obj): - # This method is used by Marshmallow to serialize the preset_values property + """This method is used by Marshmallow to serialize the preset_values property + """ 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. + """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 diff --git a/met-api/tests/unit/models/test_engagement_metadata.py b/met-api/tests/unit/models/test_engagement_metadata.py index c4cead08d..7f72de2ec 100644 --- a/met-api/tests/unit/models/test_engagement_metadata.py +++ b/met-api/tests/unit/models/test_engagement_metadata.py @@ -11,9 +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 EngagementMetadata model. -""" +"""Tests for the EngagementMetadata model.""" from faker import Faker @@ -35,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') @@ -75,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/services/test_engagement_metadata.py b/met-api/tests/unit/services/test_engagement_metadata.py index 90f92384a..b7c8b4f03 100644 --- a/met-api/tests/unit/services/test_engagement_metadata.py +++ b/met-api/tests/unit/services/test_engagement_metadata.py @@ -125,7 +125,7 @@ def test_bulk_update_engagement_metadata(session): 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]}" + assert meta['value'] == new_values_2[i], f'{meta}, {new_values_2[i]}' def test_delete_engagement_metadata(session): diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index b70424e3b..e6452ca58 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 @@ -55,7 +54,6 @@ 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, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, @@ -608,7 +606,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 From d9bc58b29acfb9982242184e4bb1790576c1623f Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 16:09:32 -0700 Subject: [PATCH 22/27] Flake8 validation - attempt 2 --- met-api/src/met_api/models/engagement_metadata.py | 3 +-- met-api/src/met_api/schemas/engagement_metadata.py | 4 ++-- met-api/tests/utilities/factory_utils.py | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index a1c3139e5..f25c4b7fa 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -144,8 +144,7 @@ def __repr__(self) -> str: @property def preset_values(self) -> list[str]: - """Get preset values - any metadata entries with no specific engagement - """ + """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 diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index 12468ea6a..abedcad9a 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -60,12 +60,12 @@ class Meta: 'get_preset_values', deserialize='set_preset_values') def get_preset_values(self, obj): - """This method is used by Marshmallow to serialize the preset_values property - """ + """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] diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index e6452ca58..37537fcc1 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -54,6 +54,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, TestFeedbackInfo, TestJwtClaims, TestLanguageInfo, TestParticipantInfo, TestPollAnswerInfo, From 60cda11eadc4f12608772f4cfe2748f394d8ec7e Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 16:38:27 -0700 Subject: [PATCH 23/27] Patch jest tests --- .../engagement/form/edit/EngagementForm.Edit.One.test.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 24203e9b2..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 @@ -189,11 +189,7 @@ describe('Engagement form page tests', () => { await waitFor(() => { expect(screen.getByDisplayValue('Test Engagement')).toBeInTheDocument(); }); - getEngagementMetadataMock.mockReturnValueOnce( - Promise.resolve({ - ...[engagementMetadata], - }), - ); + getEngagementMetadataMock.mockReturnValueOnce(Promise.resolve([engagementMetadata])); expect(screen.getByText('Add Survey')).toBeDisabled(); }); From 31bcd8b311881e8d738079c974cfe01bae614310 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 11 Mar 2024 16:49:09 -0700 Subject: [PATCH 24/27] Patch jest tests again (sorry) --- .../tests/unit/components/widgets/VideoWidget.test.tsx | 2 +- .../tests/unit/components/widgets/setupWidgetTestEnv.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) 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/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), From 3fb3746c58487a0c781eb19abf6e7bdc819c2e4c Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 13 Mar 2024 13:02:33 -0700 Subject: [PATCH 25/27] Fix lint warnings, remove commented code, enhance accessibility (PR review - Alex) --- .../Metadata/EngagementMetadata.tsx | 8 ++-- .../Metadata/TaxonInputComponents.tsx | 1 - .../metadataManagement/TaxonCard.tsx | 9 ++-- .../metadataManagement/TaxonEditForm.tsx | 43 ++++++++++++++----- .../metadataManagement/TaxonEditor.tsx | 26 +++++++++-- .../components/metadataManagement/types.ts | 4 +- .../engagementMetadataService/index.ts | 26 +++++------ 7 files changed, 80 insertions(+), 37 deletions(-) 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 index 9819dcae6..cb9ba31d3 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -22,7 +22,6 @@ const EngagementMetadata = forwardRef((_props, ref) => { const dispatch = useAppDispatch(); const validationSchema = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaShape: { [key: string]: yup.AnySchema } = tenantTaxa.reduce((acc, taxon) => { const taxonType = TaxonTypes[taxon.data_type as keyof typeof TaxonTypes]; if (taxonType.yupValidator) { @@ -81,14 +80,13 @@ const EngagementMetadata = forwardRef((_props, ref) => { // Normalize and clean the arrays taxonValue = cleanArray(Array.isArray(taxonValue) ? taxonValue : [taxonValue]); const normalizedTaxonMeta = cleanArray(taxonMeta); - if (JSON.stringify(taxonValue.sort()) === JSON.stringify(taxonMeta.sort())) continue; + if (JSON.stringify(taxonValue.sort()) === JSON.stringify(normalizedTaxonMeta.sort())) continue; // If we reach here, arrays are not equal, proceed with update - console.log('Updating taxon metadata', normalizedTaxonMeta, taxonId, taxonValue); try { const updatedMetadata = await bulkPatchEngagementMetadata(taxonId, Number(engagementId), taxonValue); updatedEntries.set(taxonId, updatedMetadata); } catch (err) { - console.log(err); + console.error(err); dispatch(openNotification({ severity: 'error', text: 'Error Updating Taxon Metadata' })); } } @@ -104,8 +102,8 @@ const EngagementMetadata = forwardRef((_props, ref) => { submitForm: async () => { // validate the form await handleSubmit(onSubmit)(); // manually trigger form submission - const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); // After submission, check if there are any errors + const isValid = await trigger([...tenantTaxa.map((taxon) => taxon.id.toString())]); return isValid; }, })); 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 index 5d2ecaeea..4f6df57d0 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/TaxonInputComponents.tsx @@ -101,7 +101,6 @@ export const DefaultAutocomplete = ({ taxon, taxonType, field, setValue, errors, = ({ taxon, isExpanded, onExpan component={Paper} style={{ display: 'flex', - // boxSizing: 'border-box', alignItems: 'center', // Center items vertically padding: '10px', width: '100%', @@ -112,15 +111,19 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan direction="row" justifyContent="space-between" alignItems="center" + aria-expanded={isExpanded} + aria-label="A card representing a taxon in the engagement metadata." + role="group" > - + diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx index e633293be..5fdbc01b0 100644 --- a/met-web/src/components/metadataManagement/TaxonEditForm.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -70,8 +70,8 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { const presetValues = watch('preset_values'); const schema = yup.object().shape({ - name: yup.string().required('Name is required').max(64, 'Name is too long!'), - description: yup.string().max(255, 'Description is too long!').strip(), + 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'), @@ -116,7 +116,6 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { } if (Object.keys(formErrors).length) { - console.log('Form errors:', formErrors); dispatch( openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), ); @@ -285,16 +284,23 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { - + + + - + - + + + diff --git a/met-web/src/components/metadataManagement/TaxonEditor.tsx b/met-web/src/components/metadataManagement/TaxonEditor.tsx index 95865262c..759d3d563 100644 --- a/met-web/src/components/metadataManagement/TaxonEditor.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -125,15 +125,35 @@ export const TaxonEditor = () => { }} > - {/* Metadata Management */} Manage the ways metadata is collected and organized for your engagements. - - diff --git a/met-web/src/components/metadataManagement/types.ts b/met-web/src/components/metadataManagement/types.ts index dc35bb28b..03d9df105 100644 --- a/met-web/src/components/metadataManagement/types.ts +++ b/met-web/src/components/metadataManagement/types.ts @@ -30,11 +30,13 @@ export interface GenericInputProps { trigger: (name?: string | string[] | undefined) => Promise; errors: Partial< FieldErrorsImpl<{ - [x: string]: any; + // 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<{ diff --git a/met-web/src/services/engagementMetadataService/index.ts b/met-web/src/services/engagementMetadataService/index.ts index 744b7378e..2ee373ac9 100644 --- a/met-web/src/services/engagementMetadataService/index.ts +++ b/met-web/src/services/engagementMetadataService/index.ts @@ -6,13 +6,13 @@ import { replaceUrl } from 'helper'; 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(new Error('Invalid Engagement Id ' + engagementId)); + throw new Error('Invalid Engagement ID ' + engagementId); } const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject(new Error('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(new Error('Failed to create engagement metadata')); + throw new Error('Failed to create engagement metadata'); }; export const patchEngagementMetadata = async (data: EngagementMetadata): Promise => { @@ -28,7 +28,7 @@ export const patchEngagementMetadata = async (data: EngagementMetadata): Promise if (response.data) { return response.data; } - return Promise.reject(new Error('Failed to update engagement metadata')); + throw new Error('Failed to update engagement metadata'); }; export const bulkPatchEngagementMetadata = async ( @@ -41,7 +41,7 @@ export const bulkPatchEngagementMetadata = async ( if (response.data) { return response.data; } - return Promise.reject(new Error('Failed to update engagement metadata')); + throw new Error('Failed to update engagement metadata'); }; export const getMetadataTaxa = async (): Promise> => { @@ -49,19 +49,19 @@ export const getMetadataTaxa = async (): Promise> => { if (response.data) { return response.data; } - return Promise.reject(new Error('Failed to fetch metadata taxa')); + 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))) { - return Promise.reject(new Error('Invalid Taxon Id ' + taxonId)); + throw new Error('Invalid Taxon Id ' + taxonId); } const response = await http.GetRequest(url); if (response.data) { return response.data; } - return Promise.reject(new Error('Failed to fetch metadata taxon')); + throw new Error('Failed to fetch metadata taxon'); }; export const postMetadataTaxon = async (data: MetadataTaxonModify): Promise => { @@ -69,7 +69,7 @@ export const postMetadataTaxon = async (data: MetadataTaxonModify): Promise => { @@ -78,16 +78,16 @@ export const patchMetadataTaxon = async (id: number, data: MetadataTaxonModify): if (response.data) { return response.data; } - return Promise.reject(new Error('Failed to update metadata taxon')); + 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 Promise.resolve(); + return; } - return Promise.reject(new Error('Failed to delete metadata taxon')); + throw new Error('Failed to delete metadata taxon'); }; export const patchMetadataTaxaOrder = async (taxonIds: Array): Promise> => { @@ -98,5 +98,5 @@ export const patchMetadataTaxaOrder = async (taxonIds: Array): Promise
Date: Wed, 13 Mar 2024 13:51:28 -0700 Subject: [PATCH 26/27] Fix sonarcloud linting; aria accessibility tweaks --- .../Metadata/EngagementMetadata.tsx | 7 ++++--- .../components/metadataManagement/TaxonCard.tsx | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) 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 index cb9ba31d3..8de662c08 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/AdditionalDetails/Metadata/EngagementMetadata.tsx @@ -78,9 +78,10 @@ const EngagementMetadata = forwardRef((_props, ref) => { const taxonMeta = taxonMetadata.get(taxonId) ?? []; let taxonValue = value ?? []; // Normalize and clean the arrays - taxonValue = cleanArray(Array.isArray(taxonValue) ? taxonValue : [taxonValue]); - const normalizedTaxonMeta = cleanArray(taxonMeta); - if (JSON.stringify(taxonValue.sort()) === JSON.stringify(normalizedTaxonMeta.sort())) continue; + 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); diff --git a/met-web/src/components/metadataManagement/TaxonCard.tsx b/met-web/src/components/metadataManagement/TaxonCard.tsx index e63811cfd..37c60c230 100644 --- a/met-web/src/components/metadataManagement/TaxonCard.tsx +++ b/met-web/src/components/metadataManagement/TaxonCard.tsx @@ -111,9 +111,9 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan direction="row" justifyContent="space-between" alignItems="center" - aria-expanded={isExpanded} aria-label="A card representing a taxon in the engagement metadata." - role="group" + role="gridcell" + id={`taxon-${taxon.id}`} > @@ -157,13 +157,23 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan size="small" aria-label="expand" onClick={handleExpand} + aria-expanded={isExpanded} + aria-controls={`taxon-${taxon.id}-content`} >
- + {/* Description */} }> From 1db888fa941afb42475cc7b3727c421c80787b48 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Wed, 13 Mar 2024 14:01:18 -0700 Subject: [PATCH 27/27] Ensure proper sequence of flask migration files --- ...4ed_create_engagement_translation_table.py | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) 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 ###