From bbbaddf398bc5bceffd502540b998af86be9378e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nat=C2=B2?= Date: Fri, 5 Apr 2024 13:10:38 -0700 Subject: [PATCH] [To Main] Feature/DESENG-445: engagement filtering by metadata (#2444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Feature** Engagement filtering - Add filtering by taxon [🎟️DESENG-445](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-445) - Added properties to metadata taxa to allow them to be marked as filterable. - Added a new file `filter_types.py` where additional filters can be added as subquery factory functions. - Added a new endpoint to the API to retrieve filterable taxa. - Modified the search endpoint to allow filtering by metadata. - Added schemas, data validation and unit tests for the new functionality. - Updated the Metadata Management UI to allow taxa to be marked as filterable. - Currently, the only two filter types are `chips_any` and `chips_all`. - `chips_any`: Displays as a series of toggleable buttons ("chips"), uses the `list_match_any` subquery returning engagements with any of the selected values. - `chips_all`: Similar to chips_any; uses the `list_match_all` subquery to get only engagements with ALL of the selected values. - If multiple filterable taxa are selected, all the taxon filters must be met for an engagement to be returned. - Updated the public-facing engagement list to allow filtering by metadata taxa. This makes use of the new API endpoint to retrieve filterable taxa. - Added a new filter "drawer" to the listing page to hold these and any future filter types. - (Out of scope, but related to UX work for this ticket) Fixed a display issue with the public engagements page where engagements would not take up the full width of their grid cell. --------- Co-authored-by: Ratheesh kumar R <108045773+ratheesh-aot@users.noreply.github.com> Co-authored-by: VineetBala-AOT <90332175+VineetBala-AOT@users.noreply.github.com> Co-authored-by: Baelx <16845197+Baelx@users.noreply.github.com> --- CHANGELOG.MD | 19 +- .../f8bc8ce202f3_add_metadata_filters.py | 32 ++ met-api/src/met_api/models/engagement.py | 105 ++++-- .../src/met_api/models/engagement_metadata.py | 10 + met-api/src/met_api/resources/engagement.py | 13 +- .../src/met_api/resources/metadata_taxon.py | 37 ++- .../met_api/schemas/engagement_metadata.py | 19 +- .../services/metadata_taxon_service.py | 33 +- met-api/src/met_api/utils/filter_types.py | 39 +++ met-api/tests/unit/api/test_engagement.py | 236 ++++++++++++-- met-api/tests/unit/models/test_engagement.py | 138 +++++++- .../tests/unit/services/test_metadata_taxa.py | 73 ++++- met-api/tests/utilities/factory_scenarios.py | 36 ++- met-api/tests/utilities/factory_utils.py | 5 +- met-web/src/App.scss | 4 + met-web/src/apiManager/endpoints/index.ts | 1 + .../engagement/form/ActionContext.tsx | 27 +- .../EngagementContent/ContentTabs.tsx | 10 +- .../EngagementTabsContext.tsx | 2 +- .../landing/DeletableFilterChip.tsx | 38 +++ .../src/components/landing/FilterBlock.tsx | 298 ++++++++++++++++++ .../src/components/landing/FilterDrawer.tsx | 176 +++++++++++ .../components/landing/LandingComponent.tsx | 119 +------ .../src/components/landing/LandingContext.tsx | 52 ++- .../components/landing/MetadataFilterChip.tsx | 49 +++ met-web/src/components/landing/TileBlock.tsx | 6 +- .../MetadataFilterTypes.tsx | 19 ++ .../metadataManagement/TaxonCard.tsx | 31 +- .../metadataManagement/TaxonEditForm.tsx | 288 ++++++++++++----- .../metadataManagement/TaxonEditor.tsx | 137 ++++---- .../metadataManagement/TaxonTypes.tsx | 9 +- .../presetFieldsEditor/PresetValuesEditor.tsx | 5 +- .../components/metadataManagement/types.ts | 17 + met-web/src/locales/en/default.json | 39 ++- met-web/src/locales/en/gdx.json | 29 ++ met-web/src/models/engagement.ts | 2 + .../engagementMetadataService/index.ts | 9 + .../src/services/engagementService/index.ts | 2 + met-web/src/services/userService/index.ts | 2 +- .../landingPage/LandingPage.test.tsx | 102 +++++- 40 files changed, 1894 insertions(+), 374 deletions(-) create mode 100644 met-api/migrations/versions/f8bc8ce202f3_add_metadata_filters.py create mode 100644 met-api/src/met_api/utils/filter_types.py create mode 100644 met-web/src/components/landing/DeletableFilterChip.tsx create mode 100644 met-web/src/components/landing/FilterBlock.tsx create mode 100644 met-web/src/components/landing/FilterDrawer.tsx create mode 100644 met-web/src/components/landing/MetadataFilterChip.tsx create mode 100644 met-web/src/components/metadataManagement/MetadataFilterTypes.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 8c5e39e4b..67b45da10 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,21 @@ ## April 04, 2024 +- **Feature** Engagement filtering - Add filtering by taxon [🎟️DESENG-445](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-445) + + - Added properties to metadata taxa to allow them to be marked as filterable. + - Added a new file `filter_types.py` where additional filters can be added as subquery factory functions. + - Added a new endpoint to the API to retrieve filterable taxa. + - Modified the search endpoint to allow filtering by metadata. + - Added schemas, data validation and unit tests for the new functionality. + - Updated the Metadata Management UI to allow taxa to be marked as filterable. + - Currently, the only two filter types are `chips_any` and `chips_all`. + - `chips_any`: Displays as a series of toggleable buttons ("chips"), uses the `list_match_any` subquery returning engagements with any of the selected values. + - `chips_all`: Similar to chips_any; uses the `list_match_all` subquery to get only engagements with ALL of the selected values. + - If multiple filterable taxa are selected, all the taxon filters must be met for an engagement to be returned. + - Updated the public-facing engagement list to allow filtering by metadata taxa. This makes use of the new API endpoint to retrieve filterable taxa. + - Added a new filter "drawer" to the listing page to hold these and any future filter types. + - (Out of scope, but related to UX work for this ticket) Fixed a display issue with the public engagements page where engagements would not take up the full width of their grid cell. + - **Task**: Keycloak Unit Tests for New CSS API Integration [DESENG-508](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-508) - Updated Keycloak Unit Tests for New CSS API Integration. @@ -25,7 +41,7 @@ - **Bug Fix**: Various bugs in the survey tab [DESENG-520](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-523) - Update survey components and services to fix the bugs. - - Removed is_template and is_hidden values from autosaving to avoid the debounce function consuming it older values + - Removed is_template and is_hidden values from autosaving to avoid the debounce function consuming it older values - **Task**: Add a custom dynamic page to engagement [DESENG-501](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-501) - Integrated new summary and custom content tabs into the engagement form, providing users with structured sections for managing different types of content, enhancing overall organization and user experience. - Implemented functionality to allow users to add custom tabs with a variety of provided icons, along with options to edit or delete tabs as needed, granting users greater flexibility and control over their engagement content layout. @@ -44,7 +60,6 @@ - Added poll results to results tab. - Added poll results API. - Added Unit tests. - - **Task**: Change static english text to be able to support string translations [DESENG-467](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-467) - Implemented a language selector in the public header. - Incorporated logic to dynamically adjust the unauthenticated route based on the selected language and load the appropriate translation file. diff --git a/met-api/migrations/versions/f8bc8ce202f3_add_metadata_filters.py b/met-api/migrations/versions/f8bc8ce202f3_add_metadata_filters.py new file mode 100644 index 000000000..75ded06c0 --- /dev/null +++ b/met-api/migrations/versions/f8bc8ce202f3_add_metadata_filters.py @@ -0,0 +1,32 @@ +"""Add metadata filters to the engagement metadata taxa table. + +Revision ID: f8bc8ce202f3 +Revises: 734f160dd120 +Create Date: 2024-03-21 13:02:12.680363 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f8bc8ce202f3' +down_revision = '734f160dd120' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('engagement_metadata_taxa', sa.Column( + 'filter_type', sa.String(length=64), nullable=True)) + op.add_column('engagement_metadata_taxa', sa.Column( + 'include_freeform', sa.Boolean(), nullable=False, server_default='false')) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('engagement_metadata_taxa', 'include_freeform') + op.drop_column('engagement_metadata_taxa', 'filter_type') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/engagement.py b/met-api/src/met_api/models/engagement.py index c67e05f3b..4d5dc3256 100644 --- a/met-api/src/met_api/models/engagement.py +++ b/met-api/src/met_api/models/engagement.py @@ -15,15 +15,18 @@ from met_api.constants.engagement_status import EngagementDisplayStatus, Status from met_api.constants.user import SYSTEM_USER +from met_api.models.engagement_scope_options import EngagementScopeOptions from met_api.models.membership import Membership as MembershipModel -from met_api.models.staff_user import StaffUser from met_api.models.pagination_options import PaginationOptions -from met_api.models.engagement_scope_options import EngagementScopeOptions +from met_api.models.staff_user import StaffUser from met_api.schemas.engagement import EngagementSchema from met_api.utils.datetime import local_datetime from met_api.utils.enums import MembershipStatus +from met_api.utils.filter_types import filter_map + from .base_model import BaseModel from .db import db +from .engagement_metadata import EngagementMetadata as EngagementMetadataModel from .engagement_status import EngagementStatus @@ -37,14 +40,18 @@ class Engagement(BaseModel): rich_description = db.Column(JSON, unique=False, nullable=False) start_date = db.Column(db.DateTime) end_date = db.Column(db.DateTime) - status_id = db.Column(db.Integer, ForeignKey('engagement_status.id', ondelete='CASCADE')) + status_id = db.Column(db.Integer, ForeignKey( + 'engagement_status.id', ondelete='CASCADE')) status = db.relationship('EngagementStatus', backref='engagement') published_date = db.Column(db.DateTime, nullable=True) scheduled_date = db.Column(db.DateTime, nullable=True) banner_filename = db.Column(db.String(), unique=False, nullable=True) - surveys = db.relationship('Survey', backref='engagement', cascade='all, delete') - status_block = db.relationship('EngagementStatusBlock', backref='engagement') - tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) + surveys = db.relationship( + 'Survey', backref='engagement', cascade='all, delete') + status_block = db.relationship( + 'EngagementStatusBlock', backref='engagement') + tenant_id = db.Column( + db.Integer, db.ForeignKey('tenant.id'), nullable=True) tenant = db.relationship('Tenant', backref='engagements') is_internal = db.Column(db.Boolean, nullable=False) consent_message = db.Column(JSON, unique=False, nullable=True) @@ -71,8 +78,7 @@ def get_engagements_paginated( query = cls._filter_by_engagement_status(query, search_options) - # TODO: Uncomment for metadata changes coming soon. - # query = cls._filter_by_project_metadata(query, search_options) + query = cls._filter_by_metadata(query, search_options) query = cls._filter_by_internal(query, search_options) @@ -80,7 +86,8 @@ def get_engagements_paginated( if scope_options.include_assigned: # the engagement status ids that should not be filtered out exception_status_ids = scope_options.engagement_status_ids - query = cls._filter_by_assigned_engagements(query, external_user_id, exception_status_ids) + query = cls._filter_by_assigned_engagements( + query, external_user_id, exception_status_ids) else: # the engagement status ids of the engagements that should fetched statuses = scope_options.engagement_status_ids @@ -95,7 +102,8 @@ def get_engagements_paginated( items = query.all() return items, len(items) - page = query.paginate(page=pagination_options.page, per_page=pagination_options.size) + page = query.paginate(page=pagination_options.page, + per_page=pagination_options.size) return page.items, page.total @classmethod @@ -116,13 +124,16 @@ def update_engagement(cls, engagement: EngagementSchema) -> Engagement: status_id=engagement.get('status_id', None), # to fix the bug with UI not passing published date always. # Defaulting to existing - published_date=engagement.get('published_date', record.published_date), - scheduled_date=engagement.get('scheduled_date', record.scheduled_date), + published_date=engagement.get( + 'published_date', record.published_date), + scheduled_date=engagement.get( + 'scheduled_date', record.scheduled_date), updated_date=datetime.utcnow(), updated_by=engagement.get('updated_by', None), banner_filename=engagement.get('banner_filename', None), is_internal=engagement.get('is_internal', record.is_internal), - consent_message=engagement.get('consent_message', record.consent_message), + consent_message=engagement.get( + 'consent_message', record.consent_message), ) query.update(update_fields) db.session.commit() @@ -198,19 +209,20 @@ def _get_sort_order(pagination_options): @staticmethod def _filter_by_engagement_status(query, search_options): - statuses = [int(status) for status in search_options.get('engagement_status', [])] + statuses = [int(status) + for status in search_options.get('engagement_status', [])] if not statuses: return query - status_filter = [] - if EngagementDisplayStatus.Draft.value in statuses: - status_filter.append(Engagement.status_id == Status.Draft.value) - if EngagementDisplayStatus.Published.value in statuses: - status_filter.append(Engagement.status_id == Status.Published.value) - if EngagementDisplayStatus.Closed.value in statuses: - status_filter.append(Engagement.status_id == Status.Closed.value) - if EngagementDisplayStatus.Scheduled.value in statuses: - status_filter.append(Engagement.status_id == Status.Scheduled.value) + allowed_statuses = [ + Status.Draft.value, + Status.Published.value, + Status.Closed.value, + Status.Scheduled.value + ] + status_filter = [Engagement.status_id.in_( + [status for status in statuses if status in allowed_statuses])] + if EngagementDisplayStatus.Upcoming.value in statuses: status_filter.append( and_( @@ -226,16 +238,19 @@ def _filter_by_engagement_status(query, search_options): ) ) if EngagementDisplayStatus.Unpublished.value in statuses: - status_filter.append(Engagement.status_id == Status.Unpublished.value) + status_filter.append(Engagement.status_id == + Status.Unpublished.value) query = query.filter(or_(*status_filter)) return query @classmethod def _filter_by_published_date(cls, query, search_options): if published_from_date := search_options.get('published_from_date'): - query = query.filter(Engagement.published_date >= published_from_date) + query = query.filter( + Engagement.published_date >= published_from_date) if published_to_date := search_options.get('published_to_date'): - query = query.filter(Engagement.published_date <= published_to_date) + query = query.filter( + Engagement.published_date <= published_to_date) return query @staticmethod @@ -249,7 +264,8 @@ def _filter_by_created_date(query, search_options): @staticmethod def _filter_by_search_text(query, search_options): if search_text := search_options.get('search_text'): - query = query.filter(Engagement.name.ilike('%' + search_text + '%')) + query = query.filter( + Engagement.name.ilike('%' + search_text + '%')) return query @staticmethod @@ -259,11 +275,36 @@ def _filter_by_internal(query, search_options): query = query.filter(Engagement.is_internal.is_(False)) return query - # TODO: Populate or remove this method dependent on changes resulting from adding the new Engagement metadata - # @staticmethod - # def _filter_by_project_metadata(query, search_options): - # query = query.outerjoin(EngagementMetadataModel, EngagementMetadataModel.engagement_id == Engagement.id) - # return query + @staticmethod + def _filter_by_metadata(query, search_options): + """ + Filter the engagements based on metadata criteria. + + Ensures that each engagement matches all of the provided criteria. + """ + if 'metadata' not in search_options: + return query + + for criterion in search_options['metadata']: + taxon_id = criterion.get('taxon_id') + values = criterion.get('values') + # pick the type of filtering to apply + filter_type = filter_map.get(criterion.get('filter_type')) + if any([taxon_id is None, values is None, filter_type is None]): + continue # skip criterion if any of the required fields are missing + + taxon_query = query.session.query( + EngagementMetadataModel.engagement_id + ).filter( + # Filter the metadata entries to only include those that match the current taxon + EngagementMetadataModel.taxon_id == taxon_id + ) + # Use the filter function to create a subquery that filters the engagements + filter_subquery = filter_type(taxon_query, values) + # Filter the main query to include only engagements found in the subquery + query = query.filter(Engagement.id.in_(filter_subquery)) + + return query @staticmethod def _filter_by_assigned_engagements(query, external_user_id: int, exception_status_ids: Optional[list[int]] = None): diff --git a/met-api/src/met_api/models/engagement_metadata.py b/met-api/src/met_api/models/engagement_metadata.py index f25c4b7fa..ddbb257d6 100644 --- a/met-api/src/met_api/models/engagement_metadata.py +++ b/met-api/src/met_api/models/engagement_metadata.py @@ -89,6 +89,13 @@ def has_value(cls, value: str) -> bool: return value in cls._value2member_map_ +class MetadataTaxonFilterType(str, enum.Enum): + """The filter types that can be applied to a metadata property.""" + + CHIPS_ALL = 'chips_all' + CHIPS_ANY = 'chips_any' + + class MetadataTaxon(BaseModel): """ A taxon to group metadata by. @@ -110,6 +117,9 @@ class MetadataTaxon(BaseModel): data_type = db.Column(db.String(64), nullable=True, default='text') one_per_engagement = db.Column(db.Boolean) position = db.Column(db.Integer, nullable=False, index=True) + filter_type = db.Column(db.String(64), nullable=True) + # Whether to include freeform values from engagements in the user-facing filter options + include_freeform = db.Column(db.Boolean, nullable=False, default=False) def __init__(self, **kwargs) -> None: """Initialize a new instance of the MetadataTaxon class.""" diff --git a/met-api/src/met_api/resources/engagement.py b/met-api/src/met_api/resources/engagement.py index c4e6956dc..d26bc151b 100644 --- a/met-api/src/met_api/resources/engagement.py +++ b/met-api/src/met_api/resources/engagement.py @@ -15,6 +15,7 @@ from http import HTTPStatus +import json from flask import request from flask_cors import cross_origin from flask_restx import Namespace, Resource @@ -30,7 +31,8 @@ from met_api.utils.token_info import TokenInfo from met_api.utils.util import allowedorigins, cors_preflight -API = Namespace('engagements', description='Endpoints for Engagements Management') +API = Namespace( + 'engagements', description='Endpoints for Engagements Management') """Custom exception messages """ @@ -83,6 +85,10 @@ def get(): if external_user_id is None: exclude_internal = True + metadata = args.getlist('metadata[]') + if metadata: + metadata = [json.loads(m) for m in metadata] + search_options = { 'search_text': args.get('search_text', '', type=str), 'engagement_status': args.getlist('engagement_status[]'), @@ -90,9 +96,10 @@ def get(): 'created_to_date': args.get('created_to_date', None, type=str), 'published_from_date': args.get('published_from_date', None, type=str), 'published_to_date': args.get('published_to_date', None, type=str), + 'metadata': metadata, 'exclude_internal': exclude_internal, - # the membership changing pages sometimes needs only engagement where user can add a member. - # pass this has_team_access to restrict searches only within engagements he has access on. + # the membership changing pages sometimes need only engagements where users can add a member. + # pass this has_team_access to restrict searches only within engagements they have access on. 'has_team_access': args.get( 'has_team_access', default=False, diff --git a/met-api/src/met_api/resources/metadata_taxon.py b/met-api/src/met_api/resources/metadata_taxon.py index c4fb68b73..0a2565ed3 100644 --- a/met-api/src/met_api/resources/metadata_taxon.py +++ b/met-api/src/met_api/resources/metadata_taxon.py @@ -24,7 +24,7 @@ from flask import abort, g, request from flask_cors import cross_origin from flask_restx import Namespace, Resource, fields -from marshmallow import ValidationError +from marshmallow.exceptions import ValidationError from met_api.auth import auth_methods from met_api.models.tenant import Tenant from met_api.services.metadata_taxon_service import MetadataTaxonService @@ -43,6 +43,7 @@ taxon_service = MetadataTaxonService() + taxon_modify_model = API.model('MetadataTaxon', taxon_model_dict := { 'name': fields.String(required=False, description='The name of the taxon'), 'description': fields.String(required=False, description='The taxon description'), @@ -52,6 +53,9 @@ ' to one entry per engagement'), 'preset_values': fields.List(fields.String(), required=False, description='The preset values for the taxon'), + 'filter_type': fields.String(required=False, description='The filter type for the taxon (if any)'), + 'include_freeform': fields.Boolean(required=False, description='Whether to include freeform ' + 'values in the user-facing filter options') }) taxon_return_model = API.model('MetadataTaxonReturn', { @@ -62,6 +66,13 @@ **taxon_model_dict }) +taxon_filter_model = API.model('MetadataTaxonFilter', { + 'taxon_id': fields.Integer(required=True, description='The id of the taxon'), + 'name': fields.String(required=False, description='The name of the taxon'), + 'values': fields.List(fields.String, required=True, description='The values to filter by'), + 'filter_type': fields.String(required=True, description='The filter type') +}) + taxon_ids_model = API.model('TaxonIDs', { 'taxon_ids': fields.List(fields.Integer, required=True, description='A list of taxon ids') }) @@ -97,7 +108,7 @@ def decorated_function(*args, **func_kwargs): @cors_preflight('GET,POST,PATCH,OPTIONS') -@API.route('/taxa') # /metadata/taxa +@API.route('/taxa') # /engagment_metadata/taxa @API.doc(security=['apikey', 'tenant'], responses=responses) class MetadataTaxa(Resource): """Resource for managing engagement metadata taxa.""" @@ -153,7 +164,7 @@ def patch(tenant: Tenant): @cors_preflight('GET,PATCH,DELETE,OPTIONS') -# /metadata/taxon/ +# /engagement_metadata/taxon/ @API.route('/taxon/') @API.doc(security=['apikey', 'tenant'], responses=responses) class MetadataTaxon(Resource): @@ -200,3 +211,23 @@ def delete(tenant: Tenant, taxon_id: int): return {}, HTTPStatus.NO_CONTENT except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('GET,OPTIONS') +# /engagement_metadata/taxa/filters +@API.route('/taxa/filters/') +@API.doc(security=['tenant'], responses=responses) +class MetadataFilterOptions(Resource): + """ + Resource for getting filter options for a tenant's metadata taxa. + + This resource is read-only and does not require any specific roles. + """ + + @staticmethod + @cross_origin(origins=allowedorigins()) + @ensure_tenant_access() + @API.marshal_list_with(taxon_filter_model) + def get(tenant: Tenant): + """Fetch the filter options for a tenant.""" + return taxon_service.get_filter_options(tenant.id), HTTPStatus.OK diff --git a/met-api/src/met_api/schemas/engagement_metadata.py b/met-api/src/met_api/schemas/engagement_metadata.py index abedcad9a..f1103eb11 100644 --- a/met-api/src/met_api/schemas/engagement_metadata.py +++ b/met-api/src/met_api/schemas/engagement_metadata.py @@ -1,9 +1,11 @@ """Schemas for serializing and deserializing classes related to engagement metadata.""" -from marshmallow import ValidationError, fields, pre_load, validate +from marshmallow import Schema, ValidationError, fields, pre_load, validate from marshmallow_sqlalchemy import SQLAlchemyAutoSchema from marshmallow_sqlalchemy.fields import Nested -from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxon, MetadataTaxonDataType + +from met_api.models.engagement_metadata import ( + EngagementMetadata, MetadataTaxon, MetadataTaxonDataType, MetadataTaxonFilterType) class EngagementMetadataSchema(SQLAlchemyAutoSchema): @@ -58,6 +60,9 @@ class Meta: position = fields.Integer(required=False) preset_values = fields.Method( 'get_preset_values', deserialize='set_preset_values') + filter_type = fields.String( + validate=validate.OneOf([e.value for e in MetadataTaxonFilterType]), allow_none=True) + include_freeform = fields.Boolean() def get_preset_values(self, obj): """Serialize the preset_values property for Marshmallow.""" @@ -85,3 +90,13 @@ def check_immutable_fields(self, data, **kwargs): # Nested field entries = Nested(EngagementMetadataSchema, many=True, exclude=['taxon']) + + +class MetadataTaxonFilterSchema(Schema): + """Schema for metadata taxon filters.""" + + taxon_id = fields.Integer(required=True) + name = fields.String(required=False) + values = fields.List(fields.String(), required=True) + filter_type = fields.String(required=True, validate=validate.OneOf( + [e.value for e in MetadataTaxonFilterType])) 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 7615b4391..af4052974 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 +from met_api.models.engagement_metadata import MetadataTaxon, MetadataTaxonFilterType from met_api.models.tenant import Tenant -from met_api.schemas.engagement_metadata import MetadataTaxonSchema +from met_api.schemas.engagement_metadata import MetadataTaxonFilterSchema, MetadataTaxonSchema class MetadataTaxonService: @@ -28,6 +28,35 @@ def get_by_tenant(tenant_id: int) -> List[dict]: sorted_results = sorted(results, key=lambda taxon: taxon.position) return MetadataTaxonSchema(many=True).dump(sorted_results) + @staticmethod + def get_filter_options(tenant_id: int) -> dict: + """Get all filterable taxa for a tenant.""" + tenant = Tenant.query.get(tenant_id) + results = sorted(tenant.metadata_taxa, + key=lambda taxon: taxon.position) if tenant else [] + filters = [] + available_filters = [e.value for e in MetadataTaxonFilterType] + for taxon in results: + if taxon.filter_type in available_filters: + values = [] + if taxon.freeform and taxon.include_freeform: + # Include values specified on engagements as usable options + # (unique values only) + values = list({entry.value for entry in taxon.entries}) + else: + # Just use the preset values + values = taxon.preset_values + # Don't return the filter if the user has no options; this prevents + # the frontend from displaying a useless filter + if values: + filters.append({ + 'taxon_id': taxon.id, + 'name': taxon.name, + 'filter_type': taxon.filter_type, + 'values': values + }) + return MetadataTaxonFilterSchema(many=True).dump(filters) + @staticmethod def create(tenant_id: int, taxon_data: dict) -> dict: """Create a new taxon.""" diff --git a/met-api/src/met_api/utils/filter_types.py b/met-api/src/met_api/utils/filter_types.py new file mode 100644 index 000000000..802b5bb53 --- /dev/null +++ b/met-api/src/met_api/utils/filter_types.py @@ -0,0 +1,39 @@ +"""Filters used to filter by metadata contents in various ways.""" + +from met_api.models.engagement_metadata import EngagementMetadata, MetadataTaxonFilterType +from sqlalchemy import func + + +def list_match_all(query, values): + """Create and return a subquery selecting engagements that have all of the provided values.""" + # Create a subquery to find engagements that have the required number of matching metadata entries + values_count = len(values) + subquery = query.filter( + EngagementMetadata.value.in_(values) + ).group_by( + # Group by engagement_id to count the number of matching values + EngagementMetadata.engagement_id + ).having( + # Ensure that the number of matching values is equal to the number of values provided + func.count('*') == values_count + ) .subquery() + return subquery + + +def list_match_any(query, values): + """Create and return a subquery selecting engagements that have any of the provided values.""" + metadata_subq = query.filter( + EngagementMetadata.value.in_(values) + ).subquery() + + return metadata_subq + + +""" +Provide a mapping from each filter type to the function that should be used +to filter by that type. +""" +filter_map = { + MetadataTaxonFilterType.CHIPS_ALL: list_match_all, + MetadataTaxonFilterType.CHIPS_ANY: list_match_any, +} diff --git a/met-api/tests/unit/api/test_engagement.py b/met-api/tests/unit/api/test_engagement.py index d4da1601e..5cc97a4e1 100644 --- a/met-api/tests/unit/api/test_engagement.py +++ b/met-api/tests/unit/api/test_engagement.py @@ -19,12 +19,12 @@ import copy import json from http import HTTPStatus +from unittest.mock import patch import pytest -from unittest.mock import patch from faker import Faker -from marshmallow import ValidationError from flask import current_app +from marshmallow import ValidationError from met_api.constants.engagement_status import EngagementDisplayStatus, SubmissionStatus from met_api.models.tenant import Tenant as TenantModel @@ -34,9 +34,10 @@ from tests.utilities.factory_scenarios import ( TestEngagementInfo, TestJwtClaims, TestSubmissionInfo, TestTenantInfo, TestUserInfo) from tests.utilities.factory_utils import ( - factory_auth_header, factory_engagement_model, factory_membership_model, factory_participant_model, - factory_staff_user_model, factory_submission_model, factory_survey_and_eng_model, factory_tenant_model, - set_global_tenant) + factory_auth_header, factory_engagement_metadata_model, factory_engagement_model, factory_membership_model, + factory_metadata_taxon_model, factory_participant_model, factory_staff_user_model, factory_submission_model, + factory_survey_and_eng_model, factory_tenant_model, set_global_tenant) + fake = Faker() @@ -181,7 +182,8 @@ def test_get_engagements_reviewer(client, jwt, session, engagement_info, headers=headers, content_type=ContentType.JSON.value) assert rv.status_code == HTTPStatus.FORBIDDEN.value - factory_membership_model(user_id=user.id, engagement_id=eng_id, member_type='REVIEWER') + factory_membership_model( + user_id=user.id, engagement_id=eng_id, member_type='REVIEWER') # Reveiwer has access to draft engagement if he is assigned rv = client.get(f'/api/engagements/{eng_id}', @@ -226,7 +228,8 @@ def test_search_engagements_by_status(client, jwt, def test_search_engagements(client, jwt, session): # pylint:disable=unused-argument """Verify the functionality of searching engagements with different access levels.""" - similar_engagement_base_name = fake.name() # Generate a base name for similar engagements + similar_engagement_base_name = fake.name( + ) # Generate a base name for similar engagements set_global_tenant() similar_engagements = [] @@ -234,7 +237,8 @@ def test_search_engagements(client, jwt, session): # pylint:disable=unused-argu # Create multiple engagements with similar names for testing search for i in range(total_similar_engagements): - name = f'{similar_engagement_base_name}{i}' # Append a number to distinguish names + # Append a number to distinguish names + name = f'{similar_engagement_base_name}{i}' similar_engagements.append(factory_engagement_model(name=name)) # Create a dissimilar engagement @@ -259,10 +263,12 @@ def test_search_engagements(client, jwt, session): # pylint:disable=unused-argu assert rv.json.get('total') == 0, 'No role, so no results expected' # Admin-level searches - headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + headers = factory_auth_header( + jwt=jwt, claims=TestJwtClaims.staff_admin_role) rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == total_similar_engagements, 'Matching similar names count for admin' + assert rv.json.get( + 'total') == total_similar_engagements, 'Matching similar names count for admin' # Admin searches with team-level access rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}&has_team_access=true', @@ -280,32 +286,40 @@ def test_search_engagements(client, jwt, session): # pylint:disable=unused-argu # Team member searches for similar engagements rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == total_similar_engagements, 'Name search fetches all engagements for team member' + assert rv.json.get( + 'total') == total_similar_engagements, 'Name search fetches all engagements for team member' # Team member with no membership, access should be denied rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}&has_team_access=true', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 0, 'Team member with no membership should not fetch any results' + assert rv.json.get( + 'total') == 0, 'Team member with no membership should not fetch any results' # Create membership for a specific engagement for the team member - factory_membership_model(user_id=user.id, engagement_id=similar_engagements[0].id, member_type='TEAM_MEMBER') + factory_membership_model( + user_id=user.id, engagement_id=similar_engagements[0].id, member_type='TEAM_MEMBER') # Team member search with membership, should return the specific engagement rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}&has_team_access=true', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 1, 'Name search works for team member with membership' + assert rv.json.get( + 'total') == 1, 'Name search works for team member with membership' # Create membership for a different engagement and search with the base name - factory_membership_model(user_id=user.id, engagement_id=eng2.id, member_type='TEAM_MEMBER') + factory_membership_model( + user_id=user.id, engagement_id=eng2.id, member_type='TEAM_MEMBER') rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}&has_team_access=true', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 1, 'Different name, so returns only base name results.' + assert rv.json.get( + 'total') == 1, 'Different name, so returns only base name results.' # Create membership for another similar engagement and perform the search - factory_membership_model(user_id=user.id, engagement_id=similar_engagements[1].id, member_type='TEAM_MEMBER') + factory_membership_model( + user_id=user.id, engagement_id=similar_engagements[1].id, member_type='TEAM_MEMBER') rv = client.get(f'/api/engagements/?search_text={similar_engagement_base_name}&has_team_access=true', headers=headers, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 2, 'Similar name, team member search fetches multiple results' + assert rv.json.get( + 'total') == 2, 'Similar name, team member search fetches multiple results' def test_search_engagements_not_logged_in(client, session): # pylint:disable=unused-argument @@ -313,22 +327,30 @@ def test_search_engagements_not_logged_in(client, session): # pylint:disable=un factory_engagement_model() rv = client.get('/api/engagements/', content_type=ContentType.JSON.value) - assert rv.json.get('total') == 1, 'its visible for public user wit no tenant information' + assert rv.json.get( + 'total') == 1, 'its visible for public user wit no tenant information' assert rv.status_code == 200 - tenant_header = {TENANT_ID_HEADER: current_app.config.get('DEFAULT_TENANT_SHORT_NAME')} - rv = client.get('/api/engagements/', headers=tenant_header, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 0, 'Tenant based fetching.So dont return the non-tenant info.' + tenant_header = {TENANT_ID_HEADER: current_app.config.get( + 'DEFAULT_TENANT_SHORT_NAME')} + rv = client.get('/api/engagements/', headers=tenant_header, + content_type=ContentType.JSON.value) + assert rv.json.get( + 'total') == 0, 'Tenant based fetching.So dont return the non-tenant info.' assert rv.status_code == 200 factory_engagement_model(TestEngagementInfo.engagement3) rv = client.get('/api/engagements/', content_type=ContentType.JSON.value) - assert rv.json.get('total') == 2, 'Both of the engagaments should visible for public user wit no tenant information' + assert rv.json.get( + 'total') == 2, 'Both of the engagaments should visible for public user wit no tenant information' assert rv.status_code == 200 - tenant_header = {TENANT_ID_HEADER: current_app.config.get('DEFAULT_TENANT_SHORT_NAME')} - rv = client.get('/api/engagements/', headers=tenant_header, content_type=ContentType.JSON.value) - assert rv.json.get('total') == 1, 'Tenant based fetching.So dont return the non-tenant info.' + tenant_header = {TENANT_ID_HEADER: current_app.config.get( + 'DEFAULT_TENANT_SHORT_NAME')} + rv = client.get('/api/engagements/', headers=tenant_header, + content_type=ContentType.JSON.value) + assert rv.json.get( + 'total') == 1, 'Tenant based fetching.So dont return the non-tenant info.' assert rv.status_code == 200 @@ -441,8 +463,10 @@ def test_patch_new_survey_block_engagement(client, jwt, session, assert rv.status_code == 200 actual_status_blocks = rv.json.get('status_block') assert len(actual_status_blocks) == 1 - assert actual_status_blocks[0].get('block_text') == engagement_edits.get('status_block')[0].get('block_text') - assert actual_status_blocks[0].get('survey_status') == engagement_edits.get('status_block')[0].get('survey_status') + assert actual_status_blocks[0].get('block_text') == engagement_edits.get( + 'status_block')[0].get('block_text') + assert actual_status_blocks[0].get('survey_status') == engagement_edits.get( + 'status_block')[0].get('survey_status') def test_update_survey_block_engagement(client, jwt, session, @@ -473,14 +497,16 @@ def test_update_survey_block_engagement(client, jwt, session, assert rv.status_code == 200 actual_status_blocks = rv.json.get('status_block') assert len(actual_status_blocks) == 2 - upcoming_block = next(x for x in actual_status_blocks if x.get('survey_status') == SubmissionStatus.Closed.name) + upcoming_block = next(x for x in actual_status_blocks if x.get( + 'survey_status') == SubmissionStatus.Closed.name) assert upcoming_block.get('block_text') == block_text_for_upcoming @pytest.mark.parametrize('engagement_info', [TestEngagementInfo.engagement1]) def test_count_submissions(client, jwt, session, engagement_info): # pylint:disable=unused-argument """Assert that an engagement can be POSTed.""" - headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + headers = factory_auth_header( + jwt=jwt, claims=TestJwtClaims.staff_admin_role) factory_staff_user_model(TestJwtClaims.public_user_role.get('sub')) participant = factory_participant_model() survey, eng = factory_survey_and_eng_model() @@ -501,3 +527,153 @@ def test_count_submissions(client, jwt, session, engagement_info): # pylint:dis assert submission_meta_data.get('approved', 0) == 1 assert submission_meta_data.get('pending', 0) == 1 assert submission_meta_data.get('needs_further_review', 0) == 1 + + +def test_get_engagements_metadata_match_all(client, session): # pylint:disable=unused-argument + """Assert that engagements can be looked up by metadata (match all).""" + engagements = [factory_engagement_model({ + **TestEngagementInfo.engagement1, + 'tenant_id': 1 + }) for _ in range(0, 10)] + taxon = factory_metadata_taxon_model(1, { + 'name': 'Category', + 'description': 'Category description', + 'data_type': 'text', + 'tenant_id': 1 + }) + for engagement in engagements: + factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + 'value': 'Category value', + 'tenant_id': 1 + }) + + # Add a value to one of the engagements to test the filter + factory_engagement_metadata_model({ + 'engagement_id': engagements[0].id, + 'taxon_id': taxon.id, + 'value': 'Different', + 'tenant_id': 1 + }) + # pass in pagination options and do the count + metadata_1 = json.dumps( + { + 'name': 'Category', + 'values': ['Category value'], + 'filter_type': 'chips_all', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + print(f'/api/engagements/?metadata[]={metadata_1}') + rv = client.get(f'/api/engagements/?metadata[]={metadata_1}') + + assert rv.status_code == 200 + assert rv.json.get('total') == 10 + + metadata_2 = json.dumps( + { + 'name': 'Category', + 'values': ['Different'], + 'filter_type': 'chips_all', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + rv = client.get(f'/api/engagements/?metadata[]={metadata_2}') + assert rv.status_code == 200 + assert rv.json.get('total') == 1 + + metadata_3 = json.dumps( + { + 'name': 'Category', + 'values': ['Category value', 'Different'], + 'filter_type': 'chips_all', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + rv = client.get( + f'/api/engagements/?metadata[]={metadata_3}') + assert rv.status_code == 200 + # the filter should only return the engagement with both values + assert rv.json.get('total') == 1 + + +def test_get_engagements_metadata_match_any(client, session): # pylint:disable=unused-argument + """Assert that engagements can be looked up by metadata (match all).""" + engagements = [factory_engagement_model({ + **TestEngagementInfo.engagement1, + 'tenant_id': 1 + }) for _ in range(0, 10)] + taxon = factory_metadata_taxon_model(1, { + 'name': 'Category', + 'description': 'Category description', + 'data_type': 'text', + 'tenant_id': 1 + }) + for engagement in engagements: + factory_engagement_metadata_model({ + 'engagement_id': engagement.id, + 'taxon_id': taxon.id, + 'value': 'Category value', + 'tenant_id': 1 + }) + + # Add a value to one of the engagements to test the filter + factory_engagement_metadata_model({ + 'engagement_id': engagements[0].id, + 'taxon_id': taxon.id, + 'value': 'Different', + 'tenant_id': 1 + }) + # pass in pagination options and do the count + metadata_1 = json.dumps( + { + 'name': 'Category', + 'values': ['Category value'], + 'filter_type': 'chips_any', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + print(f'/api/engagements/?metadata[]={metadata_1}') + rv = client.get(f'/api/engagements/?metadata[]={metadata_1}') + + assert rv.status_code == 200 + assert rv.json.get('total') == 10 + + metadata_2 = json.dumps( + { + 'name': 'Category', + 'values': ['Different'], + 'filter_type': 'chips_any', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + rv = client.get(f'/api/engagements/?metadata[]={metadata_2}') + assert rv.status_code == 200 + assert rv.json.get('total') == 1 + + metadata_3 = json.dumps( + { + 'name': 'Category', + 'values': ['Category value', 'Different'], + 'filter_type': 'chips_any', + 'taxon_id': taxon.id + }, + separators=(',', ':') # Remove spaces between keys and values + ) + + rv = client.get( + f'/api/engagements/?metadata[]={metadata_3}') + assert rv.status_code == 200 + # the filter should return the engagements with either value + assert rv.json.get('total') == 10 diff --git a/met-api/tests/unit/models/test_engagement.py b/met-api/tests/unit/models/test_engagement.py index 157f13695..9dada05ba 100644 --- a/met-api/tests/unit/models/test_engagement.py +++ b/met-api/tests/unit/models/test_engagement.py @@ -19,10 +19,12 @@ from faker import Faker from met_api.constants.engagement_status import Status +from met_api.models.engagement_metadata import EngagementMetadata from met_api.models.engagement import Engagement as EngagementModel from met_api.models.engagement_scope_options import EngagementScopeOptions from met_api.models.pagination_options import PaginationOptions -from tests.utilities.factory_utils import factory_engagement_model +from tests.utilities.factory_utils import factory_engagement_model, factory_metadata_taxon_model +from tests.utilities.factory_scenarios import TestEngagementInfo fake = Faker() @@ -155,3 +157,137 @@ def test_get_engagements_paginated_status_search(session): ) assert count == 13 assert len(result) == 2 + + +def test_get_engagements_metadata_match_all(session): + """Assert that engagements can be looked up by metadata (match all).""" + engagements = [factory_engagement_model({ + **TestEngagementInfo.engagement1, + 'tenant_id': 1 + }) for _ in range(0, 10)] + taxon = factory_metadata_taxon_model(1, { + 'name': 'Category', + 'description': 'Category description', + 'data_type': 'text', + 'position': 1, + 'freeform': False, + 'filter_type': 'chips_all', + }) + # give every engagement some random metadata + for eng in engagements: + eng.metadata.append(EngagementMetadata( + taxon_id=taxon.id, value=fake.word())) + + # give alternating engagements a value we will search for + for eng in range(0, len(engagements), 2): + engagements[eng].metadata.append(EngagementMetadata( + taxon_id=taxon.id, value='test')) + + external_user_id = 123 + pagination_options = PaginationOptions( + page=None, size=None, sort_key='name', sort_order='') + scope_options = EngagementScopeOptions(restricted=False) + search_options = { + 'metadata': [{ + 'taxon_id': taxon.id, + 'filter_type': 'chips_all', + 'values': ['test'] + }] + } + + def refresh_engagements(): + return EngagementModel.get_engagements_paginated( + external_user_id, + pagination_options, + scope_options, + search_options + ) + # search for metadata + _, count = refresh_engagements() + assert count == 5 + + engagements[1].metadata.append(EngagementMetadata( + taxon_id=taxon.id, value='test')) + _, count = refresh_engagements() + assert count == 6 + + search_options['metadata'][0]['values'] = ['test', 'test2'] + _, count = refresh_engagements() + # This should find *all* matching values, so the inclusion of a non-matching + # value "test2" should reduce the result to 0 + assert count == 0 + + engagements[0].metadata.append(EngagementMetadata( + taxon_id=taxon.id, value='test2')) + _, count = refresh_engagements() + + # There should now be a single engagement with both "test" and "test2" + assert count == 1 + + +def test_get_engagements_metadata_match_any(session): + """Assert that engagements can be looked up by metadata (match any).""" + engagements = [factory_engagement_model({ + **TestEngagementInfo.engagement1, + 'tenant_id': 1 + }) for _ in range(0, 10)] + taxon = factory_metadata_taxon_model(1, { + 'name': 'Category', + 'description': 'Category description', + 'data_type': 'text', + 'position': 1, + 'freeform': False, + 'filter_type': 'chips_any', + }) + # give every engagement some random metadata + for eng in engagements: + eng.metadata.append(EngagementMetadata( + taxon_id=taxon.id, value=fake.word())) + # give every *other* engagement a value we will search for + for eng in range(0, len(engagements), 2): + engagements[eng].metadata.append(EngagementMetadata( + taxon_id=taxon.id, value='test')) + + external_user_id = 123 + pagination_options = PaginationOptions( + page=None, + size=None, + sort_key='name', + sort_order='' + ) + scope_options = EngagementScopeOptions( + restricted=False, + include_assigned=False, + engagement_status_ids=None + ) + search_options = { + 'metadata': [ + { + 'taxon_id': taxon.id, + 'filter_type': 'chips_any', + 'values': ['test'] + } + ] + } + + def refresh_engagements(): + return EngagementModel.get_engagements_paginated( + external_user_id, + pagination_options, + scope_options, + search_options + ) + # search for metadata + _, count = refresh_engagements() + assert count == 5 + + engagements[1].metadata.append(EngagementMetadata( + taxon_id=taxon.id, value='test')) + _, count = refresh_engagements() + assert count == 6 + + search_options['metadata'][0]['values'] = ['test', 'test2'] + _, count = refresh_engagements() + # This should find *any* matching value, so the inclusion of a non-matching + # value "test2" should not change the result + assert count == 6 diff --git a/met-api/tests/unit/services/test_metadata_taxa.py b/met-api/tests/unit/services/test_metadata_taxa.py index 7c87ca7eb..82497a32d 100644 --- a/met-api/tests/unit/services/test_metadata_taxa.py +++ b/met-api/tests/unit/services/test_metadata_taxa.py @@ -15,10 +15,14 @@ """Tests for the metadata taxon service.""" from faker import Faker -from met_api.services.metadata_taxon_service import MetadataTaxonService + +from met_api.models.engagement_metadata import MetadataTaxon from met_api.services.engagement_metadata_service import EngagementMetadataService -from tests.utilities.factory_scenarios import TestEngagementMetadataTaxonInfo -from tests.utilities.factory_utils import factory_metadata_taxon_model, factory_taxon_requirements +from met_api.services.metadata_taxon_service import MetadataTaxonService +from tests.utilities.factory_scenarios import TestEngagementInfo, TestEngagementMetadataTaxonInfo +from tests.utilities.factory_utils import ( + factory_engagement_model, factory_metadata_taxon_model, factory_taxon_requirements) + fake = Faker() engagement_metadata_service = EngagementMetadataService() @@ -165,3 +169,66 @@ def test_auto_order_tenant(session): for i in range(10): # Every number appears once assert tenant_taxa[i]['position'] == i + 1 + + +def test_get_filters(session): + """Assert that taxon filters are correctly retrieved.""" + tenant, _ = factory_taxon_requirements() + taxon_service = MetadataTaxonService() + engagement = factory_engagement_model({**TestEngagementInfo.engagement1, + 'tenant_id': tenant.id}) + # Create multiple taxa... + # Unfilterable taxon - this should not appear in the filters + taxon1 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.taxon1) + # Includes freeform values, filter type is 'chips_all' + taxon2 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.filterable_taxon1) + # Does not include freeform values, filter type is 'chips_all' + taxon3 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.filterable_taxon2) + # Does not include freeform values, filter type is 'chips_any' + taxon4 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.filterable_taxon3) + # Includes freeform values, filter type is 'chips_any' + taxon5 = taxon_service.create( + tenant.id, TestEngagementMetadataTaxonInfo.filterable_taxon4) + # en.wikipedia.org/wiki/Metasyntactic_variable + # Create metadata for engagements - these should only appear in the filters + # if include_freeform is set on the taxon + engagement_metadata_service.create(engagement.id, taxon1['id'], 'foo') + engagement_metadata_service.create(engagement.id, taxon2['id'], 'bar') + engagement_metadata_service.create(engagement.id, taxon3['id'], 'baz') + engagement_metadata_service.create(engagement.id, taxon4['id'], 'qux') + engagement_metadata_service.create(engagement.id, taxon5['id'], 'quux') + # Preset values - these should appear on filterable taxa no matter what + MetadataTaxon.query.get(taxon2['id']).preset_values = ['grault'] + MetadataTaxon.query.get(taxon3['id']).preset_values = ['garply'] + MetadataTaxon.query.get(taxon4['id']).preset_values = ['waldo'] + MetadataTaxon.query.get(taxon5['id']).preset_values = ['fred'] + # Get filters + filters = taxon_service.get_filter_options(tenant.id) + assert len(filters) == 4 # out of 5, only 4 should be filterable + assert filters[0]['taxon_id'] == taxon2['id'] + assert filters[0]['name'] == taxon2['name'] + assert filters[0]['filter_type'] == taxon2['filter_type'] + assert len(filters[0]['values']) == 2 + assert 'grault' in filters[0]['values'] + assert 'bar' in filters[0]['values'] + + assert filters[1]['taxon_id'] == taxon3['id'] + assert filters[1]['name'] == taxon3['name'] + assert filters[1]['filter_type'] == taxon3['filter_type'] + assert filters[1]['values'] == ['garply'] + + assert filters[2]['taxon_id'] == taxon4['id'] + assert filters[2]['name'] == taxon4['name'] + assert filters[2]['filter_type'] == taxon4['filter_type'] + assert filters[2]['values'] == ['waldo'] + + assert filters[3]['taxon_id'] == taxon5['id'] + assert filters[3]['name'] == taxon5['name'] + assert filters[3]['filter_type'] == taxon5['filter_type'] + assert len(filters[3]['values']) == 2 + assert 'fred' in filters[3]['values'] + assert 'quux' in filters[3]['values'] diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index c6dc350bc..27e73cb08 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -305,12 +305,44 @@ class TestEngagementMetadataTaxonInfo(dict, Enum): 'one_per_engagement': True, } - taxon4 = { + filterable_taxon1 = { 'name': fake.name(), 'description': fake.text(max_nb_chars=256), - 'data_type': 'url', + 'data_type': 'text', + 'freeform': True, + 'one_per_engagement': False, + 'filter_type': 'chips_all', + 'include_freeform': True + } + + filterable_taxon2 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'data_type': 'text', + 'freeform': False, + 'one_per_engagement': False, + 'filter_type': 'chips_all', + 'include_freeform': False + } + + filterable_taxon3 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'data_type': 'text', 'freeform': False, 'one_per_engagement': False, + 'filter_type': 'chips_any', + 'include_freeform': False + } + + filterable_taxon4 = { + 'name': fake.name(), + 'description': fake.text(max_nb_chars=256), + 'data_type': 'text', + 'freeform': True, + 'one_per_engagement': False, + 'filter_type': 'chips_any', + 'include_freeform': True } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 1481dde6f..e1a3f3801 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -23,7 +23,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 @@ -65,6 +64,7 @@ from met_api.models.widgets_subscribe import WidgetSubscribe as WidgetSubscribeModel from met_api.utils.constants import TENANT_ID_HEADER from met_api.utils.enums import MembershipStatus +from met_api.constants.email_verification import EmailVerificationType from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementMetadataInfo, TestEngagementMetadataTaxonInfo, TestEngagementSlugInfo, TestEngagementTranslationInfo, TestEventItemTranslationInfo, TestEventnfo, TestFeedbackInfo, @@ -595,7 +595,8 @@ def factory_survey_translation_and_engagement_model(): survey_id=survey.id, language_id=lang.id, name=TestSurveyTranslationInfo.survey_translation1.get('name'), - form_json=TestSurveyTranslationInfo.survey_translation1.get('form_json'), + form_json=TestSurveyTranslationInfo.survey_translation1.get( + 'form_json'), ) translation.save() return translation, survey, lang diff --git a/met-web/src/App.scss b/met-web/src/App.scss index b61bc6896..ac069d4cb 100644 --- a/met-web/src/App.scss +++ b/met-web/src/App.scss @@ -31,6 +31,10 @@ code { &:hover { background-color: var(--bcds-surface-secondary-hover) !important; } + &.Mui-selected { + // from MET design library + background-color: var(--bcds-surface-brand-blue-20) !important; + } } /** Overriding Met table styles **/ diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index db5861fe7..af86b27cc 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -17,6 +17,7 @@ const Endpoints = { }, MetadataTaxa: { GET_BY_TENANT: `${AppConfig.apiUrl}/engagement_metadata/taxa`, + FILTER_BY_TENANT: `${AppConfig.apiUrl}/engagement_metadata/taxa/filters/`, REORDER: `${AppConfig.apiUrl}/engagement_metadata/taxa`, CREATE: `${AppConfig.apiUrl}/engagement_metadata/taxa`, GET: `${AppConfig.apiUrl}/engagement_metadata/taxon/taxon_id`, diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index 75ce81609..c4e871315 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,11 +1,9 @@ import React, { createContext, useState, useEffect, useMemo } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; import { getEngagementMetadata, getMetadataTaxa } from '../../../services/engagementMetadataService'; -import { getEngagementContent } from 'services/engagementContentService'; import { useNavigate, useParams } from 'react-router-dom'; import { EngagementContext, EngagementForm, EngagementFormUpdate, EngagementParams } from './types'; import { createDefaultEngagement, Engagement, EngagementMetadata, MetadataTaxon } from '../../../models/engagement'; -import { createDefaultEngagementContent, EngagementContent } from 'models/engagementContent'; import { saveObject } from 'services/objectStorageService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch, useAppSelector } from 'hooks'; @@ -14,6 +12,8 @@ import { updatedDiff } from 'deep-object-diff'; import { PatchEngagementRequest } from 'services/engagementService/types'; import { USER_ROLES } from 'services/userService/constants'; import { EngagementStatus } from 'constants/engagementStatus'; +import { EngagementContent, createDefaultEngagementContent } from 'models/engagementContent'; +import { getEngagementContent } from 'services/engagementContentService'; const CREATE = 'create'; export const ActionContext = createContext({ @@ -80,7 +80,6 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { const [bannerImage, setBannerImage] = useState(); const [savedBannerImageFileName, setSavedBannerImageFileName] = useState(''); const [contentTabs, setContentTabs] = useState([createDefaultEngagementContent()]); - const isCreate = window.location.pathname.includes(CREATE); const handleAddBannerImage = (files: File[]) => { @@ -136,6 +135,17 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { } }; + 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 fetchEngagementContents = async () => { if (isCreate) { return; @@ -150,17 +160,6 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { } }; - const taxonMetadata = useMemo(() => { - const taxonMetadataMap = new Map(); - engagementMetadata.forEach((metadata) => { - if (!taxonMetadataMap.has(metadata.taxon_id)) { - taxonMetadataMap.set(metadata.taxon_id, []); - } - taxonMetadataMap.get(metadata.taxon_id)?.push(metadata.value); - }); - return taxonMetadataMap; - }, [engagementMetadata]); - const setEngagement = (engagement: Engagement) => { setSavedEngagement({ ...engagement }); setIsNewEngagement(!savedEngagement.id); diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementContent/ContentTabs.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementContent/ContentTabs.tsx index c77acee0a..455412994 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementContent/ContentTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementContent/ContentTabs.tsx @@ -12,6 +12,7 @@ import { ActionContext } from '../../ActionContext'; import { EngagementContentContext } from './EngagementContentContext'; import { If, Then, Else } from 'react-if'; import ContentTabModal from './ContentTabModal'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; export const ContentTabs: React.FC = () => { const { fetchEngagementContents, contentTabs, setContentTabs, savedEngagement } = useContext(ActionContext); @@ -106,7 +107,7 @@ export const ContentTabs: React.FC = () => { handleEditTab(index)} aria-label="edit"> @@ -118,7 +119,10 @@ export const ContentTabs: React.FC = () => { onClick={() => handleDeleteTab(index)} aria-label="delete" > - + )} @@ -133,7 +137,7 @@ export const ContentTabs: React.FC = () => { disabled={isAllTabTypesPresent()} // Disable the button if customTabAdded is true data-testid="add-tab-menu" > - + diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index 2a8281d94..4d4d6ce10 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -508,7 +508,7 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re const handleSaveEngagementMetadata = async () => { const result = await metadataFormRef.current?.submitForm(); - if (!result) { + if (metadataFormRef.current && !result) { dispatch( openNotification({ severity: 'error', diff --git a/met-web/src/components/landing/DeletableFilterChip.tsx b/met-web/src/components/landing/DeletableFilterChip.tsx new file mode 100644 index 000000000..c338d9e8d --- /dev/null +++ b/met-web/src/components/landing/DeletableFilterChip.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Chip } from '@mui/material'; +import { Close } from '@mui/icons-material'; +import { useAppTranslation } from 'hooks'; + +export const DeletableFilterChip = ({ name, onDelete }: { name: string; onDelete?: () => void }) => { + const { t: translate } = useAppTranslation(); + return ( + } + variant="outlined" + // enable deleting with backspace but hide the icon + deleteIcon={} + onDelete={onDelete} + // make clicking anywhere on the chip also delete it (larger touch target) + onClick={onDelete} + sx={{ + mt: '1px', + mr: 1, + mb: 2, + p: 1, + height: 48, + fontWeight: 'normal', + borderRadius: '2em', + maxWidth: '300px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '14px', + }} + > + ); +}; diff --git a/met-web/src/components/landing/FilterBlock.tsx b/met-web/src/components/landing/FilterBlock.tsx new file mode 100644 index 000000000..60841651b --- /dev/null +++ b/met-web/src/components/landing/FilterBlock.tsx @@ -0,0 +1,298 @@ +import React, { useEffect, useRef, useState, useContext } from 'react'; +import { LandingContext } from './LandingContext'; +import { + Button, + Select, + MenuItem, + ListItemIcon, + ListItemText, + Grid, + TextField, + IconButton, + Stack, + useTheme, +} from '@mui/material'; +import { DeletableFilterChip } from './DeletableFilterChip'; +import { Close, Check, HighlightOff, Tune, Search } from '@mui/icons-material'; +import { MetadataFilter } from 'components/metadataManagement/types'; +import { MetLabel } from 'components/common'; +import { debounce } from 'lodash'; +import { EngagementDisplayStatus } from 'constants/engagementStatus'; +import { useAppTranslation } from 'hooks'; + +const FilterBlock = () => { + const { searchFilters, setSearchFilters, setPage, clearFilters, page, setDrawerOpened } = + useContext(LandingContext); + const selectedValue = searchFilters.status.length === 0 ? -1 : searchFilters.status[0]; + + const tileBlockRef = useRef(null); + const [didMount, setDidMount] = useState(false); + + const theme = useTheme(); + const { t: translate } = useAppTranslation(); + + const selectableStatuses: Map = new Map([ + [EngagementDisplayStatus.Open, translate('landing.filters.status.open')], + [EngagementDisplayStatus.Upcoming, translate('landing.filters.status.upcoming')], + [EngagementDisplayStatus.Closed, translate('landing.filters.status.closed')], + [-1, translate('landing.filters.status.all')], + ]); + + const debounceSetSearchFilters = useRef( + debounce((searchText: string) => { + setSearchFilters({ + ...searchFilters, + name: searchText, + }); + }, 300), + ).current; + + useEffect(() => { + setDidMount(true); + return () => setDidMount(false); + }, []); + + useEffect(() => { + if (didMount) { + const yOffset = tileBlockRef?.current?.offsetTop; + window.scrollTo({ top: yOffset || 0, behavior: 'smooth' }); + } + }, [page]); + + const [searchText, setSearchText] = useState(''); + + const handleDeleteFilterChip = (taxonId: number, value: string) => { + const newMetadataFilters = searchFilters.metadata + .map((filter: MetadataFilter) => { + if (filter.taxon_id === taxonId) { + // Remove the value + const newValues = filter.values.filter((v) => v !== value); + return { ...filter, values: newValues }; + } + return filter; + }) + .filter((filter) => filter.values.length > 0); // Remove any filters with no values left + + setSearchFilters({ ...searchFilters, metadata: newMetadataFilters }); + setPage(1); + }; + + return ( + + + + {translate('landing.filters.search')} + { + setSearchText(event.target.value); + debounceSetSearchFilters(event.target.value); + }} + InputProps={{ + sx: { height: 48 }, + startAdornment: ( + + ), + endAdornment: searchText ? ( + { + setSearchFilters({ + ...searchFilters, + name: '', + }); + setSearchText(''); + }} + > + + + ) : undefined, + }} + /> + + + + + + + + + {searchFilters.metadata.map((filter) => + filter.values.map((value) => ( + handleDeleteFilterChip(filter.taxon_id, value)} + /> + )), + )} + + + + + ); +}; + +export default FilterBlock; diff --git a/met-web/src/components/landing/FilterDrawer.tsx b/met-web/src/components/landing/FilterDrawer.tsx new file mode 100644 index 000000000..fd87b61ab --- /dev/null +++ b/met-web/src/components/landing/FilterDrawer.tsx @@ -0,0 +1,176 @@ +import React, { useContext, useMemo } from 'react'; +import { LandingContext } from './LandingContext'; +import { SwipeableDrawer, IconButton, Typography, Stack, Button, Grid, useTheme, useMediaQuery } from '@mui/material'; +import { Close } from '@mui/icons-material'; +import { MetadataFilterChip } from './MetadataFilterChip'; +import { EngagementDisplayStatus } from 'constants/engagementStatus'; +import { useAppTranslation } from 'hooks'; + +const FilterDrawer = () => { + const { searchFilters, setSearchFilters, setPage, metadataFilters, clearFilters, drawerOpened, setDrawerOpened } = + useContext(LandingContext); + + const theme = useTheme(); + const { t: translate } = useAppTranslation(); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const selectedValue = useMemo(() => { + if (searchFilters.status.length === 0) { + return -1; + } + return searchFilters.status[0]; + }, [searchFilters.status]); + + const handleMetadataFilterClick = (taxonId: number, value: string) => { + const existingFilter = searchFilters.metadata.find((filter) => filter.taxon_id === taxonId); + let newValues; + if (existingFilter) { + // Toggle value in or out + newValues = existingFilter.values.includes(value) + ? existingFilter.values.filter((v) => v !== value) + : [...existingFilter.values, value]; + } else { + newValues = [value]; + } + const metadataFilter = metadataFilters.find((f) => f.taxon_id === taxonId); + const newMetadataFilters = searchFilters.metadata.filter((filter) => filter.taxon_id !== taxonId); + if (newValues.length > 0 && metadataFilter) { + newMetadataFilters.push({ + name: metadataFilter.name, + values: newValues, + filter_type: metadataFilter.filter_type, + taxon_id: taxonId, + }); + } + + setSearchFilters({ ...searchFilters, metadata: newMetadataFilters }); + setPage(1); + }; + + return ( + setDrawerOpened(false)} + onOpen={() => setDrawerOpened(true)} + > + setDrawerOpened(false)} + title={translate('landing.filters.aria.closeDrawer')} + sx={{ + color: 'white', + position: 'absolute', + top: '1em', + right: '1em', + }} + > + + + + {translate('landing.filters.drawer.title')} + + + + {translate('landing.filters.drawer.statusFilter')} + + + {[ + -1, + EngagementDisplayStatus.Open, + EngagementDisplayStatus.Upcoming, + EngagementDisplayStatus.Closed, + ].map((status) => ( + { + setSearchFilters({ + ...searchFilters, + status: status == -1 ? [] : [status], + }); + setPage(1); + }} + /> + ))} + + + {metadataFilters.map((metadataFilter) => ( + <> + + {translate('landing.filters.drawer.filterHeader').replace( + '{0}', + metadataFilter.name ?? 'metadata', + )} + + + {metadataFilter.values.map((value) => ( + + filter.taxon_id === metadataFilter.taxon_id && filter.values.includes(value), + )} + onClick={() => handleMetadataFilterClick(metadataFilter.taxon_id, value)} + /> + ))} + + + ))} + + + + + + + + ); +}; + +export default FilterDrawer; diff --git a/met-web/src/components/landing/LandingComponent.tsx b/met-web/src/components/landing/LandingComponent.tsx index 215d2399a..042e0d0ab 100644 --- a/met-web/src/components/landing/LandingComponent.tsx +++ b/met-web/src/components/landing/LandingComponent.tsx @@ -1,47 +1,21 @@ -import React, { useContext, useEffect, useRef, useState } from 'react'; -import { Grid, MenuItem, TextField } from '@mui/material'; +import React from 'react'; +import { Grid } from '@mui/material'; import { Banner } from 'components/banner/Banner'; -import { MetHeader1, MetLabel, MetParagraph } from 'components/common'; +import { MetHeader1, MetParagraph } from 'components/common'; import TileBlock from './TileBlock'; -import { debounce } from 'lodash'; -import { EngagementDisplayStatus } from 'constants/engagementStatus'; -import { LandingContext } from './LandingContext'; import { Container } from '@mui/system'; import LandingPageBanner from 'assets/images/LandingPageBanner.png'; import { useAppTranslation } from 'hooks'; - +import FilterBlock from './FilterBlock'; +import FilterDrawer from './FilterDrawer'; const LandingComponent = () => { - const { searchFilters, setSearchFilters, setPage, page } = useContext(LandingContext); - const [didMount, setDidMount] = useState(false); const { t: translate } = useAppTranslation(); - const debounceSetSearchFilters = useRef( - debounce((searchText: string) => { - setSearchFilters({ - ...searchFilters, - name: searchText, - }); - }, 300), - ).current; - - const tileBlockRef = useRef(null); - - useEffect(() => { - setDidMount(true); - return () => setDidMount(false); - }, []); - - useEffect(() => { - if (didMount) { - const yOffset = tileBlockRef?.current?.offsetTop; - window.scrollTo({ top: yOffset || 0, behavior: 'smooth' }); - } - }, [page]); - return ( - + + - + { - - - - {translate('landingPage.engagementNameLabel')} - { - debounceSetSearchFilters(event.target.value); - }} - /> - - - {translate('landingPage.statusLabel')} - { - setSearchFilters({ - ...searchFilters, - status: event.target.value ? [Number(event.target.value)] : [], - }); - setPage(1); - }} - select - InputLabelProps={{ - shrink: false, - }} - > - - {''} - - - {translate('landingPage.status.open')} - - - {translate('landingPage.status.upcoming')} - - - {translate('landingPage.status.closed')} - - - - - - - + + + diff --git a/met-web/src/components/landing/LandingContext.tsx b/met-web/src/components/landing/LandingContext.tsx index 70110018b..10988b849 100644 --- a/met-web/src/components/landing/LandingContext.tsx +++ b/met-web/src/components/landing/LandingContext.tsx @@ -1,11 +1,14 @@ import React, { createContext, useState, useEffect } from 'react'; import { Engagement } from 'models/engagement'; import { getEngagements } from 'services/engagementService'; +import { getMetadataFilters } from 'services/engagementMetadataService'; import { PAGE_SIZE } from './constants'; +import { MetadataFilter } from 'components/metadataManagement/types'; interface SearchFilters { name: string; status: number[]; + metadata: MetadataFilter[]; } export interface LandingContextProps { @@ -16,12 +19,18 @@ export interface LandingContextProps { totalEngagements: number; page: number; setPage: React.Dispatch>; + metadataFilters: MetadataFilter[]; + clearFilters: () => void; + drawerOpened: boolean; + setDrawerOpened: React.Dispatch>; } const initialSearchFilters = { name: '', status: [], + metadata: [], }; + export const LandingContext = createContext({ engagements: [], loadingEngagements: false, @@ -34,14 +43,26 @@ export const LandingContext = createContext({ setPage: () => { throw new Error('setPage unimplemented'); }, + metadataFilters: [], + clearFilters: () => { + throw new Error('clearFilters unimplemented'); + }, + drawerOpened: false, + setDrawerOpened: () => { + throw new Error('setDrawerOpened unimplemented'); + }, }); export const LandingContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { const [engagements, setEngagements] = useState([]); const [totalEngagements, setTotalEngagements] = useState(0); const [loadingEngagements, setLoadingEngagements] = useState(true); + // The array of filters that are available for the user to select + const [metadataFilters, setMetadataFilters] = useState([]); const [searchFilters, setSearchFilters] = useState(initialSearchFilters); const [page, setPage] = useState(1); + // whether the filter drawer is covering the screen + const [drawerOpened, setDrawerOpened] = useState(false); const loadEngagements = async () => { try { @@ -54,17 +75,42 @@ export const LandingContextProvider = ({ children }: { children: JSX.Element | J include_banner_url: true, engagement_status: status, search_text: name, + metadata: searchFilters.metadata, }); setEngagements(loadedEngagements.items); setTotalEngagements(loadedEngagements.total); setLoadingEngagements(false); - } catch (error) {} + } catch (error) { + console.error(error); + } }; useEffect(() => { loadEngagements(); }, [searchFilters, page]); + const loadFilters = async () => { + try { + const loadedFilters = await getMetadataFilters(); + setMetadataFilters(loadedFilters); + } catch (error) { + console.error(error); + } + }; + + useEffect(() => { + loadFilters(); + }, []); + + const clearFilters = () => { + setSearchFilters({ + ...searchFilters, + status: [], + metadata: [], + }); + setPage(1); + }; + return ( {children} diff --git a/met-web/src/components/landing/MetadataFilterChip.tsx b/met-web/src/components/landing/MetadataFilterChip.tsx new file mode 100644 index 000000000..d97ded561 --- /dev/null +++ b/met-web/src/components/landing/MetadataFilterChip.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Chip, useTheme } from '@mui/material'; +import { Check } from '@mui/icons-material'; +import { useAppTranslation } from 'hooks'; + +export const MetadataFilterChip = ({ + name, + selected, + onClick, +}: { + name: string; + selected?: boolean; + onClick?: () => void; +}) => { + const theme = useTheme(); + const { t: translate } = useAppTranslation(); + const selectionHint = translate(selected ? 'landing.filters.aria.selected' : 'landing.filters.aria.notSelected'); + return ( + : undefined} + variant={selected ? 'filled' : 'outlined'} + onClick={onClick} + sx={{ + mr: 1, + mb: 2, + p: 1, + height: '48px', + fontWeight: selected ? 'bold' : 'normal', + borderColor: selected ? '#053662' : '#D8EAFD', + borderRadius: '2em', + backgroundColor: selected ? '#D8EAFD' : 'transparent', + color: selected ? theme.palette.primary.main : 'white', + fontSize: '16px', + '&.MuiChip-clickable:hover': { + backgroundColor: selected ? '#F1F8FE' : '#1E5189', + }, + '&:focus': { + backgroundColor: selected ? '#F1F8FE' : '#1E5189', + }, + }} + /> + ); +}; diff --git a/met-web/src/components/landing/TileBlock.tsx b/met-web/src/components/landing/TileBlock.tsx index c68e74ec0..5109edfd3 100644 --- a/met-web/src/components/landing/TileBlock.tsx +++ b/met-web/src/components/landing/TileBlock.tsx @@ -18,6 +18,8 @@ const TileBlock = () => { justifyContent="flex-start" columnSpacing={2} rowSpacing={4} + item + xs={10} > @@ -33,6 +35,8 @@ const TileBlock = () => { alignItems="flex-start" columnSpacing={2} rowSpacing={4} + item + xs={10} > {engagements.map((engagement) => { return ( @@ -47,7 +51,7 @@ const TileBlock = () => { justifyContent={{ xs: 'center', sm: 'flex-start' }} alignItems={{ xs: 'center', sm: 'flex-start' }} > - + diff --git a/met-web/src/components/metadataManagement/MetadataFilterTypes.tsx b/met-web/src/components/metadataManagement/MetadataFilterTypes.tsx new file mode 100644 index 000000000..ae733f180 --- /dev/null +++ b/met-web/src/components/metadataManagement/MetadataFilterTypes.tsx @@ -0,0 +1,19 @@ +import { EditAttributes, EditAttributesOutlined } from '@mui/icons-material'; +import { MetadataFilterType } from './types'; + +export const MetadataFilterTypes: { [key: string]: MetadataFilterType } = { + chips_any: { + name: 'Chips (Match Any Selected)', + code: 'chips_any', + details: + 'Users can click chips to filter by metadata. At least one of the selected values must be present on the engagement for it to be shown.', + icon: EditAttributesOutlined, + }, + chips_all: { + name: 'Chips (Match All Selected)', + code: 'chips_all', + details: + 'Users can click chips to filter by metadata. All the selected values must be present on the engagement for it to be shown.', + icon: EditAttributes, + }, +}; diff --git a/met-web/src/components/metadataManagement/TaxonCard.tsx b/met-web/src/components/metadataManagement/TaxonCard.tsx index 37c60c230..0454f4f5d 100644 --- a/met-web/src/components/metadataManagement/TaxonCard.tsx +++ b/met-web/src/components/metadataManagement/TaxonCard.tsx @@ -13,12 +13,22 @@ import { Divider, } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { ExpandMore, DragIndicator, FormatQuote, EditAttributes, InsertDriveFile, FileCopy } from '@mui/icons-material'; +import { + ExpandMore, + DragIndicator, + FormatQuote, + EditAttributes, + InsertDriveFile, + FileCopy, + FilterAlt, + FilterAltOff, +} from '@mui/icons-material'; import React from 'react'; import { MetHeader4 } from 'components/common'; import { TaxonTypes } from './TaxonTypes'; import { TaxonCardProps } from './types'; import { Draggable, DraggableProvided } from '@hello-pangea/dnd'; +import { MetadataFilterTypes } from './MetadataFilterTypes'; const DetailsRow = ({ name, icon, children }: { name: string; icon: React.ReactNode; children: React.ReactNode }) => { const theme = useTheme(); @@ -224,6 +234,25 @@ export const TaxonCard: React.FC = ({ taxon, isExpanded, onExpan : 'Unlimited values per engagement.'} + + {/* Filter Type */} + {Boolean(taxonType.supportedFilters) && ( + : } + > + + {taxon.filter_type + ? MetadataFilterTypes[taxon.filter_type].name + : 'Engagements are not filtered by this field.'} + + {taxon.filter_type && ( + + {MetadataFilterTypes[taxon.filter_type].details} + + )} + + )} diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx index 5fdbc01b0..742aa8374 100644 --- a/met-web/src/components/metadataManagement/TaxonEditForm.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -6,15 +6,16 @@ import { FormGroup, Select, Grid, - InputLabel, Button, MenuItem, Tooltip, Avatar, Collapse, Box, + useMediaQuery, + Theme, } from '@mui/material'; -import { Save, Check, Edit, Close, Delete, Error, VerifiedUser, ShieldMoon, Queue, AddBox } from '@mui/icons-material'; +import { Save, Check, Edit, Close, Delete, Error, FilterAltOff, HelpOutline } from '@mui/icons-material'; import * as yup from 'yup'; import React, { useContext, useEffect } from 'react'; import { MetadataTaxon } from 'models/engagement'; @@ -25,12 +26,22 @@ 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 { MetHeader3, MetLabel } from 'components/common'; import { openNotification } from 'services/notificationService/notificationSlice'; +const HelpTooltip = ({ children }: { children: string | string[] }) => { + if (Array.isArray(children)) children = children.join(' '); + return ( + + + + ); +}; + const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { const { setSelectedTaxonId, updateMetadataTaxon, removeMetadataTaxon } = useContext(ActionContext); const dispatch = useAppDispatch(); + const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); const methods = useForm({ defaultValues: { name: taxon.name, @@ -39,6 +50,8 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { one_per_engagement: taxon.one_per_engagement, data_type: taxon.data_type, preset_values: taxon.preset_values, + filter_type: taxon.filter_type, + include_freeform: taxon.include_freeform, }, }); const { @@ -58,6 +71,8 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { one_per_engagement: taxon.one_per_engagement ?? false, data_type: taxon.data_type ?? 'text', preset_values: taxon.preset_values ?? [], + filter_type: taxon.filter_type ?? 'none', + include_freeform: taxon.include_freeform ?? false, }); }, [taxon, reset]); @@ -66,7 +81,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { const taxonType = TaxonTypes[dataType as keyof typeof TaxonTypes]; // Watch freeform to update label const isFreeform = watch('freeform'); - const isMulti = !watch('one_per_engagement'); + const isValidFilter = watch('filter_type') !== 'none'; const presetValues = watch('preset_values'); const schema = yup.object().shape({ @@ -76,7 +91,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { 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, + is: (value: string) => taxonType.supportsPresetValues, then: yup.mixed().when('freeform', { is: false, then: yup @@ -87,6 +102,21 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { }), otherwise: yup.mixed().strip(), }), + filter_type: yup.string().when('data_type', { + is: (value: string) => (taxonType.supportedFilters ?? []).length > 0, + then: yup + .string() + .oneOf(['none', ...(taxonType.supportedFilters ?? []).map((filter) => filter.code)]) + .nullable(), + otherwise: yup.string().strip(), + }), + include_freeform: yup.boolean().when('filter_type', { + is: (value: string) => { + return taxonType.allowFreeformInFilter; + }, + then: yup.boolean().optional(), + otherwise: yup.boolean().strip(), + }), }); const onSubmit: SubmitHandler = async (data, event) => { @@ -101,6 +131,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { // 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) { + console.error(error); // eslint-disable-next-line @typescript-eslint/no-explicit-any formErrors = error.inner.reduce((errors: { [key: string]: string }, innerError: any) => { errors[innerError.path] = innerError.message; @@ -117,7 +148,11 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { if (Object.keys(formErrors).length) { dispatch( - openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), + // openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), + openNotification({ + severity: 'info', + text: 'This state is never used and I had to make a custom function to open it', + }), ); return false; } @@ -130,8 +165,10 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { 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 : [], + filter_type: data.filter_type === 'none' ? null : data.filter_type, + include_freeform: data.include_freeform, }; - + console.log(updatedTaxon); updateMetadataTaxon(updatedTaxon); }; @@ -158,9 +195,11 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { - - - + {!isSmallScreen && ( + + + + )} Edit taxon @@ -175,12 +214,13 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { + Taxon Name ( { /> + Taxon Description { multiline minRows={2} maxRows={8} - label="Taxon Description" + placeholder="[Optional] Enter a description" value={field.value} onChange={field.onChange} error={!!fieldState?.error} @@ -209,13 +250,19 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { /> + + Data Type + + The type of data that this taxon will store. Affects the availability of some other + options. + + - Type ( - {Object.entries(TaxonTypes).map(([key, type]: [string, TaxonType]) => ( @@ -231,57 +278,153 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { /> - - - - + + + Preset Values + + These values will be shown to staff as possible options when creating engagements. + {(Boolean(taxonType.supportedFilters) || '') && + "If filtering is enabled below, they are also publicly visible as the filter's options."} + + + - - - { - return ( - onChange(!e.target.checked)} - /> - ); - }} - /> - } - label={ - - {isFreeform ? : } - Limit to preset values + + { + return ( + onChange(e.target.checked)} + /> + ); + }} + /> + } + label={ + + + Allow custom values - } - /> - + + If enabled, users can enter custom values when creating engagements. Otherwise, + only preset values can be used. + + + } + /> - - - ( - onChange(!e.target.checked)} /> - )} - /> - } - label={ - - {isMulti ? : } - Allow multiple values + + ( + onChange(!e.target.checked)} /> + )} + /> + } + label={ + + + Allow multiple values - } - /> - + + When enabled, users can enter multiple values for this taxon in a single + engagement. Otherwise, only one value can be entered. + + + } + /> + + + + Engagement Filtering + + Selecting a filter style allows the public to use this taxon to narrow down which + engagements they want to see. "No filtering" means it will not be publicly shown. For + details on the selected filter, expand and read the blue highlighted {taxon.name ?? ''}{' '} + card.` + + + + ( + + )} + > + + + + ( + onChange(e.target.checked)} /> + )} + /> + } + label={ + + + Include custom values as filter options + + + If enabled, custom values entered on engagements will be available as filter + options, in addition to the preset values. + + + } + /> @@ -323,16 +466,19 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { - + + {/* Span is used to allow disabled elements in a tooltip */} + + diff --git a/met-web/src/components/metadataManagement/TaxonEditor.tsx b/met-web/src/components/metadataManagement/TaxonEditor.tsx index 759d3d563..78393842f 100644 --- a/met-web/src/components/metadataManagement/TaxonEditor.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditor.tsx @@ -1,9 +1,9 @@ -import { Grid, Box, Paper, IconButton, Modal, Button, Typography, Chip } from '@mui/material'; +import { Grid, Box, Paper, IconButton, Modal, Button, Typography, Chip, Fade } 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 React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { reorder } from 'utils'; import { MetadataTaxon } from 'models/engagement'; import { MetDroppable } from 'components/common/Dragdrop'; @@ -78,35 +78,40 @@ export const TaxonEditor = () => { const [showScrollIndicators, setShowScrollIndicators] = useState({ top: false, - bottom: true, + bottom: false, }); const scrollableRef = useRef(null); + const checkScroll = useCallback(() => { + if (!scrollableRef.current) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; + const scrollMargin = 20; + setShowScrollIndicators({ + top: scrollTop > scrollMargin, + bottom: scrollTop < scrollHeight - clientHeight - scrollMargin, + }); + }, []); + 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, - }); - }; - + if (!currentRef) return; currentRef.addEventListener('scroll', checkScroll); - // Initial check checkScroll(); return () => currentRef.removeEventListener('scroll', checkScroll); - }, [orderedMetadataTaxa]); + }, [checkScroll]); + + useEffect(() => { + // Call checkScroll with a delay after any expansion/collapse animation completes + const timer = setTimeout(() => { + checkScroll(); + }, 300); + + return () => clearTimeout(timer); // Cleanup the timer if the component unmounts or if expandedCards changes again before the timer fires + }, [expandedCards, checkScroll]); // Depend on expandedCards and checkScroll const scroll = (amount: number) => { const scrollableDiv = scrollableRef.current; @@ -179,28 +184,29 @@ export const TaxonEditor = () => { }} elevation={3} > - - } - onClick={() => { - scroll(-400); + + - + > + } + onClick={() => { + scroll(-400); + }} + /> + + { /> ); })} - {isLoading && [...Array(9)].map(() => )} + {isLoading && [...Array(8)].map(() => )} {!isLoading && orderedMetadataTaxa.length === 0 && ( <> @@ -253,29 +259,30 @@ export const TaxonEditor = () => { - - } - onClick={() => { - scroll(400); + + - + > + } + onClick={() => { + scroll(400); + }} + /> + + @@ -294,6 +301,8 @@ export const TaxonEditor = () => { width: '80%', maxWidth: '40em', minWidth: '320px', + maxHeight: '90vh', + overflowY: 'scroll', }} > TaxonPicker({ ...props, pickerType: PickerTypes.DATE }), }, diff --git a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx index 7a3c7cb34..b0392901f 100644 --- a/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx +++ b/met-web/src/components/metadataManagement/presetFieldsEditor/PresetValuesEditor.tsx @@ -1,7 +1,7 @@ import React, { SyntheticEvent, useState } from 'react'; import { Autocomplete, TextField, Chip, IconButton, Stack } from '@mui/material'; import { Control, Controller, FieldError } from 'react-hook-form'; -import { ArrowCircleUp, HighlightOff } from '@mui/icons-material'; +import { AddCircleOutline, HighlightOff } from '@mui/icons-material'; const PresetValuesEditor = ({ control, // The control object (from react-hook-form) @@ -66,7 +66,6 @@ const PresetValuesEditor = ({ - + )} void; } +export interface MetadataFilterType { + name: string; + code: string; + details: string; + icon: SvgIconComponent; + // TODO: allow for custom input components based on the passed taxon type +} + +export interface MetadataFilter { + taxon_id: number; + name?: string; + filter_type: string; + values: string[]; +} + export interface TaxonType { name: string; icon: SvgIconComponent; supportsPresetValues: boolean; supportsFreeform: boolean; supportsMulti: boolean; + supportedFilters?: MetadataFilterType[]; + allowFreeformInFilter?: boolean; yupValidator: yup.AnySchema; customInput?: (props: GenericInputProps) => JSX.Element; externalResource?: (value: string) => string; diff --git a/met-web/src/locales/en/default.json b/met-web/src/locales/en/default.json index 0e7a8c83a..f2e258ed0 100644 --- a/met-web/src/locales/en/default.json +++ b/met-web/src/locales/en/default.json @@ -13,6 +13,35 @@ "banner": { "header": "The Title of The Office", "description": "Description about the office and public engagement." + }, + "filters": { + "drawer": { + "openButton": "Filter", + "title": "Filter Engagements", + "apply": "Apply Filters", + "statusFilter": "Engagement Status", + "filterHeader": "Filter by {0}" + }, + "clear": "Clear Filters", + "search": "Search Engagements", + "searchPlaceholder": "Engagement Title", + "status": { + "all": "All Engagements", + "open": "Open Engagements", + "closed": "Closed Engagements", + "upcoming": "Upcoming Engagements" + }, + "aria": { + "closeDrawer": "Close filter options", + "openDrawer": "Open more filter options", + "deleteFilterChip": "{0} filter - press to remove", + "metadataFilterChip": "{0} filter - {1}", + "selected": "Applied", + "notSelected": "Not Applied", + "applyFilters":"Apply Filters and close filter options", + "clearFilters":"Clear all filters", + "statusFilter": "Engagement Status Selector - {0} selected" + } } }, "comment": { @@ -30,17 +59,9 @@ "contactEmail": "email@gov.bc.ca" }, "landingPage": { - "engagementNameLabel": "Engagement name", - "placeholder": "Type engagement's name...", - "statusLabel": "Status", "tile": { - "error": "error Loading", + "error": "Error while loading", "status": "Status:" - }, - "status": { - "open": "Open", - "upcoming": "Upcoming", - "closed": "Closed" } }, "buttonText": { diff --git a/met-web/src/locales/en/gdx.json b/met-web/src/locales/en/gdx.json index 50ff56046..3a837e7bf 100644 --- a/met-web/src/locales/en/gdx.json +++ b/met-web/src/locales/en/gdx.json @@ -13,6 +13,35 @@ "banner": { "header": "Government Digital Experience Division", "description": "The GDX Division helps inform digital standards for web content, accessibility, forms, and design.." + }, + "filters": { + "drawer": { + "openButton": "Filter", + "title": "Filter Engagements", + "apply": "Apply Filters", + "statusFilter": "Engagement Status", + "filterHeader": "Filter by {0}" + }, + "clear": "Clear Filters", + "search": "Search Engagements", + "searchPlaceholder": "Engagement Title", + "status": { + "all": "All Engagements", + "open": "Open Engagements", + "closed": "Closed Engagements", + "upcoming": "Upcoming Engagements" + }, + "aria": { + "closeDrawer": "Close filter options", + "openDrawer": "Open more filter options", + "deleteFilterChip": "{0} filter - press to remove", + "metadataFilterChip": "{0} filter - {1}", + "selected": "Applied", + "notSelected": "Not Applied", + "applyFilters":"Apply Filters and close filter options", + "clearFilters":"Clear all filters", + "statusFilter": "Engagement Status Selector - {0} selected" + } } }, "comment": { diff --git a/met-web/src/models/engagement.ts b/met-web/src/models/engagement.ts index e78db3304..fd8ca0d41 100644 --- a/met-web/src/models/engagement.ts +++ b/met-web/src/models/engagement.ts @@ -40,6 +40,8 @@ export interface MetadataTaxonModify { 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 + filter_type?: string | null; // The filter type for the taxon, optional + include_freeform?: boolean; // Whether to include freeform values in options for filtering, optional } export interface MetadataTaxon extends MetadataTaxonModify { diff --git a/met-web/src/services/engagementMetadataService/index.ts b/met-web/src/services/engagementMetadataService/index.ts index 2ee373ac9..3edadd39e 100644 --- a/met-web/src/services/engagementMetadataService/index.ts +++ b/met-web/src/services/engagementMetadataService/index.ts @@ -2,6 +2,7 @@ import http from 'apiManager/httpRequestHandler'; import { EngagementMetadata, MetadataTaxonModify, MetadataTaxon } from 'models/engagement'; import Endpoints from 'apiManager/endpoints'; import { replaceUrl } from 'helper'; +import { MetadataFilter } from 'components/metadataManagement/types'; export const getEngagementMetadata = async (engagementId: number): Promise => { const url = replaceUrl(Endpoints.EngagementMetadata.GET_BY_ENG, 'engagement_id', String(engagementId)); @@ -52,6 +53,14 @@ export const getMetadataTaxa = async (): Promise> => { throw new Error('Failed to fetch metadata taxa'); }; +export const getMetadataFilters = async (): Promise> => { + const response = await http.GetRequest>(Endpoints.MetadataTaxa.FILTER_BY_TENANT); + if (response.data) { + return response.data; + } + throw new Error('Failed to fetch available filters'); +}; + export const getMetadataTaxon = async (taxonId: number): Promise => { const url = replaceUrl(Endpoints.MetadataTaxa.GET, 'taxon_id', String(taxonId)); if (!taxonId || isNaN(Number(taxonId))) { diff --git a/met-web/src/services/engagementService/index.ts b/met-web/src/services/engagementService/index.ts index ad1d38536..cf03a954e 100644 --- a/met-web/src/services/engagementService/index.ts +++ b/met-web/src/services/engagementService/index.ts @@ -6,6 +6,7 @@ import { PatchEngagementRequest, PostEngagementRequest, PutEngagementRequest } f import Endpoints from 'apiManager/endpoints'; import { replaceUrl } from 'helper'; import { Page } from 'services/type'; +import { MetadataFilter } from 'components/metadataManagement/types'; export const fetchAll = async (dispatch: Dispatch): Promise => { const responseData = await http.GetRequest(Endpoints.Engagement.GET_LIST); @@ -27,6 +28,7 @@ interface GetEngagementsParams { published_to_date?: string; include_banner_url?: boolean; has_team_access?: boolean; + metadata?: MetadataFilter[]; } export const getEngagements = async (params: GetEngagementsParams = {}): Promise> => { const responseData = await http.GetRequest>(Endpoints.Engagement.GET_LIST, params); diff --git a/met-web/src/services/userService/index.ts b/met-web/src/services/userService/index.ts index f10669026..7996170d1 100644 --- a/met-web/src/services/userService/index.ts +++ b/met-web/src/services/userService/index.ts @@ -97,7 +97,7 @@ const refreshToken = (dispatch: Dispatch) => { }, 60000); }; -const doLogin = (redirectUri?: string) => KeycloakData.login({ redirectUri: redirectUri ?? getBaseUrl() }); +const doLogin = (redirectUri?: string) => KeycloakData.login({ redirectUri: (redirectUri ?? getBaseUrl()) + '/' }); const doLogout = async () => { // Remove tokens from session storage diff --git a/met-web/tests/unit/components/landingPage/LandingPage.test.tsx b/met-web/tests/unit/components/landingPage/LandingPage.test.tsx index edd8bdbea..7d6b9d7bb 100644 --- a/met-web/tests/unit/components/landingPage/LandingPage.test.tsx +++ b/met-web/tests/unit/components/landingPage/LandingPage.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import { render, waitFor, screen, fireEvent, within, getByRole } from '@testing-library/react'; import React from 'react'; import '@testing-library/jest-dom'; import LandingComponent from 'components/landing/LandingComponent'; @@ -6,7 +6,6 @@ import { setupEnv } from '../setEnvVars'; import { LandingContext } from 'components/landing/LandingContext'; import * as reactRedux from 'react-redux'; import { openEngagement, closedEngagement } from '../factory'; -import userEvent from '@testing-library/user-event'; jest.mock('axios'); @@ -23,6 +22,7 @@ jest.mock('hooks', () => ({ }), })); +// mock enums to fix TS compiler issue when importing them jest.mock('constants/engagementStatus', () => ({ EngagementDisplayStatus: { Draft: 1, @@ -32,6 +32,14 @@ jest.mock('constants/engagementStatus', () => ({ Upcoming: 5, Open: 6, Unpublished: 7, + // Allow backwards lookup like the enum we're mocking + 1: 'Draft', + 2: 'Published', + 3: 'Closed', + 4: 'Scheduled', + 5: 'Upcoming', + 6: 'Open', + 7: 'Unpublished', }, SubmissionStatus: { Upcoming: 1, @@ -62,7 +70,12 @@ describe('Landing page tests', () => { searchFilters: { name: '', status: [], + metadata: [], }, + metadataFilters: [], + clearFilters: jest.fn(), + drawerOpened: false, + setDrawerOpened: jest.fn(), setSearchFilters: jest.fn(), setPage: jest.fn(), page: 1, @@ -76,10 +89,18 @@ describe('Landing page tests', () => { ); await waitFor(() => { - expect(screen.getByPlaceholderText('landingPage.placeholder')).toBeInTheDocument(); - expect(screen.getByText('landingPage.engagementNameLabel')).toBeInTheDocument(); - expect(screen.getByText('landingPage.statusLabel')).toBeInTheDocument(); - expect(screen.getByText('landing.banner.header')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('landing.filters.searchPlaceholder')).toBeInTheDocument(); + expect(screen.getByText('landing.filters.search')).toBeInTheDocument(); + expect(screen.getByText('landing.filters.drawer.openButton')).toBeInTheDocument(); + expect( + screen.getByText((content, element) => { + return ( + (element as HTMLElement).classList.contains('MuiSelect-select') && + element?.textContent === 'landing.filters.status.all' + ); + }), + ).toBeInTheDocument(); + expect(screen.getByText('landing.banner.header')).toBeInTheDocument(); expect(screen.getByText('landing.banner.description')).toBeInTheDocument(); expect(screen.getByText(openEngagement.name)).toBeInTheDocument(); expect(screen.getByText(closedEngagement.name)).toBeInTheDocument(); @@ -95,7 +116,12 @@ describe('Landing page tests', () => { searchFilters: { name: '', status: [], + metadata: [], }, + metadataFilters: [], + clearFilters: jest.fn(), + drawerOpened: false, + setDrawerOpened: jest.fn(), setSearchFilters: setSearchFiltersMock, setPage: jest.fn(), page: 1, @@ -108,7 +134,7 @@ describe('Landing page tests', () => { , ); - const searchInput = screen.getByPlaceholderText('landingPage.placeholder'); + const searchInput = screen.getByPlaceholderText('landing.filters.searchPlaceholder'); fireEvent.change(searchInput, { target: { value: 'New Search' } }); await waitFor(() => { @@ -116,7 +142,7 @@ describe('Landing page tests', () => { }); }); - test('Status filter is working', async () => { + test('Status dropdown is working', async () => { const setSearchFiltersMock = jest.fn(); render( @@ -125,7 +151,12 @@ describe('Landing page tests', () => { searchFilters: { name: '', status: [], + metadata: [], }, + metadataFilters: [], + clearFilters: jest.fn(), + drawerOpened: false, + setDrawerOpened: jest.fn(), setSearchFilters: setSearchFiltersMock, setPage: jest.fn(), page: 1, @@ -142,16 +173,63 @@ describe('Landing page tests', () => { const allButtons = screen.getAllByRole('button'); // Find the specific button with id "status" - const statusDropdown = allButtons.find((button) => button.id === 'status') as HTMLElement; - userEvent.click(statusDropdown); - const openItem = await screen.findByText('landingPage.status.open'); - userEvent.click(openItem); + const statusDropdown = allButtons.find((button) => button.id === 'status-filter') as HTMLElement; + fireEvent.mouseDown(statusDropdown); // click event doesn't work for MUI Select + // Wait for the dropdown to appear + const listbox = within(getByRole(document.body, 'listbox')); + const openOption = listbox.getByText('landing.filters.status.open'); + fireEvent.click(openOption); await waitFor(() => { expect(setSearchFiltersMock).toHaveBeenCalledWith({ name: '', status: [6], // The numeric value corresponding to 'Open' + metadata: [], }); }); }); + + test('Filter drawer is opened and closed', async () => { + const setDrawerOpenedMock = jest.fn(); + + render( + + + , + ); + + const filterButton = screen.getByText('landing.filters.drawer.openButton'); + // Open the drawer... + fireEvent.click(filterButton); + + await waitFor(() => { + expect(setDrawerOpenedMock).toHaveBeenCalledWith(true); + }); + + const closeButton = screen.getByText('landing.filters.drawer.apply'); + // Close it again >:) + fireEvent.click(closeButton); + + await waitFor(() => { + expect(setDrawerOpenedMock).toHaveBeenCalledWith(false); + }); + }); });