diff --git a/met-api/migrations/versions/db737a0db061_.py b/met-api/migrations/versions/db737a0db061_.py new file mode 100644 index 000000000..ec56787f0 --- /dev/null +++ b/met-api/migrations/versions/db737a0db061_.py @@ -0,0 +1,43 @@ +""" Fill empty widget titles +Revision ID: db737a0db061 +Revises: df73727dc6d9b7_add_sub_tbl +Create Date: 2023-08-04 14:11:01.993136 +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'db737a0db061' +down_revision = 'df73727dc6d9b7_add_sub_tbl' +branch_labels = None +depends_on = None + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Execute an UPDATE statement to set 'title' based on 'widget_type_id' + op.execute(""" + UPDATE widget + SET title = + CASE + WHEN widget_type_id = 1 THEN 'Who is Listening' + WHEN widget_type_id = 2 THEN 'Documents' + WHEN widget_type_id = 3 THEN 'Environmental Assessment Process' + WHEN widget_type_id = 4 THEN 'Sign Up for Updates' + WHEN widget_type_id = 5 THEN 'Events' + WHEN widget_type_id = 6 THEN 'Map' + WHEN widget_type_id = 7 THEN 'Video' + ELSE 'Default Title' -- This is for any widget_type_id not covered above + END + WHERE title IS NULL; + """) + + # ### end Alembic commands ### + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Execute an UPDATE statement to set 'title' to NULL for all rows + op.execute("UPDATE widget SET title = NULL;") + + # ### end Alembic commands ### diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index b5ce77f72..c9dc19863 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -97,6 +97,8 @@ class _Config(): # pylint: disable=too-few-public-methods SURVEY_PATH = os.getenv('SURVEY_PATH', '/surveys/submit/{survey_id}/{token}') SUBSCRIBE_PATH = os.getenv('SUBSCRIBE_PATH', '/engagements/{engagement_id}/subscribe/{token}') UNSUBSCRIBE_PATH = os.getenv('UNSUBSCRIBE_PATH', '/engagements/{engagement_id}/unsubscribe/{participant_id}') + ENGAGEMENT_PATH = os.getenv('ENGAGEMENT_PATH', '/engagements/{engagement_id}/view') + ENGAGEMENT_PATH_SLUG = os.getenv('ENGAGEMENT_PATH_SLUG', '/{slug}') # engagement dashboard path is used to pass the survey result to the public user. # The link is changed such that public user can access the comments page from the email and not the dashboard. ENGAGEMENT_DASHBOARD_PATH = os.getenv('ENGAGEMENT_DASHBOARD_PATH', '/engagements/{engagement_id}/comments') diff --git a/met-api/src/met_api/constants/user.py b/met-api/src/met_api/constants/user.py index 760192f72..219bcce3c 100644 --- a/met-api/src/met_api/constants/user.py +++ b/met-api/src/met_api/constants/user.py @@ -15,3 +15,4 @@ SYSTEM_USER = 1 +SYSTEM_REVIEWER = 'System' diff --git a/met-api/src/met_api/models/submission.py b/met-api/src/met_api/models/submission.py index 5ccfef073..1ae1fde29 100644 --- a/met-api/src/met_api/models/submission.py +++ b/met-api/src/met_api/models/submission.py @@ -3,14 +3,17 @@ Manages the Submission """ from __future__ import annotations + from datetime import datetime from typing import List + from sqlalchemy import ForeignKey from sqlalchemy.dialects import postgresql from met_api.constants.comment_status import Status -from met_api.models.survey import Survey +from met_api.constants.user import SYSTEM_REVIEWER from met_api.models.participant import Participant +from met_api.models.survey import Survey from met_api.schemas.submission import SubmissionSchema from .base_model import BaseModel @@ -63,7 +66,7 @@ def create(cls, submission: SubmissionSchema, session=None) -> Submission: const_review_date = None else: const_comment_status = Status.Approved.value - const_reviewed_by = 'System' + const_reviewed_by = SYSTEM_REVIEWER const_review_date = datetime.utcnow() new_submission = Submission( diff --git a/met-api/src/met_api/models/widget.py b/met-api/src/met_api/models/widget.py index b554abeda..9c55a0e63 100644 --- a/met-api/src/met_api/models/widget.py +++ b/met-api/src/met_api/models/widget.py @@ -4,6 +4,7 @@ """ from __future__ import annotations from datetime import datetime +from typing import Optional from sqlalchemy.sql.schema import ForeignKey @@ -65,6 +66,7 @@ def __create_new_widget_entity(widget): updated_date=datetime.utcnow(), created_by=widget.get('created_by', None), updated_by=widget.get('updated_by', None), + title=widget.get('title', None), ) @classmethod @@ -87,3 +89,15 @@ def update_widgets(cls, update_mappings: list) -> None: """Update widgets..""" db.session.bulk_update_mappings(Widget, update_mappings) db.session.commit() + + @classmethod + def update_widget(cls, engagement_id, widget_id, widget_data: dict) -> Optional[Widget]: + """Update widget.""" + query = Widget.query.filter_by(id=widget_id, engagement_id=engagement_id) + widget: Widget = query.first() + if not widget: + return None + widget_data['updated_date'] = datetime.utcnow() + query.update(widget_data) + db.session.commit() + return widget diff --git a/met-api/src/met_api/models/widgets_subscribe.py b/met-api/src/met_api/models/widgets_subscribe.py index 9af5417cd..11a1017e2 100644 --- a/met-api/src/met_api/models/widgets_subscribe.py +++ b/met-api/src/met_api/models/widgets_subscribe.py @@ -40,7 +40,7 @@ def get_all_by_type(cls, type_, widget_id): return db.session.query(cls).filter_by(type=type_, widget_id=widget_id).all() @classmethod - def update_widget_events_bulk(cls, update_mappings: list) -> list[WidgetSubscribe]: + def update_widget_subscribes_bulk(cls, update_mappings: list) -> list[WidgetSubscribe]: """Save widget subscribe sorting.""" db.session.bulk_update_mappings(WidgetSubscribe, update_mappings) db.session.commit() diff --git a/met-api/src/met_api/resources/engagement.py b/met-api/src/met_api/resources/engagement.py index 4a38c9d75..05b028104 100644 --- a/met-api/src/met_api/resources/engagement.py +++ b/met-api/src/met_api/resources/engagement.py @@ -46,8 +46,7 @@ class Engagement(Resource): def get(engagement_id): """Fetch a single engagement matching the provided id.""" try: - user_id = TokenInfo.get_id() - engagement_record = EngagementService().get_engagement(engagement_id, user_id) + engagement_record = EngagementService().get_engagement(engagement_id) if engagement_record: return engagement_record, HTTPStatus.OK diff --git a/met-api/src/met_api/resources/survey.py b/met-api/src/met_api/resources/survey.py index f726b1afd..25cb1ce5c 100644 --- a/met-api/src/met_api/resources/survey.py +++ b/met-api/src/met_api/resources/survey.py @@ -49,7 +49,6 @@ def get(survey_id): try: user_id = TokenInfo.get_id() if user_id: - # authenticated users have access to any survey/engagement status survey_record = SurveyService().get(survey_id) else: survey_record = SurveyService().get_open(survey_id) diff --git a/met-api/src/met_api/resources/widget.py b/met-api/src/met_api/resources/widget.py index 70a518565..26d90dabe 100644 --- a/met-api/src/met_api/resources/widget.py +++ b/met-api/src/met_api/resources/widget.py @@ -89,8 +89,8 @@ def patch(engagement_id): return {'message': err.error}, err.status_code -@cors_preflight('DELETE') -@API.route('/engagement//widget/') +@cors_preflight('DELETE, PATCH') +@API.route('//engagements/') class EngagementWidget(Resource): """Resource for managing widgets with engagements.""" @@ -107,6 +107,25 @@ def delete(engagement_id, widget_id): except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(engagement_id, widget_id): + """Update widget.""" + try: + user_id = TokenInfo.get_id() + widget_data = request.get_json() + valid_format, errors = schema_utils.validate(widget_data, 'widget_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + + updated_widget = WidgetService().update_widget(engagement_id, widget_id, widget_data, user_id) + return updated_widget, HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + @cors_preflight('POST,OPTIONS') @API.route('//items') diff --git a/met-api/src/met_api/resources/widget_subscribe.py b/met-api/src/met_api/resources/widget_subscribe.py index c9729aff0..180341b22 100644 --- a/met-api/src/met_api/resources/widget_subscribe.py +++ b/met-api/src/met_api/resources/widget_subscribe.py @@ -33,7 +33,7 @@ """ -@cors_preflight('GET, POST, OPTIONS, DELETE') +@cors_preflight('GET, POST, OPTIONS') @API.route('') class WidgetSubscribe(Resource): """Resource for managing a Widget Subscribe.""" @@ -59,17 +59,6 @@ def post(widget_id): except BusinessException as err: return str(err), err.status_code - @staticmethod - @cross_origin(origins=allowedorigins()) - def delete(widget_id, subscribe_id): - """Delete an subscribe .""" - try: - WidgetSubscribeService().delete_subscribe(subscribe_id, widget_id) - response, status = {}, HTTPStatus.OK - except BusinessException as err: - response, status = str(err), err.status_code - return response, status - @cors_preflight('GET,POST,OPTIONS') @API.route('//items', methods=['GET', 'DELETE', 'OPTIONS']) @@ -124,3 +113,20 @@ def patch(widget_id): return WidgetSubscribeSchema().dump(sort_widget_subscribe), HTTPStatus.OK except BusinessException as err: return str(err), err.status_code + + +@cors_preflight('DELETE') +@API.route('/', methods=['DELETE']) +class WidgetEvent(Resource): + """Resource for managing a Widget Events.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def delete(widget_id, subscribe_id): + """Delete an subscribe .""" + try: + WidgetSubscribeService().delete_subscribe(subscribe_id, widget_id) + response, status = {}, HTTPStatus.OK + except BusinessException as err: + response, status = str(err), err.status_code + return response, status diff --git a/met-api/src/met_api/schemas/engagement.py b/met-api/src/met_api/schemas/engagement.py index 804d1e4f2..60f707c4c 100644 --- a/met-api/src/met_api/schemas/engagement.py +++ b/met-api/src/met_api/schemas/engagement.py @@ -7,11 +7,12 @@ from marshmallow import EXCLUDE, Schema, ValidationError, fields, validate, validates_schema -from met_api.constants.engagement_status import Status, SubmissionStatus from met_api.constants.comment_status import Status as CommentStatus +from met_api.constants.engagement_status import Status, SubmissionStatus +from met_api.schemas.engagement_status_block import EngagementStatusBlockSchema from met_api.schemas.engagement_survey import EngagementSurveySchema +from met_api.schemas.utils import count_comments_by_status from met_api.utils.datetime import local_datetime -from met_api.schemas.engagement_status_block import EngagementStatusBlockSchema from .engagement_status import EngagementStatusSchema @@ -61,18 +62,14 @@ def get_submissions_meta_data(self, obj): submissions = obj.surveys[0].submissions return { 'total': len(submissions), - 'pending': self._count_comments_by_status(submissions, CommentStatus.Pending.value), - 'approved': self._count_comments_by_status(submissions, CommentStatus.Approved.value), - 'rejected': self._count_comments_by_status(submissions, CommentStatus.Rejected.value), - 'needs_further_review': self._count_comments_by_status( + 'pending': count_comments_by_status(submissions, CommentStatus.Pending.value), + 'approved': count_comments_by_status(submissions, CommentStatus.Approved.value), + 'rejected': count_comments_by_status(submissions, CommentStatus.Rejected.value), + 'needs_further_review': count_comments_by_status( submissions, CommentStatus.Needs_further_review.value) } - def _count_comments_by_status(self, submissios, status): - return len([submission for submission in submissios - if submission.comment_status_id == status]) - def get_submission_status(self, obj): """Get the submission status of the engagement.""" if obj.status_id == Status.Draft.value or obj.status_id == Status.Scheduled.value: diff --git a/met-api/src/met_api/schemas/schemas/widget_update.json b/met-api/src/met_api/schemas/schemas/widget_update.json new file mode 100644 index 000000000..1e5955ed3 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/widget_update.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/widget_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "title": "Who is Listening" + } + ], + "required": ["title"], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Widget title", + "description": "The title of the widget.", + "examples": ["Who is Listening"] + } + } +} diff --git a/met-api/src/met_api/schemas/survey.py b/met-api/src/met_api/schemas/survey.py index 7d1a2ab18..9773dd9e6 100644 --- a/met-api/src/met_api/schemas/survey.py +++ b/met-api/src/met_api/schemas/survey.py @@ -4,8 +4,11 @@ """ from marshmallow import EXCLUDE, Schema, fields -from .engagement import EngagementSchema + from met_api.constants.comment_status import Status +from met_api.schemas.utils import count_comments_by_status + +from .engagement import EngagementSchema class SurveySchema(Schema): @@ -34,14 +37,10 @@ def get_comments_meta_data(self, obj): """Get the meta data of the comments made in the survey.""" return { 'total': len(obj.submissions), - 'pending': self._count_comments_by_status(obj.submissions, Status.Pending.value), - 'approved': self._count_comments_by_status(obj.submissions, Status.Approved.value), - 'rejected': self._count_comments_by_status(obj.submissions, Status.Rejected.value), - 'needs_further_review': self._count_comments_by_status( + 'pending': count_comments_by_status(obj.submissions, Status.Pending.value), + 'approved': count_comments_by_status(obj.submissions, Status.Approved.value), + 'rejected': count_comments_by_status(obj.submissions, Status.Rejected.value), + 'needs_further_review': count_comments_by_status( obj.submissions, Status.Needs_further_review.value) } - - def _count_comments_by_status(self, submissios, status): - return len([submission for submission in submissios - if submission.comment_status_id == status]) diff --git a/met-api/src/met_api/schemas/utils.py b/met-api/src/met_api/schemas/utils.py index a16e5562c..01361b3dd 100644 --- a/met-api/src/met_api/schemas/utils.py +++ b/met-api/src/met_api/schemas/utils.py @@ -20,6 +20,7 @@ from typing import Tuple from jsonschema import Draft7Validator, RefResolver, SchemaError, draft7_format_checker +from met_api.constants.user import SYSTEM_REVIEWER BASE_URI = 'https://met.gov.bc.ca/.well_known/schemas' @@ -115,3 +116,18 @@ def serialize(errors): for error in errors: error_message.append(error.message) return error_message + + +def count_comments_by_status(submissions, status): + """Count the comments by their status. + + :param submissions: List of submissions + :param status: Status of the comments + :return: Number of comments with the provided status + """ + return len([ + submission + for submission in submissions + if (submission.comment_status_id == status and + submission.reviewed_by != SYSTEM_REVIEWER) + ]) diff --git a/met-api/src/met_api/schemas/widget.py b/met-api/src/met_api/schemas/widget.py index d86147648..c2aec64d7 100644 --- a/met-api/src/met_api/schemas/widget.py +++ b/met-api/src/met_api/schemas/widget.py @@ -14,6 +14,7 @@ class Meta: # pylint: disable=too-few-public-methods unknown = EXCLUDE id = fields.Int(data_key='id') + title = fields.Str(data_key='title') widget_type_id = fields.Int(data_key='widget_type_id', required=True) engagement_id = fields.Int(data_key='engagement_id', required=True) created_by = fields.Str(data_key='created_by') diff --git a/met-api/src/met_api/services/authorization.py b/met-api/src/met_api/services/authorization.py index 92d2e79e2..31144e870 100644 --- a/met-api/src/met_api/services/authorization.py +++ b/met-api/src/met_api/services/authorization.py @@ -15,28 +15,34 @@ def check_auth(**kwargs): """Check if user is authorized to perform action on the service.""" user_from_context: UserContext = kwargs['user_context'] - token_roles = user_from_context.roles - permitted_roles = kwargs.get('one_of_roles', []) - has_valid_roles = bool(set(token_roles) & set(permitted_roles)) + token_roles = set(user_from_context.roles) + permitted_roles = set(kwargs.get('one_of_roles', [])) + has_valid_roles = token_roles & permitted_roles if has_valid_roles: return - if MembershipType.TEAM_MEMBER.name in permitted_roles: + + team_permitted_roles = {MembershipType.TEAM_MEMBER.name, MembershipType.REVIEWER.name} & permitted_roles + + if team_permitted_roles: # check if he is a member of particular engagement. - is_a_member = _has_team_membership(kwargs, user_from_context) - if is_a_member: + has_valid_team_access = _has_team_membership(kwargs, user_from_context, team_permitted_roles) + if has_valid_team_access: return abort(403) -def _has_team_membership(kwargs, user_from_context) -> bool: - eng_id = kwargs.get('engagement_id', None) - external_id = user_from_context.sub - user = StaffUserModel.get_user_by_external_id(external_id) - if not eng_id or not user: +def _has_team_membership(kwargs, user_from_context, team_permitted_roles) -> bool: + eng_id = kwargs.get('engagement_id') + + if not eng_id: + return False + + user = StaffUserModel.get_user_by_external_id(user_from_context.sub) + + if not user: return False + memberships = MembershipModel.find_by_engagement_and_user_id(eng_id, user.id) - # TODO when multiple memberships are supported , iterate list and check role. - if memberships and memberships[0].type == MembershipType.TEAM_MEMBER: - return True - return False + + return any(membership.type.name in team_permitted_roles for membership in memberships) diff --git a/met-api/src/met_api/services/email_verification_service.py b/met-api/src/met_api/services/email_verification_service.py index 89fe66bd9..131a0353e 100644 --- a/met-api/src/met_api/services/email_verification_service.py +++ b/met-api/src/met_api/services/email_verification_service.py @@ -10,6 +10,7 @@ from met_api.models import Engagement as EngagementModel from met_api.models import EngagementSlug as EngagementSlugModel from met_api.models import Survey as SurveyModel +from met_api.models import Tenant as TenantModel from met_api.models.email_verification import EmailVerification from met_api.schemas.email_verification import EmailVerificationSchema from met_api.services.participant_service import ParticipantService @@ -157,32 +158,38 @@ def _render_survey_email_template(survey: SurveyModel, token): subject_template = current_app.config.get('VERIFICATION_EMAIL_SUBJECT') survey_path = current_app.config.get('SURVEY_PATH'). \ format(survey_id=survey.id, token=token) - dashboard_path = EmailVerificationService._get_dashboard_path(engagement) + engagement_path = EmailVerificationService._get_engagement_path(engagement) site_url = notification.get_tenant_site_url(engagement.tenant_id) + tenant_name = EmailVerificationService._get_tenant_name(engagement.tenant_id) args = { 'engagement_name': engagement_name, 'survey_url': f'{site_url}{survey_path}', - 'engagement_url': f'{site_url}{dashboard_path}', - 'end_date': datetime.strftime(engagement.end_date, EmailVerificationService.full_date_format), + 'engagement_url': f'{site_url}{engagement_path}', + 'tenant_name': tenant_name, } subject = subject_template.format(engagement_name=engagement_name) body = template.render( engagement_name=args.get('engagement_name'), survey_url=args.get('survey_url'), engagement_url=args.get('engagement_url'), - end_date=args.get('end_date'), + tenant_name=args.get('tenant_name'), ) return subject, body, args, template_id @staticmethod - def _get_dashboard_path(engagement: EngagementModel): + def _get_engagement_path(engagement: EngagementModel): engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement.id) if engagement_slug: - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH_SLUG'). \ + return current_app.config.get('ENGAGEMENT_PATH_SLUG'). \ format(slug=engagement_slug.slug) - return current_app.config.get('ENGAGEMENT_DASHBOARD_PATH'). \ + return current_app.config.get('ENGAGEMENT_PATH'). \ format(engagement_id=engagement.id) + @staticmethod + def _get_tenant_name(tenant_id): + tenant = TenantModel.find_by_id(tenant_id) + return tenant.name + @staticmethod def validate_email_verification(email_verification: EmailVerificationSchema): """Validate an email verification.""" diff --git a/met-api/src/met_api/services/engagement_service.py b/met-api/src/met_api/services/engagement_service.py index 077743e44..eec2542b9 100644 --- a/met-api/src/met_api/services/engagement_service.py +++ b/met-api/src/met_api/services/engagement_service.py @@ -31,26 +31,33 @@ class EngagementService: otherdateformat = '%Y-%m-%d' @staticmethod - def get_engagement(engagement_id, user_id) -> EngagementSchema: + def get_engagement(engagement_id) -> EngagementSchema: """Get Engagement by the id.""" engagement_model: EngagementModel = EngagementModel.find_by_id(engagement_id) if engagement_model: - if user_id is None \ + if TokenInfo.get_id() is None \ and engagement_model.status_id not in (Status.Published.value, Status.Closed.value): # Non authenticated users only have access to published and closed engagements return None + if engagement_model.status_id in (Status.Draft.value, Status.Scheduled.value): + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + Role.VIEW_ENGAGEMENT.value + ) + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) + engagement = EngagementSchema().dump(engagement_model) engagement['banner_url'] = ObjectStorageService.get_url(engagement_model.banner_filename) return engagement @classmethod def get_engagements_paginated( - cls, - external_user_id, - pagination_options: PaginationOptions, - search_options=None, - include_banner_url=False, + cls, + external_user_id, + pagination_options: PaginationOptions, + search_options=None, + include_banner_url=False, ): """Get engagements paginated.""" user_roles = TokenInfo.get_user_roles() @@ -199,7 +206,7 @@ def _save_or_update_eng_block(engagement_id, status_block): # see if there is one existing for the status ;if not create one survey_status = survey_block.get('survey_status') survey_block = survey_block.get('block_text') - status_block: EngagementStatusBlockModel = EngagementStatusBlockModel.\ + status_block: EngagementStatusBlockModel = EngagementStatusBlockModel. \ get_by_status(engagement_id, survey_status) if status_block: status_block.block_text = survey_block diff --git a/met-api/src/met_api/services/submission_service.py b/met-api/src/met_api/services/submission_service.py index 88c97d59e..4ea0d994f 100644 --- a/met-api/src/met_api/services/submission_service.py +++ b/met-api/src/met_api/services/submission_service.py @@ -28,6 +28,7 @@ from met_api.utils import notification from met_api.utils.roles import Role from met_api.utils.template import Template +from met_api.models import Tenant as TenantModel class SubmissionService: @@ -58,7 +59,8 @@ def create(cls, token, submission: SubmissionSchema): # Creates a scoped session that will be committed when diposed or rolledback if a exception occurs with session_scope() as session: - email_verification = EmailVerificationService().verify(token, survey_id, None, session) + email_verification = EmailVerificationService().verify( + token, survey_id, None, session) participant_id = email_verification.get('participant_id') submission['participant_id'] = participant_id submission['created_by'] = participant_id @@ -66,7 +68,8 @@ def create(cls, token, submission: SubmissionSchema): submission_result = Submission.create(submission, session) submission['id'] = submission_result.id - comments = CommentService.extract_comments_from_survey(submission, survey) + comments = CommentService.extract_comments_from_survey( + submission, survey) CommentService().create_comments(comments, session) return submission_result @@ -85,8 +88,10 @@ def update_comments(cls, token, data: PublicSubmissionSchema): submission.comment_status_id = Status.Pending with session_scope() as session: - EmailVerificationService().verify(token, submission.survey_id, submission.id, session) - comments_result = [Comment.update(submission.id, comment, session) for comment in data.get('comments', [])] + EmailVerificationService().verify( + token, submission.survey_id, submission.id, session) + comments_result = [Comment.update( + submission.id, comment, session) for comment in data.get('comments', [])] Submission.update(SubmissionSchema().dump(submission), session) return comments_result @@ -96,7 +101,8 @@ def _validate_fields(submission): """Validate all fields.""" survey_id = submission.get('survey_id', None) survey: SurveyModel = SurveyModel.find_by_id(survey_id) - engagement: EngagementModel = EngagementModel.find_by_id(survey.engagement_id) + engagement: EngagementModel = EngagementModel.find_by_id( + survey.engagement_id) if not engagement: raise ValueError('Survey not linked to an Engagement') @@ -109,20 +115,26 @@ def review_comment(cls, submission_id, staff_review_details: dict, external_user user = StaffUserService.get_user_by_external_id(external_user_id) cls.validate_review(staff_review_details, user, submission_id) - reviewed_by = ' '.join([user.get('first_name', ''), user.get('last_name', '')]) + reviewed_by = ' '.join( + [user.get('first_name', ''), user.get('last_name', '')]) staff_review_details['reviewed_by'] = reviewed_by staff_review_details['user_id'] = user.get('id') with session_scope() as session: - should_send_email = SubmissionService._should_send_email(submission_id, staff_review_details) - submission = Submission.update_comment_status(submission_id, staff_review_details, session) + should_send_email = SubmissionService._should_send_email( + submission_id, staff_review_details) + submission = Submission.update_comment_status( + submission_id, staff_review_details, session) if staff_notes := staff_review_details.get('staff_note', []): - cls.add_or_update_staff_note(submission.survey_id, submission_id, staff_notes) + cls.add_or_update_staff_note( + submission.survey_id, submission_id, staff_notes) if should_send_email: - rejection_review_note = StaffNote.get_staff_note_by_type(submission_id, StaffNoteType.Review.name) - SubmissionService._trigger_email(rejection_review_note[0].note, session, submission) + rejection_review_note = StaffNote.get_staff_note_by_type( + submission_id, StaffNoteType.Review.name) + SubmissionService._trigger_email( + rejection_review_note[0].note, session, submission) session.commit() return SubmissionSchema().dump(submission) @@ -134,7 +146,8 @@ def _trigger_email(review_note, session, submission): 'submission_id': submission.id, 'type': EmailVerificationType.RejectedComment, }, session) - SubmissionService._send_rejected_email(submission, review_note, email_verification.get('verification_token')) + SubmissionService._send_rejected_email( + submission, review_note, email_verification.get('verification_token')) @classmethod def validate_review(cls, values: dict, user, submission_id): @@ -145,7 +158,8 @@ def validate_review(cls, values: dict, user, submission_id): has_threat = values.get('has_threat', None) rejected_reason_other = values.get('rejected_reason_other', None) - valid_statuses = [status.id for status in CommentStatus.get_comment_statuses()] + valid_statuses = [ + status.id for status in CommentStatus.get_comment_statuses()] if not user: raise ValueError('Invalid user.') @@ -162,7 +176,8 @@ def validate_review(cls, values: dict, user, submission_id): if not submission: raise ValueError('Invalid submission.') authorization.check_auth( - one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.REVIEW_ALL_COMMENTS.value), + one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.REVIEW_ALL_COMMENTS.value), engagement_id=submission.engagement_id ) @@ -170,14 +185,21 @@ def validate_review(cls, values: dict, user, submission_id): def add_or_update_staff_note(cls, survey_id, submission_id, staff_notes): """Process staff note for a comment.""" for staff_note in staff_notes: - note = StaffNote.get_staff_note_by_type(submission_id, staff_note.get('note_type')) + note = StaffNote.get_staff_note_by_type( + submission_id, staff_note.get('note_type')) if note: note[0].note = staff_note['note'] note[0].flush() else: - doc = SubmissionService._create_staff_notes(survey_id, submission_id, staff_note) + doc = SubmissionService._create_staff_notes( + survey_id, submission_id, staff_note) doc.flush() + @staticmethod + def _get_tenant_name(tenant_id): + tenant = TenantModel.find_by_id(tenant_id) + return tenant.name + @staticmethod def _create_staff_notes(survey_id, submission_id, staff_note): doc: StaffNote = StaffNote() @@ -203,10 +225,12 @@ def _should_send_email(submission_id: int, staff_comment_details: dict) -> bool: if staff_comment_details.get('notify_email') is False: return False if staff_comment_details.get('status_id') == Status.Rejected.value: - has_review_note_changed = SubmissionService.is_review_note_changed(submission_id, staff_comment_details) + has_review_note_changed = SubmissionService.is_review_note_changed( + submission_id, staff_comment_details) if has_review_note_changed: return True - has_reason_changed = SubmissionService.is_rejection_reason_changed(submission_id, staff_comment_details) + has_reason_changed = SubmissionService.is_rejection_reason_changed( + submission_id, staff_comment_details) if has_reason_changed: return True return False @@ -229,7 +253,8 @@ def is_review_note_changed(submission_id: int, values: dict) -> bool: staff_notes = values.get('staff_note', []) for staff_note in staff_notes: if staff_note['note_type'] == StaffNoteType.Review.name: - note = StaffNote.get_staff_note_by_type(submission_id, StaffNoteType.Review.name) + note = StaffNote.get_staff_note_by_type( + submission_id, StaffNoteType.Review.name) if not note or note[0].note != staff_note.get('note'): return True return False @@ -256,7 +281,8 @@ def get_paginated( survey_id, pagination_options, search_text, - advanced_search_filters if any(advanced_search_filters.values()) else None + advanced_search_filters if any( + advanced_search_filters.values()) else None ) return { 'items': SubmissionSchema(many=True, exclude=['submission_json']).dump(items), @@ -269,16 +295,20 @@ def _send_rejected_email(submission: Submission, review_note, token) -> None: participant_id = submission.participant_id participant = ParticipantModel.find_by_id(participant_id) - template_id = current_app.config.get('REJECTED_EMAIL_TEMPLATE_ID', None) - subject, body, args = SubmissionService._render_email_template(submission, review_note, token) + template_id = current_app.config.get( + 'REJECTED_EMAIL_TEMPLATE_ID', None) + subject, body, args = SubmissionService._render_email_template( + submission, review_note, token) try: notification.send_email(subject=subject, - email=ParticipantModel.decode_email(participant.email_address), + email=ParticipantModel.decode_email( + participant.email_address), html_body=body, args=args, template_id=template_id) except Exception as exc: # noqa: B902 - current_app.logger.error(' None: @staticmethod def _render_email_template(submission: Submission, review_note, token): template = Template.get_template('email_rejected_comment.html') - engagement: EngagementModel = EngagementModel.find_by_id(submission.engagement_id) + engagement: EngagementModel = EngagementModel.find_by_id( + submission.engagement_id) survey: SurveyModel = SurveyModel.find_by_id(submission.survey_id) engagement_name = engagement.name survey_name = survey.name - + tenant_name = SubmissionService._get_tenant_name( + engagement.tenant_id) submission_path = current_app.config.get('SUBMISSION_PATH'). \ - format(engagement_id=submission.engagement_id, submission_id=submission.id, token=token) - submission_url = notification.get_tenant_site_url(engagement.tenant_id, submission_path) + format(engagement_id=submission.engagement_id, + submission_id=submission.id, token=token) + submission_url = notification.get_tenant_site_url( + engagement.tenant_id, submission_path) subject = current_app.config.get('REJECTED_EMAIL_SUBJECT'). \ format(engagement_name=engagement_name) args = { @@ -306,6 +340,7 @@ def _render_email_template(submission: Submission, review_note, token): 'submission_url': submission_url, 'review_note': review_note, 'end_date': datetime.strftime(engagement.end_date, EmailVerificationService.full_date_format), + 'tenant_name': tenant_name, } body = template.render( engagement_name=args.get('engagement_name'), diff --git a/met-api/src/met_api/services/survey_service.py b/met-api/src/met_api/services/survey_service.py index a3a1e6dae..8db1f1923 100644 --- a/met-api/src/met_api/services/survey_service.py +++ b/met-api/src/met_api/services/survey_service.py @@ -13,11 +13,10 @@ from met_api.schemas.survey import SurveySchema from met_api.services import authorization from met_api.services.membership_service import MembershipService -from met_api.services.report_setting_service import ReportSettingService from met_api.services.object_storage_service import ObjectStorageService +from met_api.services.report_setting_service import ReportSettingService from met_api.utils.roles import Role from met_api.utils.token_info import TokenInfo - from ..exceptions.business_exception import BusinessException @@ -29,8 +28,32 @@ class SurveyService: @classmethod def get(cls, survey_id): - """Get survey by the id.""" - survey_model: SurveyModel = SurveyModel.find_by_id(survey_id) + """Get survey by the ID.""" + survey_model = SurveyModel.find_by_id(survey_id) + eng_id = None + one_of_roles = (Role.VIEW_SURVEYS.value,) + skip_auth = False + + if survey_model.is_hidden: + # Only Admins can view hidden surveys. + one_of_roles = (Role.VIEW_ALL_SURVEYS.value,) + elif survey_model.engagement_id: + engagement_model = EngagementModel.find_by_id(survey_model.engagement_id) + if engagement_model: + eng_id = engagement_model.id + if engagement_model.status_id == Status.Published.value: + # Published Engagement anyone can access. + skip_auth = True + else: + one_of_roles = ( + MembershipType.TEAM_MEMBER.name, + MembershipType.REVIEWER.name, + Role.VIEW_SURVEYS.value + ) + + if not skip_auth: + authorization.check_auth(one_of_roles=one_of_roles, engagement_id=eng_id) + survey = SurveySchema().dump(survey_model) return survey @@ -112,6 +135,13 @@ def clone(cls, data, survey_id): if not survey_to_clone: raise KeyError('Survey to clone was not found') + eng_id = None + if engagement_id := data.get('engagement_id', None): + engagement_model = EngagementModel.find_by_id(engagement_id) + eng_id = getattr(engagement_model, 'id', None) + + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.CLONE_SURVEY.value), engagement_id=eng_id) cloned_survey = SurveyModel.create_survey({ 'name': data.get('name'), @@ -143,7 +173,7 @@ def update(cls, data: SurveySchema): engagement_id = survey.get('engagement_id', None) authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, - Role.EDIT_ALL_SURVEYS.value), engagement_id=engagement_id) + Role.EDIT_SURVEY.value), engagement_id=engagement_id) # check if user has edit all surveys access to edit template surveys as well user_roles = TokenInfo.get_user_roles() @@ -189,6 +219,8 @@ def validate_create_fields(data): def link(cls, survey_id, engagement_id): """Update survey.""" cls.validate_link_fields(survey_id, engagement_id) + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_SURVEY.value), engagement_id=engagement_id) return SurveyModel.link_survey(survey_id, engagement_id) @classmethod @@ -210,6 +242,8 @@ def validate_link_fields(cls, survey_id, engagement_id): def unlink(cls, survey_id, engagement_id): """Unlink survey.""" cls.validate_unlink_fields(survey_id, engagement_id) + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_SURVEY.value), engagement_id=engagement_id) return SurveyModel.unlink_survey(survey_id) @classmethod diff --git a/met-api/src/met_api/services/widget_service.py b/met-api/src/met_api/services/widget_service.py index 52383a950..15b744fe4 100644 --- a/met-api/src/met_api/services/widget_service.py +++ b/met-api/src/met_api/services/widget_service.py @@ -65,6 +65,16 @@ def sort_widget(engagement_id, widgets: list, user_id=None): WidgetModel.update_widgets(widget_sort_mappings) + @staticmethod + def update_widget(engagement_id, widget_id: list, widget_data: dict, user_id=None): + """Sort widgets.""" + WidgetService._verify_widget(widget_id) + + widget_data['updated_by'] = user_id + + updated_widget = WidgetModel.update_widget(engagement_id, widget_id, widget_data) + return WidgetSchema().dump(updated_widget) + @staticmethod def _validate_widget_ids(engagement_id, widgets): """Validate if widget ids belong to the engagement.""" @@ -76,6 +86,14 @@ def _validate_widget_ids(engagement_id, widgets): error='Invalid widgets.', status_code=HTTPStatus.BAD_REQUEST) + @staticmethod + def _verify_widget(widget_id): + """Verify if widget exists.""" + widget = WidgetModel.get_widget_by_id(widget_id) + if not widget: + raise KeyError('Widget ' + widget_id + ' does not exist') + return widget + @staticmethod def create_widget_items_bulk(widget_items: list, user_id): """Create widget items in bulk.""" diff --git a/met-api/src/met_api/services/widget_subscribe_service.py b/met-api/src/met_api/services/widget_subscribe_service.py index 3a8eab822..0224e9b7f 100644 --- a/met-api/src/met_api/services/widget_subscribe_service.py +++ b/met-api/src/met_api/services/widget_subscribe_service.py @@ -115,13 +115,16 @@ def update_subscribe_item(widget_id, subscribe_id, item_id, request_json): raise BusinessException( error='Invalid widgets and subscribe', status_code=HTTPStatus.BAD_REQUEST) + subscribe_item: SubscribeItemsModel = SubscribeItemsModel.find_by_id( item_id) - if subscribe_item.widget_subscribes_id != subscribe_id: + if subscribe_item.widget_subscribe_id != subscribe_id: raise BusinessException( error='Invalid widgets and subscribe', status_code=HTTPStatus.BAD_REQUEST) - WidgetSubscribeService._update_from_dict(subscribe_item, request_json) + + WidgetSubscribeService._update_from_dict( + subscribe_item, request_json) subscribe_item.commit() return SubscribeItemsModel.find_by_id(item_id) diff --git a/met-api/src/met_api/utils/roles.py b/met-api/src/met_api/utils/roles.py index 40e332c7c..28648136f 100644 --- a/met-api/src/met_api/utils/roles.py +++ b/met-api/src/met_api/utils/roles.py @@ -30,6 +30,7 @@ class Role(Enum): CREATE_ENGAGEMENT = 'create_engagement' VIEW_SURVEYS = 'view_surveys' CREATE_SURVEY = 'create_survey' + EDIT_SURVEY = 'edit_survey' CLONE_SURVEY = 'clone_survey' PUBLISH_ENGAGEMENT = 'publish_engagement' VIEW_ENGAGEMENT = 'view_engagement' @@ -41,7 +42,7 @@ class Role(Enum): ACCESS_DASHBOARD = 'access_dashboard' VIEW_MEMBERS = 'view_members' EDIT_MEMBERS = 'edit_members' - VIEW_ALL_SURVEYS = 'view_all_surveys' + VIEW_ALL_SURVEYS = 'view_all_surveys' # Super user can view all kind of surveys including hidden EDIT_ALL_SURVEYS = 'edit_all_surveys' EDIT_DRAFT_ENGAGEMENT = 'edit_draft_engagement' EDIT_SCHEDULED_ENGAGEMENT = 'edit_scheduled_engagement' diff --git a/met-api/templates/email_rejected_comment.html b/met-api/templates/email_rejected_comment.html index 538fd6c5a..954824a61 100644 --- a/met-api/templates/email_rejected_comment.html +++ b/met-api/templates/email_rejected_comment.html @@ -1,20 +1,30 @@ -

Thank you for taking the time to fill in our survey about {{ engagement_name }}.

-

We reviewed your comments and can't publish them on our public site for the following reason(s):

+

Thank you for taking the time to provide your feedback on {{ engagement_name }}.

+

We have reviewed your feedback and can't accept it for the following reason(s):


+

Your feedback contained:

+
    - {% if has_personal_info %} -
  • One or many of your comments contain personal information such as name, address, or other information that could identify you.
  • - {% endif %} - {% if has_profanity %} -
  • One or many of your comments contain swear words or profanities.
  • - {% endif %} - {% if has_other_reason %} -
  • One or many of your comments can't be published because of {{ other_reason }}.
  • - {% endif %} + {% if has_personal_info %} +
  • + One or many of your comments contain personal information such as name, + address, or other information that could identify you. +
  • + {% endif %} {% if has_profanity %} +
  • One or many of your comments contain swear words or profanities.
  • + {% endif %} {% if has_other_reason %} +
  • + One or many of your comments can't be published because of {{ other_reason + }}. +
  • + {% endif %}

-

Your comments will still be taken in consideration and send to the proponent for consideration but won't appear on our website.

+

You can edit and re-submit your feedback here.

+

+ The comment period is open until {{ end_date }}. You must re-submit + your feedback before the comment period closes. +


Thank you,


-

The EAO Team

\ No newline at end of file +

The {{tenant_name}} Team

diff --git a/met-api/templates/email_verification.html b/met-api/templates/email_verification.html index 2ada0370b..a020c7882 100644 --- a/met-api/templates/email_verification.html +++ b/met-api/templates/email_verification.html @@ -1,11 +1,8 @@ -

Thank you for your interest in sharing your thoughts about {{ engagement_name }}.

-

Please click the link below to access the survey and share your comments. This link will expire in 24 hours and is only valid once.

+

Share your feedback about {{ engagement_name }}.

+

Please click the link below to provide your feedback. This link will expire in 24 hours and is only valid once. If the link has expired, you can request request access again by visiting {{ engagement_name }}.


-Access Survey -
-

You can view the survey results anytime here.

-

The comments will be available for viewing when the Engagement period is over, starting on {{ end_date }}.

+

Click here to provide your feedback.


Thank you,


-

The EAO Team

\ No newline at end of file +

The {{ tenant_name }} Team

diff --git a/met-api/tests/unit/api/test_engagement.py b/met-api/tests/unit/api/test_engagement.py index b4c84af5d..a83bbc2a7 100644 --- a/met-api/tests/unit/api/test_engagement.py +++ b/met-api/tests/unit/api/test_engagement.py @@ -18,6 +18,7 @@ """ import copy import json +from http import HTTPStatus import pytest from faker import Faker @@ -107,6 +108,32 @@ def test_get_engagements(client, jwt, session, engagement_info): # pylint:disab assert created_eng.get('content') == rv.json.get('content') +@pytest.mark.parametrize('engagement_info', [TestEngagementInfo.engagement_draft]) +def test_get_engagements_reviewer(client, jwt, session, engagement_info): # pylint:disable=unused-argument + """Assert reviewers access on an engagement.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) + rv = client.post('/api/engagements/', data=json.dumps(engagement_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK.value + created_eng = rv.json + eng_id = created_eng.get('id') + staff_1 = dict(TestUserInfo.user_staff_1) + user = factory_staff_user_model(user_info=staff_1) + claims = copy.deepcopy(TestJwtClaims.reviewer_role.value) + claims['sub'] = str(user.external_id) + headers = factory_auth_header(jwt=jwt, claims=claims) + rv = client.get(f'/api/engagements/{eng_id}', + 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') + + # Reveiwer has no access to draft engagement + rv = client.get(f'/api/engagements/{eng_id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.FORBIDDEN.value + + @pytest.mark.parametrize('engagement_info', [TestEngagementInfo.engagement1]) def test_search_engagements_by_status(client, jwt, session, engagement_info): # pylint:disable=unused-argument diff --git a/met-api/tests/unit/api/test_survey.py b/met-api/tests/unit/api/test_survey.py index 28a9c1df6..3b4e5e42f 100644 --- a/met-api/tests/unit/api/test_survey.py +++ b/met-api/tests/unit/api/test_survey.py @@ -16,18 +16,24 @@ Test-Suite to ensure that the /Engagement endpoint is working as expected. """ +import copy import json from http import HTTPStatus import pytest from flask import current_app +from met_api.constants.engagement_status import Status +from met_api.models.engagement import Engagement as EngagementModel +from met_api.models.membership import Membership as MembershipModel from met_api.models.tenant import Tenant as TenantModel from met_api.utils.constants import TENANT_ID_HEADER -from met_api.utils.enums import ContentType -from tests.utilities.factory_scenarios import TestJwtClaims, TestSurveyInfo, TestTenantInfo +from met_api.utils.enums import ContentType, MembershipStatus +from tests.utilities.factory_scenarios import TestJwtClaims, TestSurveyInfo, TestTenantInfo, TestUserInfo from tests.utilities.factory_utils import ( - factory_auth_header, factory_engagement_model, factory_survey_model, factory_tenant_model, set_global_tenant) + factory_auth_header, factory_engagement_model, factory_membership_model, factory_staff_user_model, + factory_survey_model, factory_tenant_model, set_global_tenant) + surveys_url = '/api/surveys/' @@ -105,7 +111,7 @@ def test_survey_link(client, jwt, session): # pylint:disable=unused-argument """Assert that a survey can be POSTed.""" survey = factory_survey_model() survey_id = survey.id - headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.no_role) + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) eng = factory_engagement_model() eng_id = eng.id @@ -151,6 +157,59 @@ def test_get_hidden_survey_for_admins(client, jwt, session): # pylint:disable=u assert rv.json.get('total') == 1 +def test_get_survey_for_reviewer(client, jwt, session): # pylint:disable=unused-argument + """Assert reviewers different permission.""" + staff_1 = dict(TestUserInfo.user_staff_1) + user = factory_staff_user_model(user_info=staff_1) + claims = copy.deepcopy(TestJwtClaims.reviewer_role.value) + claims['sub'] = str(user.external_id) + headers = factory_auth_header(jwt=jwt, claims=claims) + set_global_tenant() + survey1 = factory_survey_model(TestSurveyInfo.survey1) + + # Attempt to access unlinked survey + rv = client.get(f'{surveys_url}{survey1.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 403 + + # Link to a draft engagement + eng: EngagementModel = factory_engagement_model(status=Status.Draft.value) + survey1.engagement_id = eng.id + survey1.commit() + + # Attempt to access survey linked to draft engagement + rv = client.get(f'{surveys_url}{survey1.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 403 + + # Add user as a reviewer in the team + factory_membership_model(user_id=user.id, engagement_id=eng.id, member_type='REVIEWER') + + # Assert Reviewer can see the survey since he is added to the team. + rv = client.get(f'{surveys_url}{survey1.id}', + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == 200 + + # Deactivate membership + membership_model: MembershipModel = MembershipModel.find_by_engagement_and_user_id(eng.id, user.id) + membership_model[0].status = MembershipStatus.INACTIVE.value + membership_model[0].commit() + + rv = client.get(f'{surveys_url}{survey1.id}', + headers=headers, content_type=ContentType.JSON.value) + # Verify reviewer lost access after being removed from the team + assert rv.status_code == 403 + + # Publish the engagement + eng.status_id = Status.Published.value + eng.commit() + rv = client.get(f'{surveys_url}{survey1.id}', + headers=headers, content_type=ContentType.JSON.value) + + # Assert user can access the survey even when he is removed from the team since its published. + assert rv.status_code == 200 + + def test_get_hidden_survey_for_team_member(client, jwt, session): # pylint:disable=unused-argument """Assert that a hidden survey cannot be fetched by team members.""" headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.team_member_role) diff --git a/met-api/tests/unit/services/test_engagement.py b/met-api/tests/unit/services/test_engagement.py index 671722260..efc931973 100644 --- a/met-api/tests/unit/services/test_engagement.py +++ b/met-api/tests/unit/services/test_engagement.py @@ -21,20 +21,21 @@ from met_api.services import authorization from met_api.services.engagement_service import EngagementService -from tests.utilities.factory_scenarios import TestEngagementInfo, TestUserInfo -from tests.utilities.factory_utils import factory_engagement_model +from tests.utilities.factory_scenarios import TestEngagementInfo, TestJwtClaims +from tests.utilities.factory_utils import factory_engagement_model, factory_staff_user_model, patch_token_info fake = Faker() date_format = '%Y-%m-%d' -def test_create_engagement(session): # pylint:disable=unused-argument +def test_create_engagement(session, monkeypatch): # pylint:disable=unused-argument """Assert that an Org can be created.""" - user_id = TestUserInfo.user['id'] engagement_data = TestEngagementInfo.engagement1 saved_engagament = EngagementService().create_engagement(engagement_data) # fetch the engagement with id and assert - fetched_engagement = EngagementService().get_engagement(saved_engagament.id, user_id) + factory_staff_user_model() + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) + fetched_engagement = EngagementService().get_engagement(saved_engagament.id) assert fetched_engagement.get('id') == saved_engagament.id assert fetched_engagement.get('name') == engagement_data.get('name') assert fetched_engagement.get('description') == engagement_data.get('description') @@ -42,13 +43,14 @@ def test_create_engagement(session): # pylint:disable=unused-argument assert fetched_engagement.get('end_date') -def test_create_engagement_with_survey_block(session): # pylint:disable=unused-argument +def test_create_engagement_with_survey_block(session, monkeypatch): # pylint:disable=unused-argument """Assert that an Org can be created.""" - user_id = TestUserInfo.user['id'] engagement_data = TestEngagementInfo.engagement2 saved_engagament = EngagementService().create_engagement(engagement_data) + factory_staff_user_model() + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) # fetch the engagement with id and assert - fetched_engagement = EngagementService().get_engagement(saved_engagament.id, user_id) + fetched_engagement = EngagementService().get_engagement(saved_engagament.id) assert fetched_engagement.get('id') == saved_engagament.id assert fetched_engagement.get('name') == engagement_data.get('name') assert fetched_engagement.get('description') == engagement_data.get('description') diff --git a/met-api/tests/unit/services/test_survey.py b/met-api/tests/unit/services/test_survey.py index 2cb19731d..455572c55 100644 --- a/met-api/tests/unit/services/test_survey.py +++ b/met-api/tests/unit/services/test_survey.py @@ -17,15 +17,18 @@ """ from met_api.services.survey_service import SurveyService -from tests.utilities.factory_scenarios import TestSurveyInfo +from tests.utilities.factory_scenarios import TestJwtClaims, TestSurveyInfo +from tests.utilities.factory_utils import factory_staff_user_model, patch_token_info -def test_create_survey(session): # pylint:disable=unused-argument +def test_create_survey(session, monkeypatch,): # pylint:disable=unused-argument """Assert that a survey can be created.""" survey_data = { 'name': TestSurveyInfo.survey1.get('name'), 'display': TestSurveyInfo.survey1.get('form_json').get('display'), } + factory_staff_user_model() + patch_token_info(TestJwtClaims.staff_admin_role, monkeypatch) saved_survey = SurveyService().create(survey_data) # fetch the survey with id and assert fetched_survey = SurveyService().get(saved_survey.id) diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index cf7639c1b..061edf876 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -24,6 +24,8 @@ from met_api.config import get_named_config from met_api.constants.comment_status import Status as CommentStatus from met_api.constants.engagement_status import SubmissionStatus +from met_api.constants.engagement_status import Status as EngagementStatus + from met_api.constants.feedback import CommentType, FeedbackSourceType, RatingType from met_api.constants.widget import WidgetType from met_api.utils.enums import LoginSource @@ -158,7 +160,7 @@ class TestEngagementInfo(dict, Enum): 'banner_url': '', 'created_by': '123', 'updated_by': '123', - 'status': SubmissionStatus.Open.value, + 'status': EngagementStatus.Published.value, 'is_internal': False, 'description': 'My Test Engagement Description', 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ @@ -168,6 +170,23 @@ class TestEngagementInfo(dict, Enum): \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"' } + engagement_draft = { + 'name': fake.name(), + 'start_date': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d'), + 'end_date': (datetime.today() + timedelta(days=1)).strftime('%Y-%m-%d'), + 'banner_url': '', + 'created_by': '123', + 'updated_by': '123', + 'status': EngagementStatus.Draft.value, + 'is_internal': False, + 'description': 'My Test Engagement Description', + 'rich_description': '"{\"blocks\":[{\"key\":\"2ku94\",\"text\":\"Rich Description Sample\",\"type\":\"unstyled\",\ + \"depth\":0,\"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"', + 'content': 'Content Sample', + 'rich_content': '"{\"blocks\":[{\"key\":\"fclgj\",\"text\":\"Rich Content Sample\",\"type\":\"unstyled\",\"depth\":0,\ + \"inlineStyleRanges\":[],\"entityRanges\":[],\"data\":{}}],\"entityMap\":{}}"' + } + engagement2 = { 'name': fake.name(), 'start_date': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d'), @@ -270,7 +289,9 @@ class TestJwtClaims(dict, Enum): 'view_private_engagements', 'create_admin_user', 'view_all_surveys', + 'view_surveys', 'edit_all_surveys', + 'edit_survey', 'view_unapproved_comments', 'clone_survey', 'edit_members', @@ -299,6 +320,23 @@ class TestJwtClaims(dict, Enum): } } + reviewer_role = { + 'iss': CONFIG.JWT_OIDC_TEST_ISSUER, + 'sub': 'f7a4a1d3-73a8-4cbc-a40f-bb1145302064', + 'idp_userid': 'f7a4a1d3-73a8-4cbc-a40f-bb1145302064', + 'preferred_username': f'{fake.user_name()}@idir', + 'given_name': fake.first_name(), + 'family_name': fake.last_name(), + 'email': 'staff@gov.bc.ca', + 'identity_provider': LoginSource.IDIR.value, + 'realm_access': { + 'roles': [ + 'staff', + 'view_users', + ] + } + } + class TestWidgetInfo(dict, Enum): """Test scenarios of widget.""" diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index cd05dd7b1..b837f4301 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -44,7 +44,6 @@ TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, TestWidgetItemInfo) - CONFIG = get_named_config('testing') fake = Faker() @@ -176,12 +175,12 @@ def factory_participant_model(participant: dict = TestParticipantInfo.participan return participant -def factory_membership_model(user_id, engagement_id, member_type='TEAM_MEMBER'): +def factory_membership_model(user_id, engagement_id, member_type='TEAM_MEMBER', status=MembershipStatus.ACTIVE.value): """Produce a Membership model.""" membership = MembershipModel(user_id=user_id, engagement_id=engagement_id, type=member_type, - status=MembershipStatus.ACTIVE.value) + status=status) membership.created_by_id = user_id membership.save() diff --git a/met-web/nginx/nginx.dev.conf b/met-web/nginx/nginx.dev.conf index 9e5297ded..d9d7eb1ad 100644 --- a/met-web/nginx/nginx.dev.conf +++ b/met-web/nginx/nginx.dev.conf @@ -39,16 +39,29 @@ http { return 405; } - # add in most common security headers - add_header Content-Security-Policy " - default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: 'unsafe-inline' 'unsafe-eval'; - script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com https://www.youtube.com 'unsafe-eval'; - worker-src 'self' blob:; - img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; - style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://noembed.com; - frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca https://www.youtube.com; - frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca"; + # add in most common security headers + add_header Content-Security-Policy " + default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: + 'unsafe-inline' 'unsafe-eval'; + script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com + https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com + https://www.youtube.com https://player.vimeo.com 'unsafe-eval'; + worker-src 'self' blob:; + img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; + style-src 'self' 'unsafe-inline'; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 + https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca + https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca + https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com + https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com + https://tiles.arcgis.com https://www.arcgis.com; + frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca + https://epic-engage-analytics-api-dev.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca + https://www.youtube.com https://player.vimeo.com; + frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; diff --git a/met-web/nginx/nginx.prod.conf b/met-web/nginx/nginx.prod.conf index 4d13375c5..c9b8d2b75 100644 --- a/met-web/nginx/nginx.prod.conf +++ b/met-web/nginx/nginx.prod.conf @@ -41,13 +41,21 @@ http { # add in most common security headers add_header Content-Security-Policy " - default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: 'unsafe-inline' 'unsafe-eval'; - script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com https://www.youtube.com 'unsafe-eval'; + default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: + 'unsafe-inline' 'unsafe-eval'; + script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com + https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com + https://www.youtube.com https://player.vimeo.com 'unsafe-eval'; worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api.apps.gold.devops.gov.bc.ca https://met-oidc.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://noembed.com; - frame-src 'self' https://met-oidc.apps.gold.devops.gov.bc.ca https://met-analytics.apps.gold.devops.gov.bc.ca https://www.youtube.com; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 + https://met-analytics-api.apps.gold.devops.gov.bc.ca + https://met-oidc.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com + https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com + https://tiles.arcgis.com https://www.arcgis.com; + frame-src 'self' https://met-oidc.apps.gold.devops.gov.bc.ca + https://met-analytics.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; frame-ancestors 'self' https://met-oidc.apps.gold.devops.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; diff --git a/met-web/nginx/nginx.test.conf b/met-web/nginx/nginx.test.conf index 8900a4da0..f9a36471e 100644 --- a/met-web/nginx/nginx.test.conf +++ b/met-web/nginx/nginx.test.conf @@ -41,14 +41,26 @@ http { # add in most common security headers add_header Content-Security-Policy " - default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: 'unsafe-inline' 'unsafe-eval'; - script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com https://www.youtube.com 'unsafe-eval'; + default-src 'self' https://kit.fontawesome.com https://ka-f.fontawesome.com data: blob: filesystem: + 'unsafe-inline' 'unsafe-eval'; + script-src 'self' 'sha256-JXGej4mPACbE/fP5kuunldJEyMk62sNjNe85DtAcMoU=' https://kit.fontawesome.com + https://ka-f.fontawesome.com https://www2.gov.bc.ca https://cdn.form.io https://api.mapbox.com + https://www.youtube.com https://player.vimeo.com 'unsafe-eval'; worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca https://met-analytics-api-test.apps.gold.devops.gov.bc.ca https://met-oidc-test.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com https://noembed.com; - frame-src 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca https://met-analytics-test.apps.gold.devops.gov.bc.ca https://www.youtube.com; - frame-ancestors 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca"; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 + https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca + https://met-analytics-api-test.apps.gold.devops.gov.bc.ca + https://met-oidc-test.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com + https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com + https://tiles.arcgis.com https://www.arcgis.com; + frame-src 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca https://epic-engage-analytics-api-test.apps.gold.devops.gov.bc.ca + https://met-analytics-test.apps.gold.devops.gov.bc.ca https://www.youtube.com https://player.vimeo.com; + frame-ancestors 'self' https://met-oidc-test.apps.gold.devops.gov.bc.ca + https://epic-engage-oidc-test.apps.gold.devops.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; diff --git a/met-web/src/App.tsx b/met-web/src/App.tsx index 73f10d3a8..29412360e 100644 --- a/met-web/src/App.tsx +++ b/met-web/src/App.tsx @@ -32,8 +32,8 @@ const App = () => { const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); const authenticationLoading = useAppSelector((state) => state.user.authentication.loading); const pathSegments = window.location.pathname.split('/'); - const basename = pathSegments[1]; const language = 'en'; // Default language is English, change as needed + const basename = pathSegments[1].toLowerCase(); const tenant: TenantState = useAppSelector((state) => state.tenant); diff --git a/met-web/src/apiManager/apiSlices/widgets/index.ts b/met-web/src/apiManager/apiSlices/widgets/index.ts index 326b7b7c8..2578e8bf1 100644 --- a/met-web/src/apiManager/apiSlices/widgets/index.ts +++ b/met-web/src/apiManager/apiSlices/widgets/index.ts @@ -1,6 +1,6 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { AppConfig } from 'config'; -import { Widget } from 'models/widget'; +import { Widget, WidgetItem } from 'models/widget'; import { prepareHeaders } from 'apiManager//apiSlices/util'; // Define a service using a base URL and expected endpoints @@ -25,6 +25,25 @@ export const widgetsApi = createApi({ }), invalidatesTags: ['Widgets'], }), + updateWidget: builder.mutation }>({ + query: ({ engagementId, id, data }) => ({ + url: `widgets/${id}/engagements/${engagementId}`, + method: 'PATCH', + body: data, + }), + invalidatesTags: ['Widgets'], + }), + createWidgetItems: builder.mutation< + WidgetItem[], + { widget_id: number; widget_items_data: Partial[] } + >({ + query: ({ widget_id, widget_items_data }) => ({ + url: `widgets/${widget_id}/items`, + method: 'POST', + body: widget_items_data, + }), + invalidatesTags: ['Widgets'], + }), sortWidgets: builder.mutation({ query: ({ engagementId, widgets }) => ({ url: `widgets/engagement/${engagementId}/sort_index`, @@ -35,7 +54,7 @@ export const widgetsApi = createApi({ }), deleteWidget: builder.mutation({ query: ({ engagementId, widgetId }) => ({ - url: `widgets/engagement/${engagementId}/widget/${widgetId}`, + url: `widgets/${widgetId}/engagements/${engagementId}`, method: 'DELETE', }), invalidatesTags: (_result, _error, arg) => [{ type: 'Widgets', id: arg.widgetId }], @@ -47,5 +66,11 @@ export const widgetsApi = createApi({ // Export hooks for usage in functional components, which are // auto-generated based on the defined endpoints -export const { useLazyGetWidgetsQuery, useCreateWidgetMutation, useSortWidgetsMutation, useDeleteWidgetMutation } = - widgetsApi; +export const { + useLazyGetWidgetsQuery, + useCreateWidgetMutation, + useUpdateWidgetMutation, + useSortWidgetsMutation, + useDeleteWidgetMutation, + useCreateWidgetItemsMutation, +} = widgetsApi; diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index fb0ff1448..31cb26f15 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -84,7 +84,7 @@ const Endpoints = { Widgets: { GET_LIST: `${AppConfig.apiUrl}/widgets/engagement/engagement_id`, CREATE: `${AppConfig.apiUrl}/widgets/engagement/engagement_id`, - DELETE: `${AppConfig.apiUrl}/widgets/engagement/engagement_id/widget/widget_id`, + DELETE: `${AppConfig.apiUrl}/widgets/widget_id/engagements/engagement_id`, SORT: `${AppConfig.apiUrl}/widgets/engagement/engagement_id/sort_index`, }, Widget_items: { diff --git a/met-web/src/components/banner/BannerWithImage.tsx b/met-web/src/components/banner/BannerWithImage.tsx index 0605a7477..d80da8c32 100644 --- a/met-web/src/components/banner/BannerWithImage.tsx +++ b/met-web/src/components/banner/BannerWithImage.tsx @@ -17,6 +17,7 @@ const BannerWithImage = ({ height, imageUrl, children }: BannerProps) => { height: height ? height : '38em', width: '100%', position: 'relative', + overflow: 'clip', }} > {`One or many of your comments can't be published because of (${otherReason}).`} + >{`One or many of your comments can't be published because of ${otherReason}.`} diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index c5c8d1f75..3b1b428db 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -215,8 +215,8 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { setSaving(true); try { const uploadedBannerImageFileName = await handleUploadBannerImage(); - const state = { ...savedEngagement }; - const engagementEditsToPatch = updatedDiff(state, { + + const engagementEditsToPatch = updatedDiff(savedEngagement, { ...engagement, banner_filename: uploadedBannerImageFileName, }) as PatchEngagementRequest; @@ -229,6 +229,9 @@ export const ActionProvider = ({ children }: { children: JSX.Element }) => { const updatedEngagement = await patchEngagement({ ...engagementEditsToPatch, id: Number(engagementId), + status_block: engagement.status_block?.filter((_, index) => { + return engagementEditsToPatch.status_block?.[index]; + }), }); setEngagement(updatedEngagement); dispatch(openNotification({ severity: 'success', text: 'Engagement Updated Successfully' })); diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentForm.tsx index 6e86ba413..c1390f397 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentForm.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentForm.tsx @@ -1,36 +1,41 @@ import React, { useContext } from 'react'; import { Divider, Grid } from '@mui/material'; -import { MetHeader3, PrimaryButton } from 'components/common'; +import { PrimaryButton } from 'components/common'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; import CreateFolderForm from './CreateFolderForm'; import DocumentsBlock from './DocumentsBlock'; +import { WidgetTitle } from '../WidgetTitle'; +import { DocumentsContext } from './DocumentsContext'; const DocumentForm = () => { const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const { widget } = useContext(DocumentsContext); + + if (!widget) { + return null; + } return ( - <> - - - Documents - - + + + + + - - - + + + - - - + + + - - - handleWidgetDrawerOpen(false)}>{`Close`} - + + + handleWidgetDrawerOpen(false)}>{`Close`} - + ); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentOptionCard.tsx index e5f049e1b..578470ce4 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Documents/DocumentOptionCard.tsx @@ -12,6 +12,7 @@ import { openNotification } from 'services/notificationService/notificationSlice import { optionCardStyle } from '../constants'; import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; +const Title = 'Documents'; const DocumentOptionCard = () => { const { widgets, loadWidgets, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); const { savedEngagement } = useContext(ActionContext); @@ -31,6 +32,7 @@ const DocumentOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Document, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -82,7 +84,7 @@ const DocumentOptionCard = () => { xs={8} > - Documents + {Title} Add documents and folders diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Events/EventsOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Events/EventsOptionCard.tsx index 3d95b543a..4c4690c44 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Events/EventsOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Events/EventsOptionCard.tsx @@ -12,6 +12,7 @@ import { optionCardStyle } from '../constants'; import { WidgetTabValues } from '../type'; import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; +const Title = 'Events'; const EventsOptionCard = () => { const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -32,6 +33,7 @@ const EventsOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Events, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -83,7 +85,7 @@ const EventsOptionCard = () => { xs={8} > - Events + {Title} diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Events/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Events/Form.tsx index 8ae4deda1..14a976b6e 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Events/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Events/Form.tsx @@ -1,18 +1,23 @@ import React, { useContext } from 'react'; import { Grid, Divider } from '@mui/material'; -import { PrimaryButton, MetHeader3, WidgetButton } from 'components/common'; +import { PrimaryButton, WidgetButton } from 'components/common'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { EventsContext } from './EventsContext'; import EventsInfoBlock from './EventsInfoBlock'; +import { WidgetTitle } from '../WidgetTitle'; const Form = () => { const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); - const { setInPersonFormTabOpen, setVirtualSessionFormTabOpen } = useContext(EventsContext); + const { setInPersonFormTabOpen, setVirtualSessionFormTabOpen, widget } = useContext(EventsContext); + + if (!widget) { + return null; + } return ( - Events + diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Map/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Map/Form.tsx index f4e56711f..e70054147 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Map/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Map/Form.tsx @@ -2,7 +2,6 @@ import React, { useContext, useState, useEffect } from 'react'; import Divider from '@mui/material/Divider'; import { Grid, Typography, Stack, IconButton } from '@mui/material'; import { - MetHeader3, MetLabel, PrimaryButton, SecondaryButton, @@ -26,6 +25,7 @@ import LinkIcon from '@mui/icons-material/Link'; import { When } from 'react-if'; import HighlightOffIcon from '@mui/icons-material/HighlightOff'; import * as turf from '@turf/turf'; +import { WidgetTitle } from '../WidgetTitle'; const schema = yup .object({ @@ -163,10 +163,14 @@ const Form = () => { ); } + if (!widget) { + return null; + } + return ( - Map + diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Map/MapOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Map/MapOptionCard.tsx index 3d906580c..6e34da68b 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Map/MapOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Map/MapOptionCard.tsx @@ -12,6 +12,7 @@ import { WidgetTabValues } from '../type'; import LocationOnIcon from '@mui/icons-material/LocationOn'; import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; +const Title = 'Map'; const MapOptionCard = () => { const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -32,6 +33,7 @@ const MapOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Map, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -83,7 +85,7 @@ const MapOptionCard = () => { xs={8} > - Map + {Title} Add a map with the project location diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesForm.tsx index 78dbe3208..170696b46 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesForm.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesForm.tsx @@ -1,12 +1,13 @@ import React, { useContext, useEffect, useState } from 'react'; import { Autocomplete, Checkbox, Divider, FormControl, FormControlLabel, Grid, TextField } from '@mui/material'; -import { MetHeader3, MetLabel, PrimaryButton, SecondaryButton } from 'components/common'; +import { MetLabel, PrimaryButton, SecondaryButton } from 'components/common'; import { EngagementPhases } from 'models/engagementPhases'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; -import { postWidgetItem } from 'services/widgetService'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { WidgetType } from 'models/widget'; +import { useCreateWidgetItemsMutation } from 'apiManager/apiSlices/widgets'; +import { WidgetTitle } from '../WidgetTitle'; interface ISelectOptions { id: EngagementPhases; @@ -21,6 +22,8 @@ const PhasesForm = () => { const [savingWidgetItems, setSavingWidgetItems] = useState(false); const widget = widgets.filter((widget) => widget.widget_type_id === WidgetType.Phases)[0] || null; + const [createWidgetItems] = useCreateWidgetItemsMutation(); + useEffect(() => { if (widget && widget.items.length > 0) { setSelectedOption(options.find((o) => o.id === widget.items[0].widget_data_id) || null); @@ -66,7 +69,7 @@ const PhasesForm = () => { }; try { setSavingWidgetItems(true); - await postWidgetItem(widget.id, widgetsToUpdate); + await createWidgetItems({ widget_id: widget.id, widget_items_data: [widgetsToUpdate] }).unwrap(); await loadWidgets(); dispatch(openNotification({ severity: 'success', text: 'Widget successfully added' })); handleWidgetDrawerOpen(false); @@ -80,72 +83,68 @@ const PhasesForm = () => { }; return ( - <> - + + + + + + - The EA Process - + Engagement Phase + ( + + )} + isOptionEqualToValue={(option: ISelectOptions, value: ISelectOptions) => option.id == value.id} + getOptionLabel={(option: ISelectOptions) => option.label} + onChange={(_e: React.SyntheticEvent, option: ISelectOptions | null) => { + setSelectedOption(option); + setIsStandalone(false); + }} + /> - - - Engagement Phase - ( - + + { + setSelectedOption(null); + setIsStandalone(checked); }} /> - )} - isOptionEqualToValue={(option: ISelectOptions, value: ISelectOptions) => - option.id == value.id } - getOptionLabel={(option: ISelectOptions) => option.label} - onChange={(_e: React.SyntheticEvent, option: ISelectOptions | null) => { - setSelectedOption(option); - setIsStandalone(false); - }} + label="This engagement is a stand-alone engagement" /> - - - - { - setSelectedOption(null); - setIsStandalone(checked); - }} - /> - } - label="This engagement is a stand-alone engagement" - /> - - + + + + + + saveWidgetItem()} + >{`Save & Close`} - - - saveWidgetItem()} - >{`Save & Close`} - - - handleWidgetDrawerOpen(false)}>{`Cancel`} - + + handleWidgetDrawerOpen(false)}>{`Cancel`} - + ); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesOptionCard.tsx index d0d6c3487..256ae2245 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Phases/PhasesOptionCard.tsx @@ -12,6 +12,7 @@ import { Else, If, Then } from 'react-if'; import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined'; import { optionCardStyle } from '../constants'; +const Title = 'Environmental Assessment Process'; const PhasesOptionCard = () => { const { savedEngagement } = useContext(ActionContext); const { widgets, loadWidgets, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -31,6 +32,7 @@ const PhasesOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Phases, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -82,7 +84,7 @@ const PhasesOptionCard = () => { xs={8} > - Environmental Assessment Process + {Title} diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/EmailListFormDrawer.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/EmailListFormDrawer.tsx index 861fb2b10..8203f7cd8 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/EmailListFormDrawer.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/EmailListFormDrawer.tsx @@ -14,11 +14,10 @@ import { Subscribe_TYPE, SubscribeForm } from 'models/subscription'; import RichTextEditor from 'components/common/RichTextEditor'; import { openNotification } from 'services/notificationService/notificationSlice'; import { getTextFromDraftJsContentState } from 'components/common/RichTextEditor/utils'; -import { postSubscribeForm } from 'services/subscriptionService'; +import { patchSubscribeForm, postSubscribeForm, PatchSubscribeProps } from 'services/subscriptionService'; const schema = yup .object({ - description: yup.string(), call_to_action_type: yup.string(), call_to_action_text: yup .string() @@ -39,23 +38,54 @@ const EmailListDrawer = () => { richEmailListDescription, setRichEmailListDescription, setSubscribe, + subscribeToEdit, + loadSubscribe, } = useContext(SubscribeContext); const [isCreating, setIsCreating] = useState(false); const [initialRichDescription, setInitialRichDescription] = useState(''); + const subscribeItem = subscribeToEdit ? subscribeToEdit.subscribe_items[0] : null; const dispatch = useAppDispatch(); const methods = useForm({ resolver: yupResolver(schema), }); useEffect(() => { - methods.setValue('description', ''); methods.setValue('call_to_action_type', 'link'); methods.setValue('call_to_action_text', 'Click here to sign up'); setInitialRichDescription(richEmailListDescription); }, []); + useEffect(() => { + methods.setValue('call_to_action_type', subscribeItem ? subscribeItem.call_to_action_type : 'link'); + methods.setValue( + 'call_to_action_text', + subscribeItem ? subscribeItem.call_to_action_text : 'Click here to sign up', + ); + setInitialRichDescription(subscribeItem ? subscribeItem.description : richEmailListDescription); + }, [subscribeToEdit]); + const { handleSubmit } = methods; + const updateEmailListForm = async (data: EmailList) => { + const validatedData = await schema.validate(data); + const { call_to_action_type, call_to_action_text } = validatedData; + if (subscribeToEdit && subscribeItem && widget) { + const subscribeUpdatesToPatch = { + description: richEmailListDescription, + call_to_action_type: call_to_action_type, + call_to_action_text: call_to_action_text, + } as PatchSubscribeProps; + + await patchSubscribeForm(widget.id, subscribeToEdit.id, subscribeItem.id, { + ...subscribeUpdatesToPatch, + }); + + loadSubscribe(); + + dispatch(openNotification({ severity: 'success', text: 'EmailListForm was successfully updated' })); + } + }; + const createEmailListForm = async (data: EmailList) => { const validatedData = await schema.validate(data); const { call_to_action_type, call_to_action_text } = validatedData; @@ -73,12 +103,18 @@ const EmailListDrawer = () => { ], }); - setSubscribe((prevWidgetForms: SubscribeForm[]) => [...prevWidgetForms, createdWidgetForm]); + setSubscribe((prevWidgetForms: SubscribeForm[]) => { + const filteredForms = prevWidgetForms.filter((form) => form.type !== Subscribe_TYPE.EMAIL_LIST); + return [...filteredForms, createdWidgetForm]; + }); } dispatch(openNotification({ severity: 'success', text: 'Email list form was successfully created' })); }; const saveForm = async (data: EmailList) => { + if (subscribeToEdit) { + return updateEmailListForm(data); + } return createEmailListForm(data); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeContext.tsx index ceaf9b0bb..ca9fdf22e 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeContext.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useEffect, useState } from 'react'; import { useAppDispatch } from 'hooks'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { Widget, WidgetType } from 'models/widget'; -import { Subscribe, Subscribe_TYPE, SubscribeTypeLabel, SubscribeForm } from 'models/subscription'; +import { Subscribe_TYPE, SubscribeTypeLabel, SubscribeForm } from 'models/subscription'; import { getSubscriptionsForms, sortWidgetSubscribeForms } from 'services/subscriptionService'; import { openNotification } from 'services/notificationService/notificationSlice'; @@ -19,7 +19,7 @@ export interface SubscribeContextProps { setSubscribe: React.Dispatch>; setSubscribeToEdit: React.Dispatch>; handleSubscribeDrawerOpen: (_Subscribe: SubscribeTypeLabel, _open: boolean) => void; - updateWidgetSubscribeSorting: (widget_Subscribe: Subscribe[]) => void; + updateWidgetSubscribeSorting: (widget_Subscribe: SubscribeForm[]) => void; richEmailListDescription: string; setRichEmailListDescription: React.Dispatch>; richFormSignUpDescription: string; @@ -51,7 +51,7 @@ export const SubscribeContext = createContext({ handleSubscribeDrawerOpen: (_Subscribe: SubscribeTypeLabel, _open: boolean) => { /* empty default method */ }, - updateWidgetSubscribeSorting: (widget_Subscribe: Subscribe[]) => { + updateWidgetSubscribeSorting: (widget_Subscribe: SubscribeForm[]) => { /* empty default method */ }, richEmailListDescription: '', @@ -104,7 +104,7 @@ export const SubscribeProvider = ({ children }: { children: JSX.Element | JSX.El loadSubscribe(); }, [widget]); - const updateWidgetSubscribeSorting = async (resortedWidgetSubscribe: Subscribe[]) => { + const updateWidgetSubscribeSorting = async (resortedWidgetSubscribe: SubscribeForm[]) => { if (!widget) { return; } diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeForm.tsx index 022b095b8..464c13319 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeForm.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeForm.tsx @@ -1,53 +1,61 @@ import React, { useContext } from 'react'; -import { Grid, Divider, FormControlLabel, Checkbox } from '@mui/material'; -import { PrimaryButton, MetHeader3, WidgetButton, MetParagraph, MetLabel } from 'components/common'; +import { Grid, Divider } from '@mui/material'; +import { PrimaryButton, WidgetButton, MetParagraph } from 'components/common'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; -import BorderColorIcon from '@mui/icons-material/BorderColor'; import { SubscribeContext } from './SubscribeContext'; import { Subscribe_TYPE } from 'models/subscription'; +import { WidgetTitle } from '../WidgetTitle'; +import { When } from 'react-if'; +import SubscribeInfoBlock from './SubscribeInfoBlock'; const Form = () => { const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); - const { handleSubscribeDrawerOpen } = useContext(SubscribeContext); + const { handleSubscribeDrawerOpen, subscribe, widget } = useContext(SubscribeContext); + const subscribeFormExists = subscribe.length > 0; + + if (!widget) { + return null; + } return ( - - - Sign-up for updates - - - + - } label={Hide title} /> - - - - The email list will collect email addresses for a mailing list. A "double-opt-in" email will be - sent to confirm the subscription.Only the email addresses that have been double-opted-in will be - on the list. Please include the unsubscribe link provided on the Email List screen in every - future communication. Unsubscribed email addresses will be removed from the list. Please - downloaded the list before each communication. - - - - - The form sign-up will open the pre-defined form. The text and CTA for both are customizable. - - - - handleSubscribeDrawerOpen(Subscribe_TYPE.EMAIL_LIST, true)}> - Email List - - - - handleSubscribeDrawerOpen(Subscribe_TYPE.FORM, true)}> - Form Sign-up - + + + + + The email list will collect email addresses for a mailing list. A "double-opt-in" email will + be sent to confirm the subscription. Only the email addresses that have been double-opted-in + will be on the list. + + + + + The form sign-up will open the pre-defined form. The text and CTA for both are customizable. + + + + + handleSubscribeDrawerOpen(Subscribe_TYPE.EMAIL_LIST, true)}> + Email List + + + + handleSubscribeDrawerOpen(Subscribe_TYPE.FORM, true)}> + Form Sign-up + + + + + + + + { + const { subscribe, setSubscribe, isLoadingSubscribe, updateWidgetSubscribeSorting, widget } = + useContext(SubscribeContext); + const dispatch = useAppDispatch(); + const debounceUpdateWidgetSubscribeSorting = useRef( + debounce((widgetSubscribeToSort: SubscribeForm[]) => { + updateWidgetSubscribeSorting(widgetSubscribeToSort); + }, 800), + ).current; + + const moveSubscribeForm = (result: DropResult) => { + if (!result.destination) { + return; + } + + const items = reorder(subscribe, result.source.index, result.destination.index); + + setSubscribe(items); + + debounceUpdateWidgetSubscribeSorting(items); + }; + + if (isLoadingSubscribe) { + return ( + + + + + + ); + } + + const handleRemoveSubscribeForm = (subscribeFormId: number) => { + dispatch( + openNotificationModal({ + open: true, + data: { + header: 'Remove SubscribeForm', + subText: [ + { + text: 'You will be removing this subscribeForm from the engagement.', + }, + { + text: 'Do you want to remove this subscribeForm?', + }, + ], + handleConfirm: () => { + removeSubscribeForm(subscribeFormId); + }, + }, + type: 'confirm', + }), + ); + }; + + const removeSubscribeForm = async (subscribeFormId: number) => { + try { + if (widget) { + await deleteSubscribeForm(widget.id, subscribeFormId); + const newSubscribe = subscribe.filter((subscribeForm) => subscribeForm.id !== subscribeFormId); + setSubscribe([...newSubscribe]); + dispatch(openNotification({ severity: 'success', text: 'The subscribeForm was removed successfully' })); + } + } catch (error) { + dispatch( + openNotification({ severity: 'error', text: 'An error occurred while trying to remove subscribeForm' }), + ); + } + }; + + return ( + + + + {subscribe.map((subscribeForm: SubscribeForm, index) => { + return ( + + + + + + + + + + + ); + })} + + + + ); +}; + +export default SubscribeInfoBlock; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeInfoPaper.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeInfoPaper.tsx new file mode 100644 index 000000000..be9727e98 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeInfoPaper.tsx @@ -0,0 +1,108 @@ +import React, { useContext } from 'react'; +import { MetParagraph, MetWidgetPaper } from 'components/common'; +import { Grid, IconButton } from '@mui/material'; +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import HighlightOffIcon from '@mui/icons-material/HighlightOff'; +import EditIcon from '@mui/icons-material/Edit'; +import { When } from 'react-if'; +import { SubscribeForm } from 'models/subscription'; +import { SubscribeContext } from './SubscribeContext'; +import { Editor } from 'react-draft-wysiwyg'; +import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; +import { styled } from '@mui/system'; + +const EditorGrid = styled(Grid)` + padding-top: 0px !important; +`; +export interface SubscribeInfoPaperProps { + subscribeForm: SubscribeForm; + removeSubscribeForm: (_subscribeId: number) => void; +} + +const SubscribeInfoPaper = ({ subscribeForm, removeSubscribeForm, ...rest }: SubscribeInfoPaperProps) => { + const subscribeItem = subscribeForm.subscribe_items[0]; + const { setSubscribeToEdit, handleSubscribeDrawerOpen } = useContext(SubscribeContext); + + function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + return ( + + + + + + + + + + Email List + + + + Description: + + + + + + + + {capitalizeFirstLetter(subscribeItem.call_to_action_type)} + + + + {subscribeItem.call_to_action_text} + + + + + + + { + setSubscribeToEdit(subscribeForm); + handleSubscribeDrawerOpen(subscribeForm.type, true); + }} + /> + + + + removeSubscribeForm(subscribeForm.id)} + sx={{ padding: 1, margin: 0 }} + color="inherit" + aria-label="delete-icon" + > + + + + + + + ); +}; + +export default SubscribeInfoPaper; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeOptionCard.tsx index 8b5572b6f..5e63f540e 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Subscribe/SubscribeOptionCard.tsx @@ -12,6 +12,7 @@ import { optionCardStyle } from '../constants'; import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; import { WidgetTabValues } from '../type'; +const Title = 'Sign Up for Updates'; const SubscribeOptionCard = () => { const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -32,6 +33,7 @@ const SubscribeOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Subscribe, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -85,7 +87,7 @@ const SubscribeOptionCard = () => { xs={8} > - Sign Up for Updates + {Title} Offer members of the public to sign up for updates diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx index ebc9b8bef..46f37b0c8 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx @@ -1,14 +1,7 @@ import React, { useContext, useEffect } from 'react'; import Divider from '@mui/material/Divider'; import { Grid } from '@mui/material'; -import { - MetDescription, - MetHeader3, - MetLabel, - MidScreenLoader, - PrimaryButton, - SecondaryButton, -} from 'components/common'; +import { MetDescription, MetLabel, MidScreenLoader, PrimaryButton, SecondaryButton } from 'components/common'; import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; @@ -19,6 +12,7 @@ import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { VideoContext } from './VideoContext'; import { patchVideo, postVideo } from 'services/widgetService/VideoService'; import { updatedDiff } from 'deep-object-diff'; +import { WidgetTitle } from '../WidgetTitle'; const schema = yup .object({ @@ -120,7 +114,7 @@ const Form = () => { } }; - if (isLoadingVideoWidget) { + if (isLoadingVideoWidget || !widget) { return ( @@ -133,7 +127,7 @@ const Form = () => { return ( - Video + @@ -163,7 +157,7 @@ const Form = () => { Video Link - The video must be hosted on one of the following platforms: + The video must be hosted on one of the following platforms: YouTube, Vimeo { const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -32,6 +33,7 @@ const VideoOptionCard = () => { await createWidget({ widget_type_id: WidgetType.Video, engagement_id: savedEngagement.id, + title: Title, }).unwrap(); await loadWidgets(); dispatch( @@ -83,7 +85,7 @@ const VideoOptionCard = () => { xs={8} > - Video + {Title} Add a link to a hosted video and link preview 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 69ce214c2..1ddc546fd 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/AddContactDrawer.tsx @@ -163,7 +163,7 @@ const AddContactDrawer = () => { handleAddFile={handleAddAvatarImage} savedImageUrl={contactToEdit?.avatar_url} savedImageName={contactToEdit?.avatar_filename} - helpText="Drag and drop an image here or click to select one" + helpText={'Drop an image here or click to select one.'} /> 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 677f6f932..de612e9a1 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningForm.tsx @@ -1,14 +1,15 @@ import React, { useContext, useState, useEffect } from 'react'; import { Autocomplete, Grid, TextField, Divider } from '@mui/material'; -import { MetLabel, PrimaryButton, SecondaryButton, MetHeader3 } from 'components/common'; +import { MetLabel, PrimaryButton, SecondaryButton } from 'components/common'; import { Contact } from 'models/contact'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; -import { postWidgetItems } from 'services/widgetService'; import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { WidgetType } from 'models/widget'; import ContactBlock from './ContactBlock'; import { WhoIsListeningContext } from './WhoIsListeningContext'; +import { useCreateWidgetItemsMutation } from 'apiManager/apiSlices/widgets'; +import { WidgetTitle } from '../WidgetTitle'; const WhoIsListeningForm = () => { const { handleWidgetDrawerOpen, widgets, loadWidgets } = useContext(WidgetDrawerContext); @@ -17,6 +18,7 @@ const WhoIsListeningForm = () => { const dispatch = useAppDispatch(); const [selectedContact, setSelectedContact] = useState(null); const [savingWidgetItems, setSavingWidgetItems] = useState(false); + const [createWidgetItems] = useCreateWidgetItemsMutation(); const widget = widgets.filter((widget) => widget.widget_type_id === WidgetType.WhoIsListening)[0] || null; useEffect(() => { @@ -68,7 +70,7 @@ const WhoIsListeningForm = () => { }); try { setSavingWidgetItems(true); - await postWidgetItems(widget.id, widgetsToUpdate); + await createWidgetItems({ widget_id: widget.id, widget_items_data: widgetsToUpdate }).unwrap(); await loadWidgets(); dispatch(openNotification({ severity: 'success', text: 'Widgets successfully added' })); handleWidgetDrawerOpen(false); @@ -86,7 +88,7 @@ const WhoIsListeningForm = () => { <> - Who is Listening + diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningOptionCard.tsx index 68da307b1..712a0d431 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningOptionCard.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WhoIsListening/WhoIsListeningOptionCard.tsx @@ -12,6 +12,7 @@ import PeopleAltOutlinedIcon from '@mui/icons-material/PeopleAltOutlined'; import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; import { optionCardStyle } from '../constants'; +const Title = 'Who is Listening'; const WhoIsListeningOptionCard = () => { const { savedEngagement } = useContext(ActionContext); const { widgets, loadWidgets, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); @@ -31,6 +32,7 @@ const WhoIsListeningOptionCard = () => { await createWidget({ widget_type_id: WidgetType.WhoIsListening, engagement_id: savedEngagement.id, + title: Title, }); await loadWidgets(); dispatch( @@ -83,7 +85,7 @@ const WhoIsListeningOptionCard = () => { xs={8} > - Who is Listening + {Title} Add contacts to this engagement diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx index 8dcef7729..43f6af81f 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx @@ -19,7 +19,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -32,7 +32,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -45,7 +45,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -58,7 +58,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -71,7 +71,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -84,7 +84,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} @@ -97,7 +97,7 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps { removeWidget(widget.id); }} diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerContext.tsx index 616f0861a..249b5fe28 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerContext.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerContext.tsx @@ -9,6 +9,7 @@ import { useDeleteWidgetMutation, useSortWidgetsMutation } from 'apiManager/apiS export interface WidgetDrawerContextProps { widgets: Widget[]; + setWidgets: React.Dispatch>; widgetDrawerOpen: boolean; handleWidgetDrawerOpen: (_open: boolean) => void; widgetDrawerTabValue: string; @@ -25,6 +26,9 @@ export type EngagementParams = { export const WidgetDrawerContext = createContext({ widgets: [], + setWidgets: () => { + return; + }, isWidgetsLoading: false, widgetDrawerOpen: false, handleWidgetDrawerOpen: (_open: boolean) => { @@ -103,6 +107,7 @@ export const WidgetDrawerProvider = ({ children }: { children: JSX.Element | JSX { + const [editing, setEditing] = React.useState(false); + const [title, setTitle] = React.useState(widget.title); + const [updateWidget] = useUpdateWidgetMutation(); + const dispatch = useAppDispatch(); + const { setWidgets } = useContext(WidgetDrawerContext); + const [isSaving, setIsSaving] = React.useState(false); + + const saveTitle = async () => { + if (title === widget.title) { + setEditing(false); + return; + } + try { + setIsSaving(true); + const response = await updateWidget({ + id: widget.id, + engagementId: widget.engagement_id, + data: { + title, + }, + }).unwrap(); + setWidgets((prevWidgets) => { + const updatedWidget = prevWidgets.find((prevWidget) => prevWidget.id === widget.id); + if (updatedWidget) { + updatedWidget.title = response?.title || ''; + } + return [...prevWidgets]; + }); + dispatch(openNotification({ severity: 'success', text: 'Widget title successfully updated' })); + setIsSaving(false); + setEditing(false); + } catch (error) { + setIsSaving(false); + dispatch(openNotification({ severity: 'error', text: 'Error occurred while updating widget title' })); + } + }; + + const handleTitleChange = (text: string) => { + if (!text) { + return; + } + + setTitle(text); + }; + + return ( + + + + handleTitleChange(e.target.value)} + inputProps={{ maxLength: 100 }} + fullWidth + /> + + + + + + { + saveTitle(); + }} + > + + + + + + + + + {widget.title} + { + setEditing(true); + }} + > + + + + + + ); +}; diff --git a/met-web/src/components/engagement/view/EngagementInfoSection.tsx b/met-web/src/components/engagement/view/EngagementInfoSection.tsx index 2877e4d43..a7b3eedf0 100644 --- a/met-web/src/components/engagement/view/EngagementInfoSection.tsx +++ b/met-web/src/components/engagement/view/EngagementInfoSection.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; -import { Grid, Typography, Stack } from '@mui/material'; -import { MetHeader1 } from 'components/common'; +import { Grid, Stack } from '@mui/material'; +import { MetHeader1, MetLabel } from 'components/common'; import { EngagementStatusChip } from '../status'; import { Editor } from 'react-draft-wysiwyg'; import dayjs from 'dayjs'; @@ -51,26 +51,22 @@ const EngagementInfoSection = ({ savedEngagement, children }: EngagementInfoSect backgroundColor: 'rgba(242, 242, 242, 0.95)', padding: '1em', margin: '1em', - maxWidth: '90%', }} m={{ lg: '3em 5em 0 3em', md: '3em', sm: '1em' }} + spacing={1} > {name} - + - - {EngagementDate} - + {EngagementDate} - + - - Status: - + Status: diff --git a/met-web/src/components/engagement/view/widgets/DocumentWidget.tsx b/met-web/src/components/engagement/view/widgets/DocumentWidget.tsx index 3b281a785..d23cdbf2c 100644 --- a/met-web/src/components/engagement/view/widgets/DocumentWidget.tsx +++ b/met-web/src/components/engagement/view/widgets/DocumentWidget.tsx @@ -50,7 +50,7 @@ const DocumentWidget = ({ widget }: DocumentWidgetProps) => { <> - Documents + {widget.title} {documents.map((document: DocumentItem) => { diff --git a/met-web/src/components/engagement/view/widgets/Events/EventsWidget.tsx b/met-web/src/components/engagement/view/widgets/Events/EventsWidget.tsx index ac9875124..f126705f2 100644 --- a/met-web/src/components/engagement/view/widgets/Events/EventsWidget.tsx +++ b/met-web/src/components/engagement/view/widgets/Events/EventsWidget.tsx @@ -73,7 +73,7 @@ const EventsWidget = ({ widget }: EventsWidgetProps) => { xs={12} paddingBottom={0} > - Events + {widget.title} {events.map((event: Event) => { diff --git a/met-web/src/components/engagement/view/widgets/Map/MapWidget.tsx b/met-web/src/components/engagement/view/widgets/Map/MapWidget.tsx index cf9545994..8d21e90ff 100644 --- a/met-web/src/components/engagement/view/widgets/Map/MapWidget.tsx +++ b/met-web/src/components/engagement/view/widgets/Map/MapWidget.tsx @@ -79,7 +79,7 @@ const MapWidget = ({ widget }: MapWidgetProps) => { xs={12} paddingBottom={0} > - Map + {widget.title} diff --git a/met-web/src/components/engagement/view/widgets/PhasesWidget/PhasesWidgetMobile/PhasesWidgetMobile.tsx b/met-web/src/components/engagement/view/widgets/PhasesWidget/PhasesWidgetMobile/PhasesWidgetMobile.tsx index e6ae67fdb..8217f4613 100644 --- a/met-web/src/components/engagement/view/widgets/PhasesWidget/PhasesWidgetMobile/PhasesWidgetMobile.tsx +++ b/met-web/src/components/engagement/view/widgets/PhasesWidget/PhasesWidgetMobile/PhasesWidgetMobile.tsx @@ -65,7 +65,7 @@ export const PhasesWidgetMobile = () => { - The EA Process + {phasesWidget.title} diff --git a/met-web/src/components/engagement/view/widgets/PhasesWidget/index.tsx b/met-web/src/components/engagement/view/widgets/PhasesWidget/index.tsx index 6a07a999a..1a22c53bf 100644 --- a/met-web/src/components/engagement/view/widgets/PhasesWidget/index.tsx +++ b/met-web/src/components/engagement/view/widgets/PhasesWidget/index.tsx @@ -45,7 +45,7 @@ export const PhasesWidget = () => { - The Environmental Assessment Process + {phasesWidget.title} diff --git a/met-web/src/components/engagement/view/widgets/Subscribe/SubscribeWidget.tsx b/met-web/src/components/engagement/view/widgets/Subscribe/SubscribeWidget.tsx index c11cb0eb7..a49ff8cc6 100644 --- a/met-web/src/components/engagement/view/widgets/Subscribe/SubscribeWidget.tsx +++ b/met-web/src/components/engagement/view/widgets/Subscribe/SubscribeWidget.tsx @@ -1,7 +1,7 @@ -import React, { useState, useContext } from 'react'; +import React, { useEffect, useState, useContext } from 'react'; import { MetBody, MetHeader2, MetLabel, MetPaper, MetParagraph, PrimaryButton } from 'components/common'; import { ActionContext } from '../../ActionContext'; -import { Grid, Divider, Link, Typography, Box, RadioGroup, Radio, FormControlLabel } from '@mui/material'; +import { Grid, Divider, Link, Typography, Box, RadioGroup, Radio, FormControlLabel, Skeleton } from '@mui/material'; import { useAppDispatch } from 'hooks'; import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; import EmailModal from 'components/common/Modals/EmailModal'; @@ -9,15 +9,25 @@ import { createEmailVerification } from 'services/emailVerificationService'; import { createSubscription } from 'services/subscriptionService'; import { EmailVerificationType } from 'models/emailVerification'; import { SubscriptionType } from 'constants/subscriptionType'; +import { Widget } from 'models/widget'; +import { getSubscriptionsForms } from 'services/subscriptionService'; +import { WidgetType } from 'models/widget'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { Subscribe_TYPE, SubscribeForm, CallToActionType } from 'models/subscription'; +import { When } from 'react-if'; +import { getTextFromDraftJsContentState } from 'components/common/RichTextEditor/utils'; -function SubscribeWidget() { +const SubscribeWidget = ({ widget }: { widget: Widget }) => { const dispatch = useAppDispatch(); - const { savedEngagement, engagementMetadata } = useContext(ActionContext); + const { savedEngagement, engagementMetadata, widgets } = useContext(ActionContext); const defaultType = engagementMetadata.project_id ? SubscriptionType.PROJECT : SubscriptionType.ENGAGEMENT; const [email, setEmail] = useState(''); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const [subscriptionType, setSubscriptionType] = useState(''); + const subscribeWidget = widgets.find((widget) => widget.widget_type_id === WidgetType.Subscribe); + const [subscribeItems, setSubscribeItems] = useState([]); + const [isLoadingSubscribeItems, setIsLoadingSubscribeItems] = useState(true); const sendEmail = async () => { try { @@ -89,10 +99,41 @@ function SubscribeWidget() { } }; + const loadSubscribeItems = async () => { + if (!subscribeWidget) { + return; + } + try { + setIsLoadingSubscribeItems(true); + const loadedSubscribe = await getSubscriptionsForms(subscribeWidget.id); + setSubscribeItems(loadedSubscribe); + setIsLoadingSubscribeItems(false); + } catch (error) { + dispatch( + openNotification({ + severity: 'error', + text: 'An error occurred while trying to load the Subscribe Items', + }), + ); + } + }; + + useEffect(() => { + loadSubscribeItems(); + }, [widgets]); + const handleSubscriptionChange = (type: string) => { setSubscriptionType(type); }; + if (isLoadingSubscribeItems) { + return ( + + + + ); + } + return ( } /> - - - Sign Up for Updates - - - - - If you are interested in getting updates on public engagements at the EAO, you can sign up - below: - - - - setOpen(true)} sx={{ width: '100%' }}> - Sign Up for Updates - - - + {subscribeItems?.map((item) => { + return ( + + + + {widget.title} + + + + {getTextFromDraftJsContentState(item.subscribe_items[0].description)} + + + + setOpen(true)} sx={{ width: '100%' }}> + {item.subscribe_items[0].call_to_action_text} + + + + setOpen(true)} sx={{ cursor: 'pointer' }}> + {item.subscribe_items[0].call_to_action_text} + + + + + + ); + })} ); -} +}; export default SubscribeWidget; diff --git a/met-web/src/components/engagement/view/widgets/Video/VideoWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Video/VideoWidgetView.tsx index c2e414e37..be4b87d5a 100644 --- a/met-web/src/components/engagement/view/widgets/Video/VideoWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Video/VideoWidgetView.tsx @@ -78,7 +78,7 @@ const VideoWidgetView = ({ widget }: VideoWidgetProps) => { xs={12} paddingBottom={0} > - Video + {widget.title} diff --git a/met-web/src/components/engagement/view/widgets/WhoIsListeningWidget.tsx b/met-web/src/components/engagement/view/widgets/WhoIsListeningWidget.tsx index 9196728e3..fc543c33a 100644 --- a/met-web/src/components/engagement/view/widgets/WhoIsListeningWidget.tsx +++ b/met-web/src/components/engagement/view/widgets/WhoIsListeningWidget.tsx @@ -73,7 +73,7 @@ const WhoIsListeningWidget = ({ widget }: WhoIsListeningWidgetProps) => { return ( - Who is Listening + {widget.title} {contacts.map((contact) => { diff --git a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx index ee2de56ea..0924ffa79 100644 --- a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx +++ b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx @@ -28,7 +28,7 @@ export const WidgetSwitch = ({ widget }: WidgetSwitchProps) => { - + diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx index 76eeb23bc..ea7534f56 100644 --- a/met-web/src/components/imageUpload/Uploader.tsx +++ b/met-web/src/components/imageUpload/Uploader.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useContext } from 'react'; import { Grid, Stack, Typography } from '@mui/material'; -import Dropzone from 'react-dropzone'; +import Dropzone, { Accept } from 'react-dropzone'; import { PrimaryButton, SecondaryButton } from 'components/common'; import { ImageUploadContext } from './imageUploadContext'; @@ -8,11 +8,13 @@ interface UploaderProps { margin?: number; helpText?: string; height?: string; + accept?: Accept; } const Uploader = ({ margin = 2, helpText = 'Drag and drop some files here, or click to select files', height = '10em', + accept = {}, }: UploaderProps) => { const { handleAddFile, @@ -88,6 +90,7 @@ const Uploader = ({ onClick={() => { setCropModalOpen(true); }} + size="small" > Crop @@ -99,11 +102,13 @@ const Uploader = ({ return ( { + if (acceptedFiles.length === 0) return; const createdObjectURL = URL.createObjectURL(acceptedFiles[0]); handleAddFile(acceptedFiles); setAddedImageFileUrl(createdObjectURL); setAddedImageFileName(acceptedFiles[0].name); }} + accept={accept} > {({ getRootProps, getInputProps }) => (
diff --git a/met-web/src/components/imageUpload/index.tsx b/met-web/src/components/imageUpload/index.tsx index b3821cf50..c4108ebb5 100644 --- a/met-web/src/components/imageUpload/index.tsx +++ b/met-web/src/components/imageUpload/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { CropModal } from './cropModal'; import { ImageUploadContextProvider } from './imageUploadContext'; import Uploader from './Uploader'; +import { Accept } from 'react-dropzone'; interface UploaderProps { margin?: number; @@ -11,15 +12,21 @@ interface UploaderProps { helpText?: string; height?: string; cropAspectRatio?: number; + accept?: Accept; } export const ImageUpload = ({ margin = 2, handleAddFile, savedImageUrl = '', savedImageName = '', - helpText = 'Drag and drop some files here, or click to select files', + helpText = 'Drag and drop an image here, or click to select an image from your device. Formats accepted are: .jpg, .jpeg, .png, .webp.', height = '10em', cropAspectRatio = 1, + accept = { + 'image/jpeg': [], + 'image/png': [], + 'image/webp': [], + }, }: UploaderProps) => { return ( - + ); diff --git a/met-web/src/components/landing/EngagementTile.tsx b/met-web/src/components/landing/EngagementTile.tsx index f360fd628..a28bba76a 100644 --- a/met-web/src/components/landing/EngagementTile.tsx +++ b/met-web/src/components/landing/EngagementTile.tsx @@ -123,7 +123,8 @@ const EngagementTile = ({ passedEngagement, engagementId }: EngagementTileProps) { + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); window.open(engagementUrl, '_blank'); }} > @@ -133,7 +134,8 @@ const EngagementTile = ({ passedEngagement, engagementId }: EngagementTileProps) { + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); window.open(engagementUrl, '_blank'); }} > diff --git a/met-web/src/components/layout/SideNav/SideNavElements.tsx b/met-web/src/components/layout/SideNav/SideNavElements.tsx index 2446538f8..7c7368cfe 100644 --- a/met-web/src/components/layout/SideNav/SideNavElements.tsx +++ b/met-web/src/components/layout/SideNav/SideNavElements.tsx @@ -14,15 +14,15 @@ export const Routes: Route[] = [ name: 'Engagements', path: '/engagements', base: '/engagements', - authenticated: true, - allowedRoles: [USER_ROLES.VIEW_ENGAGEMENT, USER_ROLES.VIEW_ASSIGNED_ENGAGEMENTS], + authenticated: false, + allowedRoles: [], }, { name: 'Surveys', path: '/surveys', base: '/surveys', - authenticated: true, - allowedRoles: [USER_ROLES.VIEW_SURVEYS], + authenticated: false, + allowedRoles: [], }, { name: 'User Management', diff --git a/met-web/src/components/publicDashboard/SubmissionTrend/SubmissionTrend.tsx b/met-web/src/components/publicDashboard/SubmissionTrend/SubmissionTrend.tsx index 0fa1d4590..2042cc4ec 100644 --- a/met-web/src/components/publicDashboard/SubmissionTrend/SubmissionTrend.tsx +++ b/met-web/src/components/publicDashboard/SubmissionTrend/SubmissionTrend.tsx @@ -173,7 +173,11 @@ const SubmissionTrend = ({ engagement, engagementIsLoading }: SubmissionTrendPro {...params} InputProps={{ ...params.InputProps, - endAdornment: , + endAdornment: isExtraSmall ? ( + <> + ) : ( + + ), }} /> )} @@ -199,7 +203,11 @@ const SubmissionTrend = ({ engagement, engagementIsLoading }: SubmissionTrendPro {...params} InputProps={{ ...params.InputProps, - endAdornment: , + endAdornment: isExtraSmall ? ( + <> + ) : ( + + ), }} /> )} diff --git a/met-web/src/components/survey/edit/FormWrapped.tsx b/met-web/src/components/survey/edit/FormWrapped.tsx index 9f727b171..e5ffd1873 100644 --- a/met-web/src/components/survey/edit/FormWrapped.tsx +++ b/met-web/src/components/survey/edit/FormWrapped.tsx @@ -1,6 +1,5 @@ import React, { useContext } from 'react'; -import { Grid, Link as MuiLink, Skeleton } from '@mui/material'; -import { Link } from 'react-router-dom'; +import { Grid, Skeleton } from '@mui/material'; import { Banner } from 'components/banner/Banner'; import { EditForm } from './EditForm'; import { ActionContext } from './ActionContext'; @@ -36,11 +35,6 @@ const FormWrapped = () => { alignItems="flex-start" m={{ lg: '0 8em 1em 3em', md: '2em', xs: '1em' }} > - - - {`<< Return to ${savedEngagement.name} Engagement`} - - diff --git a/met-web/src/components/survey/submit/EngagementLink.tsx b/met-web/src/components/survey/submit/EngagementLink.tsx index 72bacd1ee..e50ed6902 100644 --- a/met-web/src/components/survey/submit/EngagementLink.tsx +++ b/met-web/src/components/survey/submit/EngagementLink.tsx @@ -9,7 +9,7 @@ import { openNotificationModal } from 'services/notificationModalService/notific export const EngagementLink = () => { const dispatch = useDispatch(); - const { savedEngagement, isEngagementLoading, slug } = useContext(ActionContext); + const { savedEngagement, isEngagementLoading } = useContext(ActionContext); const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); const navigate = useNavigate(); @@ -55,11 +55,6 @@ export const EngagementLink = () => { return ( <> - - - {`<< Return to ${savedEngagement.name} Engagement`} - - { } return ( - + ); diff --git a/met-web/src/models/subscription.ts b/met-web/src/models/subscription.ts index ff32b1bab..69d30eeef 100644 --- a/met-web/src/models/subscription.ts +++ b/met-web/src/models/subscription.ts @@ -1,5 +1,7 @@ export type SubscribeTypeLabel = 'EMAIL_LIST' | 'FORM'; +export type CallToActionTypes = 'link' | 'button'; + export interface Subscription { engagement_id: number; email_address: string; @@ -25,11 +27,30 @@ export const Subscribe_TYPE: { [x: string]: SubscribeTypeLabel } = { FORM: 'FORM', }; +export const CallToActionType: { [x: string]: CallToActionTypes } = { + LINK: 'link', + BUTTON: 'button', +}; + export interface SubscribeForm { + id: number; + title: string; + type: SubscribeTypeLabel; + sort_index: number; widget_id: number; + created_date: string; + updated_date: string; + subscribe_items: SubscribeFormItem[]; +} + +export interface SubscribeFormItem { + id: number; title?: string; - description?: string; - call_to_action_type?: string; - call_to_action_text?: string; + description: string; + call_to_action_type: 'link' | 'button'; + call_to_action_text: string; form_type: SubscribeTypeLabel; + created_date: string; + updated_date: string; + widget_subscribe: number; } diff --git a/met-web/src/models/widget.tsx b/met-web/src/models/widget.tsx index 9bbdcde90..403348ec5 100644 --- a/met-web/src/models/widget.tsx +++ b/met-web/src/models/widget.tsx @@ -10,6 +10,7 @@ export interface Widget { widget_type_id: number; engagement_id: number; items: WidgetItem[]; + title: string; } export enum WidgetType { diff --git a/met-web/src/services/subscriptionService/index.ts b/met-web/src/services/subscriptionService/index.ts index 6b811938e..afb213cc5 100644 --- a/met-web/src/services/subscriptionService/index.ts +++ b/met-web/src/services/subscriptionService/index.ts @@ -78,15 +78,9 @@ export const postSubscribeForm = async (widget_id: number, data: PostSubscribePr }; export interface PatchSubscribeProps { - widget_id: number; - title?: string; - type: SubscribeTypeLabel; - items: { - description?: string; - call_to_action_type?: string; - call_to_action_text?: string; - form_type: SubscribeTypeLabel; - }[]; + description?: string; + call_to_action_type?: string; + call_to_action_text?: string; } export const patchSubscribeForm = async ( @@ -133,7 +127,7 @@ export const deleteSubscribeForm = async (widget_id: number, subscribe_id: numbe } }; -export const sortWidgetSubscribeForms = async (widget_id: number, data: Subscribe[]): Promise => { +export const sortWidgetSubscribeForms = async (widget_id: number, data: SubscribeForm[]): Promise => { try { const url = replaceUrl(Endpoints.Subscription.SORT_FORMS, 'widget_id', String(widget_id)); const response = await http.PatchRequest(url, data); diff --git a/met-web/src/services/widgetService/DocumentService/index.tsx b/met-web/src/services/widgetService/DocumentService/index.tsx index a1843a6f3..75e612772 100644 --- a/met-web/src/services/widgetService/DocumentService/index.tsx +++ b/met-web/src/services/widgetService/DocumentService/index.tsx @@ -3,9 +3,6 @@ import { DocumentItem, DocumentType } from 'models/document'; import Endpoints from 'apiManager/endpoints'; import { replaceAllInURL, replaceUrl } from 'helper'; -/** - * @deprecated The method was replaced by Redux RTK query to have caching behaviour - */ export const fetchDocuments = async (widget_id: number): Promise => { try { const url = replaceUrl(Endpoints.Documents.GET_LIST, 'widget_id', String(widget_id)); diff --git a/met-web/src/services/widgetService/index.tsx b/met-web/src/services/widgetService/index.tsx index 4dd2988a2..7bff37665 100644 --- a/met-web/src/services/widgetService/index.tsx +++ b/met-web/src/services/widgetService/index.tsx @@ -13,6 +13,9 @@ interface PostWidget { widget_type_id: number; engagement_id: number; } +/** + * @deprecated The method was replaced by Redux RTK query to have caching behaviour + */ export const postWidget = async (engagement_id: number, data: PostWidget): Promise => { try { const url = replaceUrl(Endpoints.Widgets.CREATE, 'engagement_id', String(engagement_id)); @@ -30,11 +33,9 @@ interface PostWidgetItemRequest { widget_id: number; widget_data_id: number; } -export const postWidgetItem = async (widget_id: number, data: PostWidgetItemRequest): Promise => { - const result = await postWidgetItems(widget_id, [data]); - return result[0]; -}; - +/** + * @deprecated The method was replaced by Redux RTK query to have caching behaviour + */ export const postWidgetItems = async (widget_id: number, data: PostWidgetItemRequest[]): Promise => { try { const url = replaceUrl(Endpoints.Widget_items.CREATE, 'widget_id', String(widget_id)); @@ -48,6 +49,9 @@ export const postWidgetItems = async (widget_id: number, data: PostWidgetItemReq } }; +/** + * @deprecated The method was replaced by Redux RTK query to have caching behaviour + */ export const removeWidget = async (engagement_id: number, widget_id: number): Promise => { try { const url = replaceAllInURL({ @@ -67,6 +71,9 @@ export const removeWidget = async (engagement_id: number, widget_id: number): Pr } }; +/** + * @deprecated The method was replaced by Redux RTK query to have caching behaviour + */ export const sortWidgets = async (engagement_id: number, data: Widget[]): Promise => { try { const url = replaceUrl(Endpoints.Widgets.SORT, 'engagement_id', String(engagement_id)); diff --git a/met-web/tests/unit/components/engagement/engagement.test.tsx b/met-web/tests/unit/components/engagement/engagement.test.tsx index 686afaf62..f8aba194f 100644 --- a/met-web/tests/unit/components/engagement/engagement.test.tsx +++ b/met-web/tests/unit/components/engagement/engagement.test.tsx @@ -41,6 +41,7 @@ const widgetItem: WidgetItem = { const whoIsListeningWidget: Widget = { id: 1, + title: 'Who is Listening', widget_type_id: WidgetType.WhoIsListening, engagement_id: 1, items: [widgetItem], @@ -48,6 +49,7 @@ const whoIsListeningWidget: Widget = { const engagementPhasesWidget: Widget = { id: 2, + title: 'Engagement Phases', widget_type_id: WidgetType.Phases, engagement_id: 1, items: [], diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index 46310bb77..f7f9397c8 100644 --- a/met-web/tests/unit/components/factory.ts +++ b/met-web/tests/unit/components/factory.ts @@ -88,7 +88,7 @@ const mockEventItem: EventItem = { const mockEvent: Event = { id: 1, - title: 'Jace', + title: 'Events', type: 'OPENHOUSE', sort_index: 1, widget_id: 1, @@ -106,6 +106,7 @@ const eventWidgetItem: WidgetItem = { const eventWidget: Widget = { id: 1, + title: 'Events', widget_type_id: WidgetType.Events, engagement_id: 1, items: [eventWidgetItem], @@ -120,6 +121,7 @@ const mapWidgetItem: WidgetItem = { const mapWidget: Widget = { id: 1, + title: 'Map', widget_type_id: WidgetType.Map, engagement_id: 1, items: [mapWidgetItem], diff --git a/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx b/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx index 251e7d70c..d0a19b3fa 100644 --- a/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/DocumentWidget.test.tsx @@ -50,6 +50,7 @@ const mockFolder: DocumentItem = { const documentWidget: Widget = { id: 1, + title: 'Documents', widget_type_id: WidgetType.Document, engagement_id: 1, items: [], @@ -74,6 +75,7 @@ const mockCreateWidget = jest.fn(() => Promise.resolve(documentWidget)); jest.mock('apiManager/apiSlices/widgets', () => ({ ...jest.requireActual('apiManager/apiSlices/widgets'), useCreateWidgetMutation: () => [mockCreateWidget], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(documentWidget))], useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); @@ -135,6 +137,7 @@ describe('Document widget in engagement page tests', () => { expect(mockCreateWidget).toHaveBeenNthCalledWith(1, { widget_type_id: WidgetType.Document, engagement_id: engagement.id, + title: documentWidget.title, }); expect(getWidgetsMock).toHaveBeenCalledTimes(2); expect(screen.getByText('Create Folder')).toBeVisible(); diff --git a/met-web/tests/unit/components/widgets/EventsWidget.test.tsx b/met-web/tests/unit/components/widgets/EventsWidget.test.tsx index 2ac900cba..c51894ca4 100644 --- a/met-web/tests/unit/components/widgets/EventsWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/EventsWidget.test.tsx @@ -62,6 +62,7 @@ const mockCreateWidget = jest.fn(() => Promise.resolve(eventWidget)); jest.mock('apiManager/apiSlices/widgets', () => ({ ...jest.requireActual('apiManager/apiSlices/widgets'), useCreateWidgetMutation: () => [mockCreateWidget], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(eventWidget))], useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); @@ -118,6 +119,7 @@ describe('Event Widget tests', () => { expect(mockCreateWidget).toHaveBeenNthCalledWith(1, { widget_type_id: WidgetType.Events, engagement_id: draftEngagement.id, + title: mockEvent.title, }); expect(getWidgetsMock).toHaveBeenCalledTimes(2); expect(screen.getByText('Add In-Person Event')).toBeVisible(); diff --git a/met-web/tests/unit/components/widgets/MapWidget.test.tsx b/met-web/tests/unit/components/widgets/MapWidget.test.tsx index af79d1638..2d2dc1afe 100644 --- a/met-web/tests/unit/components/widgets/MapWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/MapWidget.test.tsx @@ -62,6 +62,7 @@ const mockCreateWidget = jest.fn(() => Promise.resolve(mapWidget)); jest.mock('apiManager/apiSlices/widgets', () => ({ ...jest.requireActual('apiManager/apiSlices/widgets'), useCreateWidgetMutation: () => [mockCreateWidget], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(mapWidget))], useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); @@ -118,6 +119,7 @@ describe('Map Widget tests', () => { expect(mockCreateWidget).toHaveBeenNthCalledWith(1, { widget_type_id: WidgetType.Map, engagement_id: draftEngagement.id, + title: mapWidget.title, }); expect(getWidgetsMock).toHaveBeenCalledTimes(2); expect(screen.getByText('Upload Shapefile')).toBeVisible(); diff --git a/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx b/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx index d853f11c6..f6976d1d5 100644 --- a/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/PhasesWidget.test.tsx @@ -30,6 +30,7 @@ const phaseWidgetItem: WidgetItem = { const phasesWidget: Widget = { id: 2, + title: 'Environmental Assessment Process', widget_type_id: WidgetType.Phases, engagement_id: 1, items: [], @@ -55,9 +56,17 @@ jest.mock('components/map', () => () => { }); const mockCreateWidget = jest.fn(() => Promise.resolve(phasesWidget)); +const mockCreateWidgetItems = jest.fn(() => Promise.resolve([phaseWidgetItem])); +const mockCreateWidgetItemsTrigger = jest.fn(() => { + return { + unwrap: mockCreateWidgetItems, + }; +}); jest.mock('apiManager/apiSlices/widgets', () => ({ ...jest.requireActual('apiManager/apiSlices/widgets'), useCreateWidgetMutation: () => [mockCreateWidget], + useCreateWidgetItemsMutation: () => [mockCreateWidgetItemsTrigger], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(phasesWidget))], useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); @@ -75,7 +84,7 @@ describe('Phases widget tests', () => { setupEnv(); }); - test('Phases widget is created when option is clicked', async () => { + test.only('Phases widget is created when option is clicked', async () => { useParamsMock.mockReturnValue({ engagementId: '1' }); getEngagementMock.mockReturnValueOnce( Promise.resolve({ @@ -84,9 +93,8 @@ describe('Phases widget tests', () => { }), ); getWidgetsMock.mockReturnValueOnce(Promise.resolve([])); - const postWidgetItemMock = jest.spyOn(widgetService, 'postWidgetItem'); mockCreateWidget.mockReturnValue(Promise.resolve(phasesWidget)); - postWidgetItemMock.mockReturnValue(Promise.resolve(phaseWidgetItem)); + mockCreateWidgetItems.mockReturnValue(Promise.resolve([phaseWidgetItem])); render(); await waitFor(() => { @@ -112,6 +120,7 @@ describe('Phases widget tests', () => { expect(mockCreateWidget).toHaveBeenNthCalledWith(1, { widget_type_id: WidgetType.Phases, engagement_id: draftEngagement.id, + title: phasesWidget.title, }); expect(getWidgetsMock).toHaveBeenCalledTimes(2); @@ -129,9 +138,14 @@ describe('Phases widget tests', () => { expect(saveWidgetButton).not.toBeVisible(); }); - expect(postWidgetItemMock).toHaveBeenNthCalledWith(1, phasesWidget.id, { + expect(mockCreateWidgetItemsTrigger).toHaveBeenNthCalledWith(1, { widget_id: phasesWidget.id, - widget_data_id: 0, + widget_items_data: [ + { + widget_id: phasesWidget.id, + widget_data_id: 0, + }, + ], }); }); }); diff --git a/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx b/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx index 7795d18c7..a64b51005 100644 --- a/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx +++ b/met-web/tests/unit/components/widgets/WhoIsListeningWidget.test.tsx @@ -44,6 +44,7 @@ const contactWidgetItem: WidgetItem = { const whoIsListeningWidget: Widget = { id: 1, + title: 'Who is Listening', widget_type_id: WidgetType.WhoIsListening, engagement_id: 1, items: [contactWidgetItem], @@ -104,9 +105,17 @@ jest.mock('@hello-pangea/dnd', () => ({ })); const mockCreateWidget = jest.fn(() => Promise.resolve(whoIsListeningWidget)); +const mockCreateWidgetItems = jest.fn(() => Promise.resolve(contactWidgetItem)); +const mockCreateWidgetItemsTrigger = jest.fn(() => { + return { + unwrap: mockCreateWidgetItems, + }; +}); jest.mock('apiManager/apiSlices/widgets', () => ({ ...jest.requireActual('apiManager/apiSlices/widgets'), useCreateWidgetMutation: () => [mockCreateWidget], + useCreateWidgetItemsMutation: () => [mockCreateWidgetItemsTrigger], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(whoIsListeningWidget))], useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], })); @@ -164,6 +173,7 @@ describe('Who is Listening widget tests', () => { expect(mockCreateWidget).toHaveBeenNthCalledWith(1, { widget_type_id: WidgetType.WhoIsListening, engagement_id: draftEngagement.id, + title: whoIsListeningWidget.title, }); expect(getWidgetsMock).toHaveBeenCalledTimes(2); expect(screen.getByText('Add This Contact')).toBeVisible();