From bb89f8d21bf32f89879c0608d2c6d921f0ff4852 Mon Sep 17 00:00:00 2001 From: Jareth <110929259+jareth-whitney@users.noreply.github.com> Date: Wed, 2 Oct 2024 17:17:45 -0700 Subject: [PATCH] Feature/deseng695: Updated Who is Listening Widget, changed colour of Video Widget overlay logo, fixed media query for Map Widget expand link. (#2598) * feature/deseng695: Implemented Who Is Listening widget design, changed icon color for video widget, adjusted viewport for expand map link in map widget. * feature/deseng695: Updated contact create/edit form, minor styling update to image picker. * feature/deseng695: Made changes as per Alex, updated Who is Listening form for better legibility. * feature/deseng695: Removed blank labels from contact form. --- CHANGELOG.MD | 18 +- docs/MET_database_ERD.md | 11 + ...b6ca9_added_widget_listening_table_for_.py | 38 ++++ met-api/src/met_api/models/__init__.py | 1 + .../src/met_api/models/widget_listening.py | 38 ++++ .../src/met_api/models/widget_translation.py | 2 + met-api/src/met_api/resources/__init__.py | 2 + .../src/met_api/resources/widget_listening.py | 79 +++++++ .../schemas/listening_widget_update.json | 23 ++ .../src/met_api/schemas/widget_listening.py | 28 +++ .../src/met_api/schemas/widget_translation.py | 1 + .../services/widget_listening_service.py | 84 +++++++ .../services/widget_translation_service.py | 6 + .../tests/unit/api/test_widget_listening.py | 152 +++++++++++++ .../tests/unit/api/test_widget_timeline.py | 4 +- met-api/tests/utilities/factory_scenarios.py | 9 + met-api/tests/utilities/factory_utils.py | 14 +- met-web/src/apiManager/endpoints/index.ts | 5 + .../create/authoring/AuthoringSummary.tsx | 5 +- .../WhoIsListening/AddContactDrawer.tsx | 83 +++---- .../WhoIsListening/ContactInfoPaper.tsx | 47 ++-- .../WhoIsListening/WhoIsListeningContext.tsx | 53 ++++- .../WhoIsListening/WhoIsListeningForm.tsx | 45 +++- .../old-view/widgets/Map/MapWidget.tsx | 4 +- .../widgets/Video/VideoWidgetView.tsx | 2 +- .../old-view/widgets/WhoIsListeningWidget.tsx | 209 ++++++++++++++---- met-web/src/components/imageUpload/index.tsx | 11 +- met-web/src/models/listeningWidget.ts | 6 + met-web/src/routes/AuthenticatedRoutes.tsx | 1 + .../widgetService/ListeningService/index.tsx | 57 +++++ 30 files changed, 913 insertions(+), 125 deletions(-) create mode 100644 met-api/migrations/versions/917a911b6ca9_added_widget_listening_table_for_.py create mode 100644 met-api/src/met_api/models/widget_listening.py create mode 100644 met-api/src/met_api/resources/widget_listening.py create mode 100644 met-api/src/met_api/schemas/schemas/listening_widget_update.json create mode 100644 met-api/src/met_api/schemas/widget_listening.py create mode 100644 met-api/src/met_api/services/widget_listening_service.py create mode 100644 met-api/tests/unit/api/test_widget_listening.py create mode 100644 met-web/src/models/listeningWidget.ts create mode 100644 met-web/src/services/widgetService/ListeningService/index.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 3c6fc148a..628d021f1 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,20 @@ +## October 2, 2024 + +- **Feature** New Who is Listening widget front end [🎟️ DESENG-695](https://citz-gdx.atlassian.net/browse/DESENG-695) + - Implemented Figma design + - Added widget_listening table to db for widget instance data + - Added option to enter widget description text (below title, above contacts) + - Adjusted CSS to accomodate multiple viewports + - Added ARIA labels for accessibility + - Updated contact create/edit form + - Updated Who is Listening widget form + +- **Feature** New Video Widget front end [🎟️ DESENG-692](https://citz-gdx.atlassian.net/browse/DESENG-692) + - Changed icon colour from yellow to white to accomodate company branding requests + +- **Feature** New Map Widget front end [🎟️ DESENG-693](https://citz-gdx.atlassian.net/browse/DESENG-693) + - Adjusted viewport settings of expand map link to accomodate breakpoints of engagement view page + ## September 26, 2024 - **Feature** New Timeline Widget designs [🎟️ DESENG-694](https://citz-gdx.atlassian.net/browse/DESENG-694) @@ -7,7 +24,6 @@ ## September 25, 2024 - **Feature** New Video Widget front end [🎟️ DESENG-692](https://citz-gdx.atlassian.net/browse/DESENG-692) - - Removed unneeded tables from db - Updated all other met_api and met_web logic to accomodate this diff --git a/docs/MET_database_ERD.md b/docs/MET_database_ERD.md index f27bcbcd0..883278fb3 100644 --- a/docs/MET_database_ERD.md +++ b/docs/MET_database_ERD.md @@ -172,6 +172,17 @@ erDiagram string updated_by boolean is_uploaded } + widget only one to zero or more widget_documents : has + widget_listening { + integer id PK + integer widget_id FK "The id from widget" + integer engagement_id FK "The id from engagement" + string description + timestamp created_date + timestamp updated_date + string created_by + string updated_by + } widget only one to zero or more widget_documents : has user_status { integer id PK diff --git a/met-api/migrations/versions/917a911b6ca9_added_widget_listening_table_for_.py b/met-api/migrations/versions/917a911b6ca9_added_widget_listening_table_for_.py new file mode 100644 index 000000000..637123f88 --- /dev/null +++ b/met-api/migrations/versions/917a911b6ca9_added_widget_listening_table_for_.py @@ -0,0 +1,38 @@ +"""Added widget_listening table for storing values particular to the listening widget instance, but not related to contacts. + +Revision ID: 917a911b6ca9 +Revises: 70a410c85b24 +Create Date: 2024-10-01 15:34:13.253066 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '917a911b6ca9' +down_revision = '70a410c85b24' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('widget_listening', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('widget_id', sa.Integer(), nullable=True), + sa.Column('engagement_id', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['widget_id'], ['widget.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('widget_translation', sa.Column('listening_description', sa.Text(), nullable=True)) + + +def downgrade(): + op.drop_column('widget_translation', 'listening_description') + op.drop_table('widget_listening') diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 8ed18ff4e..bda1684e0 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -49,6 +49,7 @@ from .engagement_slug import EngagementSlug from .report_setting import ReportSetting from .widget_video import WidgetVideo +from .widget_listening import WidgetListening from .widget_image import WidgetImage from .cac_form import CACForm from .engagement_metadata import EngagementMetadata, MetadataTaxon diff --git a/met-api/src/met_api/models/widget_listening.py b/met-api/src/met_api/models/widget_listening.py new file mode 100644 index 000000000..a436454ee --- /dev/null +++ b/met-api/src/met_api/models/widget_listening.py @@ -0,0 +1,38 @@ +"""WidgetListening model class. + +Manages the who is listening widget +""" +from __future__ import annotations + +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class WidgetListening(BaseModel): # pylint: disable=too-few-public-methods, too-many-instance-attributes + """Definition of the who is listening widget.""" + + __tablename__ = 'widget_listening' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) + description = db.Column(db.Text()) + + @classmethod + def get_listening(cls, widget_id) -> list[WidgetListening]: + """Get who is listening widget.""" + widget_listening = db.session.query(WidgetListening) \ + .filter(WidgetListening.widget_id == widget_id) \ + .all() + return widget_listening + + @classmethod + def update_listening(cls, listening_widget_id, listening_data: dict) -> WidgetListening: + """Update who is listening widget.""" + widget_listening: WidgetListening = WidgetListening.query.get(listening_widget_id) + if widget_listening: + for key, value in listening_data.items(): + setattr(widget_listening, key, value) + widget_listening.save() + return widget_listening diff --git a/met-api/src/met_api/models/widget_translation.py b/met-api/src/met_api/models/widget_translation.py index 88d6b7d68..4158266b1 100644 --- a/met-api/src/met_api/models/widget_translation.py +++ b/met-api/src/met_api/models/widget_translation.py @@ -29,6 +29,7 @@ class WidgetTranslation(BaseModel): # pylint: disable=too-few-public-methods poll_description = db.Column(db.String(2048)) video_url = db.Column(db.String(255)) video_description = db.Column(db.Text()) + listening_description = db.Column(db.Text()) @classmethod def get_translation_by_widget_id_and_language_id(cls, widget_id=None, language_id=None): @@ -63,6 +64,7 @@ def __create_new_widget_translation_entity(translation): poll_description=translation.get('poll_description', None), video_url=translation.get('video_url', None), video_description=translation.get('video_description', None), + listening_description=translation.get('listening_description', None), ) @classmethod diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index c22b0b83f..c928f036c 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -55,6 +55,7 @@ from .widget_timeline import API as WIDGET_TIMELINE_API from .widget_poll import API as WIDGET_POLL_API from .widget_image import API as WIDGET_IMAGE_API +from .widget_listening import API as WIDGET_LISTENING_API from .language import API as LANGUAGE_API from .widget_translation import API as WIDGET_TRANSLATION_API from .survey_translation import API as SURVEY_TRANSLATION_API @@ -101,6 +102,7 @@ API.add_namespace(ENGAGEMENT_SLUG_API, path='/slugs') API.add_namespace(REPORT_SETTING_API, path='/surveys//reportsettings') API.add_namespace(WIDGET_VIDEO_API, path='/widgets//videos') +API.add_namespace(WIDGET_LISTENING_API, path='/widgets//listening_widgets') API.add_namespace(ENGAGEMENT_SETTINGS_API) API.add_namespace(CAC_FORM_API, path='/engagements//cacform') API.add_namespace(WIDGET_TIMELINE_API, path='/widgets//timelines') diff --git a/met-api/src/met_api/resources/widget_listening.py b/met-api/src/met_api/resources/widget_listening.py new file mode 100644 index 000000000..3e8579296 --- /dev/null +++ b/met-api/src/met_api/resources/widget_listening.py @@ -0,0 +1,79 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing a listening widget resource.""" +from http import HTTPStatus + +from flask import jsonify, request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas import utils as schema_utils +from met_api.schemas.widget_listening import WidgetListeningSchema +from met_api.services.widget_listening_service import WidgetListeningService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('widget_listenings', description='Endpoints for Who Is Listening Widget Management') +"""Widget Listenings""" + + +@cors_preflight('GET, POST') +@API.route('') +class Listenings(Resource): + """Resource for managing Who is Listening widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id): + """Get Who is Listening widget.""" + try: + widget_listening = WidgetListeningService().get_listening(widget_id) + return jsonify(WidgetListeningSchema().dump(widget_listening, many=True)), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(widget_id): + """Create Who is Listening widget.""" + try: + request_json = request.get_json() + widget_listening = WidgetListeningService().create_listening(widget_id, request_json) + return WidgetListeningSchema().dump(widget_listening), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + +@cors_preflight('PATCH') +@API.route('/') +class Listening(Resource): + """Resource for managing Who is Listening widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(widget_id, listening_widget_id): + """Update Who is Listening widget.""" + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'listening_widget_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + try: + widget_listening = WidgetListeningService().update_listening(widget_id, listening_widget_id, request_json) + return WidgetListeningSchema().dump(widget_listening), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/schemas/schemas/listening_widget_update.json b/met-api/src/met_api/schemas/schemas/listening_widget_update.json new file mode 100644 index 000000000..b876af194 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/listening_widget_update.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/listening_widget_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "description": "A Who is Listening widget description" + } + ], + "required": [], + "properties": { + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Who is Listening description", + "description": "The description of this Who is Listening widget.", + "examples": ["A Who is Listening widget description"] + } + } +} diff --git a/met-api/src/met_api/schemas/widget_listening.py b/met-api/src/met_api/schemas/widget_listening.py new file mode 100644 index 000000000..41d68de03 --- /dev/null +++ b/met-api/src/met_api/schemas/widget_listening.py @@ -0,0 +1,28 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Manager for widget listening schema.""" + +from met_api.models.widget_listening import WidgetListening as WidgetListeningModel + +from marshmallow import Schema +from marshmallow_sqlalchemy.fields import Nested + +class WidgetListeningSchema(Schema): # pylint: disable=too-many-ancestors, too-few-public-methods + """This is the schema for the widget listening model.""" + + class Meta: # pylint: disable=too-few-public-methods + """All of the fields in the Widget Listening schema.""" + + model = WidgetListeningModel + fields = ('id', 'engagement_id', 'widget_id', 'description') diff --git a/met-api/src/met_api/schemas/widget_translation.py b/met-api/src/met_api/schemas/widget_translation.py index 5186a2c0f..460106323 100644 --- a/met-api/src/met_api/schemas/widget_translation.py +++ b/met-api/src/met_api/schemas/widget_translation.py @@ -21,3 +21,4 @@ class Meta: # pylint: disable=too-few-public-methods poll_description = fields.Str(data_key='poll_description') video_title = fields.Str(data_key='video_title') video_description = fields.Str(data_key='video_description') + listening_description = fields.Str(data_key='listening_description') diff --git a/met-api/src/met_api/services/widget_listening_service.py b/met-api/src/met_api/services/widget_listening_service.py new file mode 100644 index 000000000..b97412a32 --- /dev/null +++ b/met-api/src/met_api/services/widget_listening_service.py @@ -0,0 +1,84 @@ +"""Service for Widget Listening management.""" +from http import HTTPStatus +from typing import Optional + +from met_api.constants.membership_type import MembershipType +from met_api.exceptions.business_exception import BusinessException +from met_api.models.widget_listening import WidgetListening as WidgetListeningModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class WidgetListeningService: + """Widget Listening management service.""" + + @staticmethod + def get_listening_by_id(listening_id: int): + """Get listening by id.""" + widget_listening = WidgetListeningModel.find_by_id(listening_id) + return widget_listening + + @staticmethod + def get_listening(widget_id: int): + """Get listening by widget id.""" + widget_listening = WidgetListeningModel.get_listening(widget_id) + return widget_listening + + @staticmethod + def create_listening(widget_id: int, listening_details: dict): + """Create listening for the widget.""" + listening_data = dict(listening_details) + eng_id = listening_data.get('engagement_id') + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + widget_listening = WidgetListeningService._create_listening_model(widget_id, listening_data) + widget_listening.commit() + return widget_listening + + @staticmethod + def update_listening(widget_id: int, listening_id: int, listening_data: dict) -> Optional[WidgetListeningModel]: + """Update listening widget.""" + engagement_id = listening_data.get('engagement_id') + + WidgetListeningService._check_update_listening_auth(engagement_id) + + widget_listening: WidgetListeningModel = WidgetListeningModel.find_by_id(listening_id) + + if not widget_listening: + raise BusinessException( + error='Who is Listening widget not found', + status_code=HTTPStatus.BAD_REQUEST) + + if widget_listening.widget_id != widget_id: + raise BusinessException( + error='Invalid widget ID', + status_code=HTTPStatus.BAD_REQUEST) + + if widget_listening.id != listening_id: + raise BusinessException( + error='Invalid Who is Listening widget ID', + status_code=HTTPStatus.BAD_REQUEST) + + WidgetListeningService._update_widget_listening(widget_listening, listening_data) + + return widget_listening + + @staticmethod + def _check_update_listening_auth(engagement_id): + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=engagement_id) + + @staticmethod + def _update_widget_listening(widget_listening: WidgetListeningModel, listening_data: dict): + widget_listening.description = listening_data.get('description') + widget_listening.save() + + @staticmethod + def _create_listening_model(widget_id: int, listening_data: dict): + listening_model: WidgetListeningModel = WidgetListeningModel() + listening_model.widget_id = widget_id + listening_model.engagement_id = listening_data.get('engagement_id') + listening_model.description = listening_data.get('description') + listening_model.flush() + return listening_model diff --git a/met-api/src/met_api/services/widget_translation_service.py b/met-api/src/met_api/services/widget_translation_service.py index 58d4bdc39..9af0f1879 100644 --- a/met-api/src/met_api/services/widget_translation_service.py +++ b/met-api/src/met_api/services/widget_translation_service.py @@ -11,6 +11,7 @@ from met_api.models.widget_poll import Poll as PollModel from met_api.models.widget_translation import WidgetTranslation as WidgetTranslationModel from met_api.models.widget_video import WidgetVideo as WidgetVideoModel +from met_api.models.widget_listening import WidgetListening as WidgetListeningModel from met_api.schemas.widget_translation import WidgetTranslationSchema from met_api.services import authorization from met_api.utils.roles import Role @@ -131,5 +132,10 @@ def _get_default_language_values(widget, translation_data): if widget_video: translation_data['video_url'] = widget_video[0].video_url translation_data['video_description'] = widget_video[0].description + + if widget_type == WidgetType.WHO_IS_LISTENING.value: + widget_listening = WidgetListeningModel.get_listening(widget_id) + if widget_listening: + translation_data['listening_description'] = widget_listening[0].description return translation_data diff --git a/met-api/tests/unit/api/test_widget_listening.py b/met-api/tests/unit/api/test_widget_listening.py new file mode 100644 index 000000000..28a18db54 --- /dev/null +++ b/met-api/tests/unit/api/test_widget_listening.py @@ -0,0 +1,152 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests to verify the Widget Timeline API end-point. + +Test-Suite to ensure that the Widget Timeline API endpoint +is working as expected. +""" +import json +from http import HTTPStatus +from unittest.mock import patch + +from faker import Faker + +from met_api.exceptions.business_exception import BusinessException +from met_api.services.widget_listening_service import WidgetListeningService +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestJwtClaims, TestWidgetListening, TestWidgetInfo +from tests.utilities.factory_utils import ( + factory_auth_header, factory_engagement_model, factory_widget_model, + factory_widget_listening_model) + + +fake = Faker() + + +def test_create_listening_widget(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that Who is Listening widget can be POSTed.""" + user, claims = setup_admin_user_and_claims + engagement = factory_engagement_model() + TestWidgetInfo.widget_listening['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget_listening) + headers = factory_auth_header(jwt=jwt, claims=claims) + + listening_widget_info = TestWidgetListening.widget_listening + + data = { + **listening_widget_info, + 'widget_id': widget.id, + 'engagement_id': engagement.id, + 'description': 'test description', + } + + rv = client.post( + f'/api/widgets/{widget.id}/timelines', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK.value + assert rv.json.get('engagement_id') == engagement.id + + with patch.object(WidgetListeningService, 'create_timeline', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.post( + f'/api/widgets/{widget.id}/timelines', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_get_timeline(client, jwt, session): # pylint:disable=unused-argument + """Assert that Who is Listening widget can be fetched.""" + engagement = factory_engagement_model() + TestWidgetInfo.widget_timeline['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget_timeline) + + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role) + + listening_widget_info = TestWidgetListening.widget_listening + + widget_listening = factory_widget_listening_model({ + 'widget_id': widget.id, + 'engagement_id': engagement.id, + 'description': listening_widget_info.get('description'), + }) + + rv = client.get( + f'/api/widgets/{widget.id}/listening_widgets', + headers=headers, + content_type=ContentType.JSON.value + ) + + assert rv.status_code == HTTPStatus.OK + assert rv.json[0].get('id') == widget_listening.id + + with patch.object(WidgetListeningService, 'get_listening_widget', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.get( + f'/api/widgets/{widget.id}/listening_widgets', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_patch_listening(client, jwt, session, + setup_admin_user_and_claims): # pylint:disable=unused-argument + """Assert that a Who is Listening widget can be PATCHed.""" + user, claims = setup_admin_user_and_claims + engagement = factory_engagement_model() + TestWidgetInfo.widget_listening['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget_listening) + + listening_widget_info = TestWidgetListening.widget_listening + + widget_listening = factory_widget_listening_model({ + 'widget_id': widget.id, + 'engagement_id': engagement.id, + 'description': listening_widget_info.get('description'), + }) + + headers = factory_auth_header(jwt=jwt, claims=claims) + + listening_edits = { + 'description': fake.text(max_nb_chars=20), + } + + rv = client.patch(f'/api/widgets/{widget.id}/listening_widgets/{widget_listening.id}', + data=json.dumps(listening_edits), + headers=headers, content_type=ContentType.JSON.value) + + assert rv.status_code == HTTPStatus.OK + + rv = client.get( + f'/api/widgets/{widget.id}/listening_widgets', + headers=headers, + content_type=ContentType.JSON.value + ) + assert rv.status_code == HTTPStatus.OK + assert rv.json[0].get('description') == listening_edits.get('description') + + with patch.object(WidgetListeningService, 'update_listening_widget', + side_effect=BusinessException('Test error', status_code=HTTPStatus.BAD_REQUEST)): + rv = client.patch(f'/api/widgets/{widget.id}/listening_widgets/{widget_listening.id}', + data=json.dumps(listening_edits), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.BAD_REQUEST diff --git a/met-api/tests/unit/api/test_widget_timeline.py b/met-api/tests/unit/api/test_widget_timeline.py index 3b7e7308a..9a9a7b1e3 100644 --- a/met-api/tests/unit/api/test_widget_timeline.py +++ b/met-api/tests/unit/api/test_widget_timeline.py @@ -134,8 +134,8 @@ def test_patch_timeline(client, jwt, session, """Assert that a timeline can be PATCHed.""" user, claims = setup_admin_user_and_claims engagement = factory_engagement_model() - TestWidgetInfo.widget_video['engagement_id'] = engagement.id - widget = factory_widget_model(TestWidgetInfo.widget_video) + TestWidgetInfo.widget_timeline['engagement_id'] = engagement.id + widget = factory_widget_model(TestWidgetInfo.widget_timeline) timeline_widget_info = TestTimelineInfo.widget_timeline timeline_event_info = TestTimelineInfo.timeline_event diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 77c96e4e9..37967dd93 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -808,6 +808,15 @@ class TestWidgetVideo(dict, Enum): 'video_url': fake.url(), 'description': fake.text(max_nb_chars=50), } + + +class TestWidgetListening(dict, Enum): + """Test scenarios of Who is Listening widget.""" + + listening1 = { + 'id': '1', + 'description': fake.text(max_nb_chars=50), + } class TestTimelineInfo(dict, Enum): diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 90927f967..76533f635 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -66,6 +66,7 @@ from met_api.models.widget_timeline import WidgetTimeline as WidgetTimelineModel from met_api.models.widget_translation import WidgetTranslation as WidgetTranslationModel from met_api.models.widget_video import WidgetVideo as WidgetVideoModel +from met_api.models.widget_listening import WidgetListening as WidgetListeningModel 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 CompositeRoleId, MembershipStatus @@ -76,7 +77,7 @@ TestPollAnswerInfo, TestPollAnswerTranslationInfo, TestPollResponseInfo, TestReportSettingInfo, TestSubmissionInfo, TestSubscribeInfo, TestSubscribeItemTranslationInfo, TestSurveyInfo, TestSurveyTranslationInfo, TestTenantInfo, TestTimelineEventTranslationInfo, TestTimelineInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, - TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo) + TestWidgetItemInfo, TestWidgetMap, TestWidgetPollInfo, TestWidgetTranslationInfo, TestWidgetVideo, TestWidgetListening) fake = Faker() @@ -534,6 +535,17 @@ def factory_video_model(video_info: dict = TestWidgetVideo.video1): return video +def factory_widget_listening_model(listening_info: dict = TestWidgetListening.listening1): + """Produce a who is listening model.""" + listening_widget = WidgetListeningModel( + description=listening_info.get('description'), + widget_id=listening_info.get('widget_id'), + engagement_id=listening_info.get('engagement_id'), + ) + listening_widget.save() + return listening_widget + + def factory_widget_timeline_model( widget_timeline: dict = TestTimelineInfo.widget_timeline, ): diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 9d52daf7f..fd35eadb0 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -162,6 +162,11 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines`, UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines/timeline_id`, }, + ListeningWidgets: { + GET: `${AppConfig.apiUrl}/widgets/widget_id/listening_widgets`, + CREATE: `${AppConfig.apiUrl}/widgets/widget_id/listening_widgets`, + UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/listening_widgets/listening_widget_id`, + }, ImageWidgets: { GET: `${AppConfig.apiUrl}/widgets/widget_id/images`, CREATE: `${AppConfig.apiUrl}/widgets/widget_id/images`, diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx index ceef70d92..7714a3de8 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx @@ -1,6 +1,6 @@ import { Grid } from '@mui/material'; import React, { useEffect } from 'react'; -import { useRouteLoaderData, useOutletContext } from 'react-router-dom'; +import { useOutletContext, useLoaderData } from 'react-router-dom'; import { TextField } from 'components/common/Input'; import { AuthoringTemplateOutletContext } from './types'; import { colors } from 'styles/Theme'; @@ -19,7 +19,8 @@ const AuthoringSummary = () => { const { setValue, control, reset, getValues, setDefaultValues }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template. - const { engagement } = useRouteLoaderData('single-engagement') as EngagementLoaderData; + // Must be a loader assigned to this route or data won't be refreshed on page change. + const { engagement } = useLoaderData() as EngagementLoaderData; // Reset values to default and retrieve relevant content from loader. useEffect(() => { diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx index 6b6e1dbb6..75034aa06 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx @@ -143,7 +143,7 @@ const AddContactDrawer = () => { return ( - + { {contactToEdit ? 'Edit' : 'Add'} Contact - + Profile Picture { helpText={'Drop an image here or click to select one.'} /> - + - Name * + Name (Required) { /> - Title + Title (Optional) { size="small" /> + + Phone (Optional) + + + + Email (Required) + + - - Phone - - - - Email * - - + - Address + Address (Optional) { /> - Bio + Biography (Optional) - + - + {contact.name} @@ -39,35 +48,39 @@ const ContactInfoPaper = ({ contact, removeContact, ...rest }: ContactInfoPaperP - + Phone: - + {contact.phone_number} - + Email: - - {contact.email} + + + {contact.email} + - + Address: - + - + Bio: - + - - + + { handleChangeContactToEdit(contact); handleAddContactDrawerOpen(true); @@ -108,9 +121,9 @@ const ContactInfoPaper = ({ contact, removeContact, ...rest }: ContactInfoPaperP - + removeContact(contact.id)} color="inherit" aria-label="delete-icon" diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningContext.tsx index 86185667f..4accba182 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningContext.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningContext.tsx @@ -4,6 +4,10 @@ import { openNotification } from 'services/notificationService/notificationSlice import { ActionContext } from '../../ActionContext'; import { Contact } from 'models/contact'; import { useLazyGetContactsQuery } from 'apiManager/apiSlices/contacts'; +import { fetchListeningWidget } from 'services/widgetService/ListeningService'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { WidgetType } from 'models/widget'; +import { ListeningWidget } from 'models/listeningWidget'; export interface WhoIsListeningContextProps { contactToEdit: Contact | null; @@ -12,6 +16,9 @@ export interface WhoIsListeningContextProps { loadingContacts: boolean; contacts: Contact[]; loadContacts: () => Promise; + listeningWidget: ListeningWidget; + setListeningWidget: React.Dispatch>; + loadListeningWidget: () => Promise; handleChangeContactToEdit: (_contact: Contact | null) => void; setAddedContacts: React.Dispatch>; addedContacts: Contact[]; @@ -21,17 +28,27 @@ export type EngagementParams = { engagementId: string; }; +const emptyListeningWidget = { + id: 0, + engagement_id: 0, + widget_id: 0, + description: '', +}; + export const WhoIsListeningContext = createContext({ loadingContacts: false, contactToEdit: null, addContactDrawerOpen: false, handleAddContactDrawerOpen: (_open: boolean) => { - /* empty default method */ + /*empty*/ }, contacts: [], loadContacts: () => Promise.resolve([]), + listeningWidget: emptyListeningWidget, + setListeningWidget: (updatedListeningWidget: React.SetStateAction) => emptyListeningWidget, + loadListeningWidget: () => Promise.resolve(emptyListeningWidget), handleChangeContactToEdit: () => { - /* empty default method */ + /*empty*/ }, setAddedContacts: (updatedContacts: React.SetStateAction) => [], addedContacts: [], @@ -41,13 +58,21 @@ export const WhoIsListeningProvider = ({ children }: { children: JSX.Element | J const { savedEngagement } = useContext(ActionContext); const [getContactsTrigger] = useLazyGetContactsQuery(); const dispatch = useAppDispatch(); + const { widgets } = useContext(WidgetDrawerContext); + const widget = widgets.find((widget) => widget.widget_type_id === WidgetType.WhoIsListening) || null; const [contactToEdit, setContactToEdit] = useState(null); const [addContactDrawerOpen, setAddContactDrawerOpen] = useState(false); const [contacts, setContacts] = useState([]); + const [listeningWidget, setListeningWidget] = useState(emptyListeningWidget); const [addedContacts, setAddedContacts] = useState([]); const [loadingContacts, setLoadingContacts] = useState(true); + useEffect(() => { + loadContacts(); + loadListeningWidget(); + }, [savedEngagement]); + const loadContacts = async () => { try { if (!savedEngagement.id) { @@ -66,9 +91,24 @@ export const WhoIsListeningProvider = ({ children }: { children: JSX.Element | J } }; - useEffect(() => { - loadContacts(); - }, [savedEngagement]); + const loadListeningWidget = async () => { + try { + if (!savedEngagement.id || !widget?.id) { + return Promise.resolve(emptyListeningWidget); + } + const loadedListeningWidget = await fetchListeningWidget(widget.id); + setListeningWidget(loadedListeningWidget); + return loadedListeningWidget; + } catch (error) { + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while attempting to load Who is Listening description', + }), + ); + } + }; const handleChangeContactToEdit = (contact: Contact | null) => { setContactToEdit(contact); @@ -86,6 +126,9 @@ export const WhoIsListeningProvider = ({ children }: { children: JSX.Element | J loadingContacts, contacts, loadContacts, + listeningWidget, + setListeningWidget, + loadListeningWidget, contactToEdit, handleChangeContactToEdit, setAddedContacts, diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx index 3847d35b3..6b4942b48 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx @@ -10,11 +10,20 @@ import ContactBlock from './ContactBlock'; import { WhoIsListeningContext } from './WhoIsListeningContext'; import { useCreateWidgetItemsMutation } from 'apiManager/apiSlices/widgets'; import { WidgetTitle } from '../WidgetTitle'; +import { patchListeningWidget, postListeningWidget } from 'services/widgetService/ListeningService'; const WhoIsListeningForm = () => { const { handleWidgetDrawerOpen, widgets, loadWidgets } = useContext(WidgetDrawerContext); - const { handleAddContactDrawerOpen, loadingContacts, contacts, addedContacts, setAddedContacts } = - useContext(WhoIsListeningContext); + const { + handleAddContactDrawerOpen, + loadingContacts, + contacts, + addedContacts, + setAddedContacts, + listeningWidget, + setListeningWidget, + loadListeningWidget, + } = useContext(WhoIsListeningContext); const dispatch = useAppDispatch(); const [selectedContact, setSelectedContact] = useState(null); const [savingWidgetItems, setSavingWidgetItems] = useState(false); @@ -72,6 +81,18 @@ const WhoIsListeningForm = () => { setSavingWidgetItems(true); await createWidgetItems({ widget_id: widget.id, widget_items_data: widgetsToUpdate }).unwrap(); await loadWidgets(); + if (listeningWidget.id) { + await patchListeningWidget(widget.id, listeningWidget.id, { + description: listeningWidget.description, + }); + } else { + await postListeningWidget(widget.id, { + engagement_id: widget.engagement_id, + widget_id: widget.id, + description: listeningWidget.description, + }); + } + await loadListeningWidget(); dispatch(openNotification({ severity: 'success', text: 'Widgets successfully added' })); handleWidgetDrawerOpen(false); setSavingWidgetItems(false); @@ -90,6 +111,24 @@ const WhoIsListeningForm = () => { + + Description (Optional) + { + setListeningWidget({ ...listeningWidget, description: e.target.value }); + }} + aria-label="Description: optional." + InputLabelProps={{ + shrink: false, + }} + fullWidth + multiline + rows={4} + /> + Select Existing Contact @@ -99,7 +138,7 @@ const WhoIsListeningForm = () => { renderInput={(params) => ( { const [map, setMap] = useState(null); const [open, setOpen] = useState(false); const mapContainerRef = useRef(null); - const isLargeScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('lg')); + const isMediumScreen = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')); const [mapWidth, setMapWidth] = useState(250); const [mapHeight, setMapHeight] = useState(250); @@ -133,7 +133,7 @@ const MapWidget = ({ widget }: MapWidgetProps) => { - + setOpen(true)} sx={linkStyles} tabIndex={0} onKeyDown={() => setOpen(true)}> vs.name === source)?.icon || faQuestionCircle} - style={{ fontSize: '1.9rem', paddingRight: '0.65rem', color: '#FCBA19' }} + style={{ fontSize: '1.9rem', paddingRight: '0.65rem', color: colors.surface.white }} />{' '} {videoOverlayTitle} diff --git a/met-web/src/components/engagement/old-view/widgets/WhoIsListeningWidget.tsx b/met-web/src/components/engagement/old-view/widgets/WhoIsListeningWidget.tsx index 48c449043..b76015e61 100644 --- a/met-web/src/components/engagement/old-view/widgets/WhoIsListeningWidget.tsx +++ b/met-web/src/components/engagement/old-view/widgets/WhoIsListeningWidget.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; -import { MetPaper } from 'components/common'; -import { Grid, Avatar, Skeleton, useTheme, Divider } from '@mui/material'; +import { colors, MetHeader3 } from 'components/common'; +import { Grid, Avatar, Skeleton, useTheme, useMediaQuery, Theme } from '@mui/material'; import { Widget } from 'models/widget'; import { Contact } from 'models/contact'; import { useAppDispatch } from 'hooks'; @@ -8,7 +8,12 @@ import { openNotification } from 'services/notificationService/notificationSlice import { When } from 'react-if'; import { useLazyGetContactQuery } from 'apiManager/apiSlices/contacts'; import { Link } from 'components/common/Navigation'; -import { BodyText, Header2 } from 'components/common/Typography'; +import { BodyText } from 'components/common/Typography'; +import { Palette } from 'styles/Theme'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faEnvelope, faPhone } from '@fortawesome/pro-regular-svg-icons'; +import { fetchListeningWidget } from 'services/widgetService/ListeningService'; +import { ListeningWidget } from 'models/listeningWidget'; interface WhoIsListeningWidgetProps { widget: Widget; @@ -17,9 +22,13 @@ const WhoIsListeningWidget = ({ widget }: WhoIsListeningWidgetProps) => { const dispatch = useAppDispatch(); const [getContactTrigger] = useLazyGetContactQuery(); const theme = useTheme(); + const isDarkMode = 'dark' === theme.palette.mode; + const isMediumViewportOrLarger = useMediaQuery((theme: Theme) => theme.breakpoints.up('md')); + const isXLViewportOrLarger = useMediaQuery((theme: Theme) => theme.breakpoints.up('xl')); const [isLoading, setIsLoading] = useState(true); const [contacts, setContacts] = useState([]); + const [listeningWidget, setListeningWidget] = useState(); const fetchContacts = async () => { try { @@ -38,7 +47,24 @@ const WhoIsListeningWidget = ({ widget }: WhoIsListeningWidgetProps) => { dispatch( openNotification({ severity: 'error', - text: 'Error occurred while fetching Engagement wdigets information', + text: 'Error occurred while fetching engagement widget information', + }), + ); + } + }; + + const getListeningWidget = async () => { + try { + const listeningWidget = await fetchListeningWidget(widget.id); + setListeningWidget(listeningWidget); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while fetching Who Is Listening widget information', }), ); } @@ -46,25 +72,81 @@ const WhoIsListeningWidget = ({ widget }: WhoIsListeningWidgetProps) => { useEffect(() => { fetchContacts(); + getListeningWidget(); }, [widget]); + // Define the styles + const textColor = isDarkMode ? colors.surface.white : Palette.text.primary; + + const titleStyles = { + fontWeight: 'lighter', + fontSize: '1.5rem', + color: textColor, + }; + + const descriptionTextStyles = { + color: textColor, + fontSize: '1rem', + }; + + const avatarStyles = { + height: 100, + width: 100, + borderRadius: '50%', + pl: '0', + mb: isXLViewportOrLarger ? '0' : '1rem', + }; + + const contactNameStyles = { + fontSize: '1rem', + lineHeight: '1.75rem', + fontWeight: 700, + letterSpacing: '0.16px', + textAlign: isMediumViewportOrLarger ? 'left' : 'center', + color: textColor, + }; + + const contactTitleStyles = { + fontSize: '0.875rem', + lineHeight: '1rem', + fontWeight: 400, + letterSpacing: '0.14px', + textAlign: isMediumViewportOrLarger ? 'left' : 'center', + color: textColor, + }; + + const contactBioStyles = { + textAlign: isMediumViewportOrLarger ? 'left' : 'center', + color: textColor, + }; + + const contactEmailStyles = { + color: textColor, + fontSize: '0.875rem', + }; + + const contactPhoneNumberStyles = { + color: textColor, + fontSize: '0.875rem', + }; + if (isLoading) { return ( - - - - - Who is Listening - - - - - - - - + + + + + Who is Listening + + - + + + + + + + ); } @@ -73,27 +155,41 @@ const WhoIsListeningWidget = ({ widget }: WhoIsListeningWidgetProps) => { } return ( - - - {widget.title} - + + + {widget.title} + + + {listeningWidget?.description} + + {contacts.map((contact) => { return ( - - + + { lg={12} xl={9} > - - - {contact.name} - + + {contact.name} - - {contact.title} + + {contact.title} {contact.bio} - - - Email:{' '} - - {' ' + contact.email} + + {' '} + + {' ' + contact.email} + - - - Phone:{' '} - - {' ' + contact.phone_number} + + {' '} + + {' ' + contact.phone_number} + ); })} - + ); }; diff --git a/met-web/src/components/imageUpload/index.tsx b/met-web/src/components/imageUpload/index.tsx index 3639137d9..674d70845 100644 --- a/met-web/src/components/imageUpload/index.tsx +++ b/met-web/src/components/imageUpload/index.tsx @@ -42,10 +42,17 @@ export const ImageUpload = ({ > - + {helpText} - + Supported formats: JPG, PNG, WEBP