diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 675e2e4d8..3b680d0e3 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -2,6 +2,11 @@ - **Task** Updated Babel Traverse library. [🎟️DESENG-474](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-474) - Run `npm audit fix` to update the vulnerable Babel traverse library. +## January 26, 2024 +- **Task** Poll Widget: Front-end. [🎟️DESENG-464](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-464) + - Created UI for Poll Widget. + - Updated Poll widget API and unit tests. + ## January 25, 2024 - **Task** Resolve issue preventing met-web from deploying on the Dev OpenShift environment. [🎟️DESENG-469](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-469) - Remove Epic Engage-related links and update Keycloak link. diff --git a/met-api/src/met_api/resources/widget_poll.py b/met-api/src/met_api/resources/widget_poll.py index 02dab70ed..37fe30320 100644 --- a/met-api/src/met_api/resources/widget_poll.py +++ b/met-api/src/met_api/resources/widget_poll.py @@ -13,8 +13,9 @@ from met_api.utils.util import allowedorigins, cors_preflight from met_api.utils.ip_util import hash_ip -API = Namespace('widget_polls', description='Endpoints for Poll Widget Management') -INVALID_REQUEST_MESSAGE = 'Invalid request format' +API = Namespace( + 'widget_polls', description='Endpoints for Poll Widget Management' +) @cors_preflight('GET, POST') @@ -28,7 +29,10 @@ def get(widget_id): """Get poll widgets.""" try: widget_poll = WidgetPollService().get_polls_by_widget_id(widget_id) - return WidgetPollSchema().dump(widget_poll, many=True), HTTPStatus.OK + return ( + WidgetPollSchema().dump(widget_poll, many=True), + HTTPStatus.OK, + ) except BusinessException as err: return str(err), err.status_code @@ -39,22 +43,22 @@ def post(widget_id): """Create poll widget.""" try: request_json = request.get_json() - valid_format, errors = Polls.validate_request_format(request_json) + valid_format, errors = schema_utils.validate( + request_json, 'poll_widget' + ) if not valid_format: - return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST - widget_poll = WidgetPollService().create_poll(widget_id, request_json) + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) + + widget_poll = WidgetPollService().create_poll( + widget_id, request_json + ) return WidgetPollSchema().dump(widget_poll), HTTPStatus.OK except BusinessException as err: return str(err), err.status_code - @staticmethod - def validate_request_format(data): - """Validate response format.""" - valid_format, errors = schema_utils.validate(data, 'poll_widget') - if not valid_format: - errors = schema_utils.serialize(errors) - return valid_format, errors - @cors_preflight('PATCH') @API.route('/') @@ -68,23 +72,31 @@ def patch(widget_id, poll_widget_id): """Update poll widget.""" try: request_json = request.get_json() - valid_format, errors = Poll.validate_request_format(request_json) + valid_format, errors = schema_utils.validate( + request_json, 'poll_widget_update' + ) if not valid_format: - return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST - - widget_poll = WidgetPollService().update_poll(widget_id, poll_widget_id, request_json) + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) + # Check if the poll engagement is published + if WidgetPollService.is_poll_engagement_published(poll_widget_id): + # Define the keys to check in the request_json + keys_to_check = ['title', 'description', 'answers'] + if any(key in request_json for key in keys_to_check): + raise BusinessException( + error='Cannot update poll widget as the engagement is published', + status_code=HTTPStatus.BAD_REQUEST, + ) + + widget_poll = WidgetPollService().update_poll( + widget_id, poll_widget_id, request_json + ) return WidgetPollSchema().dump(widget_poll), HTTPStatus.OK except BusinessException as err: return str(err), err.status_code - @staticmethod - def validate_request_format(data): - """Validate request format.""" - valid_format, errors = schema_utils.validate(data, 'poll_widget_update') - if not valid_format: - errors = schema_utils.serialize(errors) - return valid_format, errors - @cors_preflight('POST') @API.route('//responses') @@ -94,58 +106,65 @@ class PollResponseRecord(Resource): @staticmethod @cross_origin(origins=allowedorigins()) def post(widget_id, poll_widget_id): + # pylint: disable=too-many-return-statements """Record a response for a given poll widget.""" try: - response_data = request.get_json() - valid_format, errors = PollResponseRecord.validate_request_format(response_data) + poll_response_data = request.get_json() + valid_format, errors = schema_utils.validate( + poll_response_data, 'poll_response' + ) if not valid_format: - return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST - - response_dict = PollResponseRecord.prepare_response_data(response_data, widget_id, poll_widget_id) - - if not PollResponseRecord.is_poll_active(poll_widget_id): - return {'message': 'Poll is not active'}, HTTPStatus.BAD_REQUEST - - if PollResponseRecord.is_poll_limit_exceeded(poll_widget_id, response_dict['participant_id']): - return {'message': 'Limit exceeded for this poll'}, HTTPStatus.FORBIDDEN - - return PollResponseRecord.record_poll_response(response_dict) + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) + + # Prepare poll request object + poll_response_dict = { + **poll_response_data, + 'poll_id': poll_widget_id, + 'widget_id': widget_id, + 'participant_id': hash_ip(request.remote_addr), + } + + # Check if poll active or not + if not WidgetPollService.is_poll_active(poll_widget_id): + raise BusinessException( + error='Poll is not active', + status_code=HTTPStatus.BAD_REQUEST, + ) + + # Check if engagement of this poll is published or not + if not WidgetPollService.is_poll_engagement_published( + poll_widget_id + ): + raise BusinessException( + error='Poll engagement is not published', + status_code=HTTPStatus.BAD_REQUEST, + ) + + # Check poll limit execeeded or not + if WidgetPollService.check_already_polled( + poll_widget_id, poll_response_dict['participant_id'], 10 + ): + raise BusinessException( + error='Limit exceeded for this poll', + status_code=HTTPStatus.BAD_REQUEST, + ) + + # Record poll response in database + poll_response = WidgetPollService.record_response( + poll_response_dict + ) + if poll_response.id: + return { + 'message': 'Response recorded successfully' + }, HTTPStatus.CREATED + + raise BusinessException( + error='Response failed to record', + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) except BusinessException as err: return err.error, err.status_code - - @staticmethod - def validate_request_format(data): - """Validate Request format.""" - valid_format, errors = schema_utils.validate(data, 'poll_response') - if not valid_format: - errors = schema_utils.serialize(errors) - return valid_format, errors - - @staticmethod - def prepare_response_data(data, widget_id, poll_widget_id): - """Prepare poll response object.""" - response_dict = dict(data) - response_dict['poll_id'] = poll_widget_id - response_dict['widget_id'] = widget_id - response_dict['participant_id'] = hash_ip(request.remote_addr) - return response_dict - - @staticmethod - def is_poll_active(poll_id): - """Check if poll active or not.""" - return WidgetPollService.is_poll_active(poll_id) - - @staticmethod - def is_poll_limit_exceeded(poll_id, participant_id): - """Check poll limit execeeded or not.""" - return WidgetPollService.check_already_polled(poll_id, participant_id, 10) - - @staticmethod - def record_poll_response(response_dict): - """Record poll respinse in database.""" - poll_response = WidgetPollService.record_response(response_dict) - if poll_response.id: - return {'message': 'Response recorded successfully'}, HTTPStatus.CREATED - - return {'message': 'Response failed to record'}, HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/schemas/schemas/poll_widget_update.json b/met-api/src/met_api/schemas/schemas/poll_widget_update.json index 780223bde..8567bcd63 100644 --- a/met-api/src/met_api/schemas/schemas/poll_widget_update.json +++ b/met-api/src/met_api/schemas/schemas/poll_widget_update.json @@ -28,7 +28,7 @@ ] } ], - "required": ["widget_id", "engagement_id"], + "required": [], "properties": { "title": { "$id": "#/properties/title", diff --git a/met-api/src/met_api/services/widget_poll_service.py b/met-api/src/met_api/services/widget_poll_service.py index 36161daf5..116732fa6 100644 --- a/met-api/src/met_api/services/widget_poll_service.py +++ b/met-api/src/met_api/services/widget_poll_service.py @@ -2,10 +2,13 @@ from http import HTTPStatus from sqlalchemy.exc import SQLAlchemyError + +from met_api.constants.engagement_status import Status as EngagementStatus from met_api.constants.membership_type import MembershipType from met_api.exceptions.business_exception import BusinessException from met_api.models.widget_poll import Poll as PollModel from met_api.services import authorization +from met_api.services.engagement_service import EngagementService from met_api.services.poll_answers_service import PollAnswerService from met_api.services.poll_response_service import PollResponseService from met_api.utils.roles import Role @@ -24,7 +27,9 @@ def get_poll_by_id(poll_id: int): """Get poll by poll ID.""" poll = PollModel.query.get(poll_id) if not poll: - raise BusinessException('Poll widget not found', HTTPStatus.NOT_FOUND) + raise BusinessException( + 'Poll widget not found', HTTPStatus.NOT_FOUND + ) return poll @staticmethod @@ -33,7 +38,9 @@ def create_poll(widget_id: int, poll_details: dict): try: eng_id = poll_details.get('engagement_id') WidgetPollService._check_authorization(eng_id) - return WidgetPollService._create_poll_model(widget_id, poll_details) + return WidgetPollService._create_poll_model( + widget_id, poll_details + ) except SQLAlchemyError as exc: raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc @@ -45,9 +52,13 @@ def update_poll(widget_id: int, poll_widget_id: int, poll_data: dict): WidgetPollService._check_authorization(widget_poll.engagement_id) if widget_poll.widget_id != widget_id: - raise BusinessException('Invalid widget ID', HTTPStatus.BAD_REQUEST) + raise BusinessException( + 'Invalid widget ID', HTTPStatus.BAD_REQUEST + ) - return WidgetPollService._update_poll_model(poll_widget_id, poll_data) + return WidgetPollService._update_poll_model( + poll_widget_id, poll_data + ) except SQLAlchemyError as exc: raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc @@ -77,6 +88,21 @@ def is_poll_active(poll_id: int) -> bool: except SQLAlchemyError as exc: raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + @staticmethod + def is_poll_engagement_published(poll_id: int) -> bool: + """Check if the poll is published.""" + try: + poll = WidgetPollService.get_poll_by_id(poll_id) + engagement = EngagementService().get_engagement(poll.engagement_id) + pub_val = EngagementStatus.Published.value + # Return False immediately if engagement is None + if engagement is None: + return False + # Check if the engagement's status matches the published value + return engagement.get('status_id') == pub_val + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc + @staticmethod def _create_poll_model(widget_id: int, poll_data: dict): """Private method to create poll model.""" @@ -94,12 +120,23 @@ def _update_poll_model(poll_id: int, poll_data: dict): @staticmethod def _check_authorization(engagement_id): """Check user authorization.""" - authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), - engagement_id=engagement_id) + authorization.check_auth( + one_of_roles=( + MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value, + ), + engagement_id=engagement_id, + ) @staticmethod def _handle_poll_answers(poll_id: int, poll_data: dict): """Handle poll answers creation and deletion.""" - PollAnswerService.delete_poll_answers(poll_id) - answers_data = poll_data.get('answers', []) - PollAnswerService.create_bulk_poll_answers(poll_id, answers_data) + try: + if 'answers' in poll_data and len(poll_data['answers']) > 0: + PollAnswerService.delete_poll_answers(poll_id) + answers_data = poll_data.get('answers', []) + PollAnswerService.create_bulk_poll_answers( + poll_id, answers_data + ) + except SQLAlchemyError as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc diff --git a/met-api/tests/unit/api/test_widget_poll.py b/met-api/tests/unit/api/test_widget_poll.py index 13ae08479..b38e5d8da 100644 --- a/met-api/tests/unit/api/test_widget_poll.py +++ b/met-api/tests/unit/api/test_widget_poll.py @@ -22,6 +22,7 @@ from faker import Faker +from met_api.constants.engagement_status import Status from met_api.utils.enums import ContentType from tests.utilities.factory_scenarios import TestJwtClaims, TestPollAnswerInfo, TestWidgetPollInfo from tests.utilities.factory_utils import ( @@ -74,8 +75,8 @@ def test_create_poll_widget(client, jwt, session, setup_admin_user_and_claims): 'answers': [ TestPollAnswerInfo.answer1.value, TestPollAnswerInfo.answer2.value, - TestPollAnswerInfo.answer3.value - ] + TestPollAnswerInfo.answer3.value, + ], } # Preparing data for POST request @@ -114,7 +115,7 @@ def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): _, claims = setup_admin_user_and_claims headers = factory_auth_header(jwt=jwt, claims=claims) - engagement = factory_engagement_model() + engagement = factory_engagement_model(status=Status.Draft.value) widget = factory_widget_model({'engagement_id': engagement.id}) poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) @@ -123,7 +124,7 @@ def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): 'title': 'Updated Title', 'engagement_id': engagement.id, 'widget_id': widget.id, - 'answers': [TestPollAnswerInfo.answer3.value] + 'answers': [TestPollAnswerInfo.answer3.value], } # Sending PATCH request @@ -134,16 +135,36 @@ def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): content_type=ContentType.JSON.value, ) - # Checking response assert rv.status_code == HTTPStatus.OK - assert rv.json.get('title') == data.get('title') - assert len(rv.json.get('answers')) == 1 + + # Checking updating title if Engagement is published + + engagement = factory_engagement_model(status=Status.Published.value) + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + + # Preparing data for PATCH request + data = { + 'title': 'Updated Title', + 'answers': [TestPollAnswerInfo.answer3.value], + } + + # Sending PATCH request + rv = client.patch( + f'/api/widgets/{widget.id}/polls/{poll.id}', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.BAD_REQUEST # testing Exceptions with wrong data # Sending patch request rv = client.patch( f'/api/widgets/{widget.id}/polls/{poll.id}', - data=json.dumps({'title_wrong_key': 'Updated'}), + data=json.dumps({'title': 5}), headers=headers, content_type=ContentType.JSON.value, ) diff --git a/met-api/tests/unit/services/test_widget_poll_service.py b/met-api/tests/unit/services/test_widget_poll_service.py index 053ecf3a8..260e334b8 100644 --- a/met-api/tests/unit/services/test_widget_poll_service.py +++ b/met-api/tests/unit/services/test_widget_poll_service.py @@ -20,6 +20,7 @@ import pytest +from met_api.constants.engagement_status import Status as EngagementStatus from met_api.exceptions.business_exception import BusinessException from met_api.services import authorization from met_api.services.poll_response_service import PollResponseService @@ -79,15 +80,15 @@ def test_update_poll(session): session.commit() updated_data = { 'title': 'Updated Title', - 'answers': [ - { - 'answer_text': 'Python' - } - ] + 'answers': [{'answer_text': 'Python'}], } - updated_poll = WidgetPollService.update_poll(widget.id, poll.id, updated_data) + updated_poll = WidgetPollService.update_poll( + widget.id, poll.id, updated_data + ) assert updated_poll.title == updated_data['title'] - assert updated_poll.answers[0].answer_text == updated_data['answers'][0]['answer_text'] + assert ( + updated_poll.answers[0].answer_text == updated_data['answers'][0]['answer_text'] + ) # Test invalid poll ID with pytest.raises(BusinessException) as exc_info: @@ -129,7 +130,9 @@ def test_check_already_polled(session): response_data = TestPollResponseInfo.response1 widget = _create_widget() poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) - already_polled = WidgetPollService.check_already_polled(poll.id, response_data['participant_id'], 1) + already_polled = WidgetPollService.check_already_polled( + poll.id, response_data['participant_id'], 1 + ) assert already_polled is False # Check already polled or not after poll response is created @@ -139,7 +142,9 @@ def test_check_already_polled(session): response_data['selected_answer_id'] = answer.id PollResponseService.create_response(response_data) - already_polled = WidgetPollService.check_already_polled(poll.id, response_data['participant_id'], 1) + already_polled = WidgetPollService.check_already_polled( + poll.id, response_data['participant_id'], 1 + ) assert already_polled is True @@ -157,6 +162,29 @@ def test_is_poll_active(session): assert exc_info.value.status_code == HTTPStatus.NOT_FOUND +def test_is_poll_engagement_published(session): + """Check if poll engagement is published or not.""" + widget = _create_widget() + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + is_published = WidgetPollService.is_poll_engagement_published(poll.id) + assert is_published is True + + # Test not published status + engagement = factory_engagement_model( + status=EngagementStatus.Unpublished.value + ) + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + is_published = WidgetPollService.is_poll_engagement_published(poll.id) + assert is_published is False + + # Test wrong poll id + with pytest.raises(BusinessException) as exc_info: + _ = WidgetPollService.is_poll_active(100) + + assert exc_info.value.status_code == HTTPStatus.NOT_FOUND + + def _create_widget(): """Create a widget for testing.""" engagement = factory_engagement_model() diff --git a/met-web/package-lock.json b/met-web/package-lock.json index 63ce07491..fdc4c64d0 100644 --- a/met-web/package-lock.json +++ b/met-web/package-lock.json @@ -71,6 +71,7 @@ "recharts": "^2.4.3", "redux": "^4.1.2", "typescript": "^4.6.3", + "universal-cookie": "^7.0.1", "web-vitals": "^2.1.4", "yup": "^0.32.11" }, @@ -6908,6 +6909,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/d3-array": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.5.tgz", @@ -25079,6 +25085,23 @@ "node": ">=8" } }, + "node_modules/universal-cookie": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.0.1.tgz", + "integrity": "sha512-6OuX9xELF6dsVJeADJAYNDOxQf/NR3Na5bGCRd+hkysMDkSt79jJ4tdv5OBe+ZgAks3ExHBdCXkD2SjqLyK59w==", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0" + } + }, + "node_modules/universal-cookie/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -31133,6 +31156,11 @@ "@types/node": "*" } }, + "@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "@types/d3-array": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.5.tgz", @@ -44696,6 +44724,22 @@ "crypto-random-string": "^2.0.0" } }, + "universal-cookie": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.0.1.tgz", + "integrity": "sha512-6OuX9xELF6dsVJeADJAYNDOxQf/NR3Na5bGCRd+hkysMDkSt79jJ4tdv5OBe+ZgAks3ExHBdCXkD2SjqLyK59w==", + "requires": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0" + }, + "dependencies": { + "cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + } + } + }, "universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", diff --git a/met-web/package.json b/met-web/package.json index 005d0f3cd..7ee0a4367 100644 --- a/met-web/package.json +++ b/met-web/package.json @@ -66,6 +66,7 @@ "recharts": "^2.4.3", "redux": "^4.1.2", "typescript": "^4.6.3", + "universal-cookie": "^7.0.1", "web-vitals": "^2.1.4", "yup": "^0.32.11" }, diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 973e805eb..95666efa6 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -169,6 +169,12 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/engagements/engagement_id/cacform/widget_id`, GET_SHEET: `${AppConfig.apiUrl}/engagements/engagement_id/cacform/sheet`, }, + PollWidgets: { + GET: `${AppConfig.apiUrl}/widgets/widget_id/polls`, + CREATE: `${AppConfig.apiUrl}/widgets/widget_id/polls`, + UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/polls/poll_id`, + RECORD_RESPONSE: `${AppConfig.apiUrl}/widgets/widget_id/polls/poll_id/responses`, + }, }; export default Endpoints; diff --git a/met-web/src/components/engagement/form/ActionContext.tsx b/met-web/src/components/engagement/form/ActionContext.tsx index 768948430..24a8651d4 100644 --- a/met-web/src/components/engagement/form/ActionContext.tsx +++ b/met-web/src/components/engagement/form/ActionContext.tsx @@ -1,10 +1,6 @@ import React, { createContext, useState, useEffect } from 'react'; import { postEngagement, getEngagement, patchEngagement } from '../../../services/engagementService'; -import { - postEngagementMetadata, - getEngagementMetadata, - patchEngagementMetadata, -} from '../../../services/engagementMetadataService'; +import { getEngagementMetadata } from '../../../services/engagementMetadataService'; import { useNavigate, useParams } from 'react-router-dom'; import { EngagementContext, EngagementForm, EngagementFormUpdate, EngagementParams } from './types'; import { @@ -17,7 +13,7 @@ import { saveObject } from 'services/objectStorageService'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch, useAppSelector } from 'hooks'; import { getErrorMessage } from 'utils'; -import { updatedDiff, diff } from 'deep-object-diff'; +import { updatedDiff } from 'deep-object-diff'; import { PatchEngagementRequest } from 'services/engagementService/types'; import { USER_ROLES } from 'services/userService/constants'; import { EngagementStatus } from 'constants/engagementStatus'; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx new file mode 100644 index 000000000..df4e41de4 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -0,0 +1,298 @@ +import React, { useContext } from 'react'; +import Divider from '@mui/material/Divider'; +import { Grid, MenuItem, TextField, Select, SelectChangeEvent } from '@mui/material'; +import { MetDescription, MetLabel, MidScreenLoader, PrimaryButton, SecondaryButton } from 'components/common'; +import { SubmitHandler } from 'react-hook-form'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { PollContext } from './PollContext'; +import { patchPoll, postPoll } from 'services/widgetService/PollService'; +import { WidgetTitle } from '../WidgetTitle'; +import { PollAnswer } from 'models/pollWidget'; +import PollDisplay from './PollDisplay'; +import { ActionContext } from '../../ActionContext'; +import { PollStatus } from 'constants/engagementStatus'; +import Alert from '@mui/material/Alert'; +import usePollWidgetState from './PollWidget.hook'; +import PollAnswerForm from './PollAnswerForm'; + +interface DetailsForm { + title: string; + description: string; + answers: PollAnswer[]; + status: string; +} + +const previewStyle = { + backgroundColor: '#f5f5f5', + padding: '1em', + borderRadius: '8px', + marginTop: '1em', + marginBottom: '1em', +}; + +const STATUS_ITEMS = [ + { value: PollStatus.Active, label: 'Active' }, + { value: PollStatus.Inactive, label: 'InActive' }, +]; + +const interactionEnabled = false; + +const Form = () => { + const dispatch = useAppDispatch(); + const { widget, isLoadingPollWidget, pollWidget } = useContext(PollContext); + const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const [isCreating, setIsCreating] = React.useState(false); + const { savedEngagement } = useContext(ActionContext); + const { pollAnswers, setPollAnswers, pollWidgetState, setPollWidgetState, isEngagementPublished } = + usePollWidgetState(pollWidget, savedEngagement, widget); + + const handleOnSubmit = (event: React.SyntheticEvent) => { + event.preventDefault(); + const answersForSubmission = [...pollAnswers]; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventTarget = event.target as any; + const restructuredData = { + title: eventTarget['title']?.value, + description: eventTarget['description']?.value, + answers: answersForSubmission, + status: eventTarget['status']?.value, + }; + setPollAnswers(answersForSubmission); + onSubmit(restructuredData); + }; + + const savePollWidget = (data: DetailsForm) => (!pollWidget ? createPoll(data) : updatePoll(data)); + + const createPoll = async (data: DetailsForm) => { + if (!widget) { + return; + } + + const { title, description, answers, status } = data; + await postPoll(widget.id, { + widget_id: widget.id, + engagement_id: widget.engagement_id, + title: title, + description: description, + answers: answers, + status: status, + }); + dispatch(openNotification({ severity: 'success', text: 'A new Poll was successfully added' })); + }; + + const updatePoll = async (data: DetailsForm) => { + if (!widget || !pollWidget) { + return; + } + + if (Object.keys(data).length === 0) { + return; + } + + if (!isEngagementPublished) { + await patchPoll(widget.id, pollWidget.id, { ...data }); + } else { + // if already published then only update status + await patchPoll(widget.id, pollWidget.id, { status: data.status }); + } + + dispatch(openNotification({ severity: 'success', text: 'The Poll widget was successfully updated' })); + }; + + const onSubmit: SubmitHandler = async (data: DetailsForm) => { + if (!widget) { + return; + } + try { + setIsCreating(true); + await savePollWidget(data); + setIsCreating(false); + handleWidgetDrawerOpen(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to add the event' })); + setIsCreating(false); + } + }; + + const handleTextChange = (e: React.ChangeEvent, property: string) => { + if (!pollAnswers) { + return; + } + const newValue = e.currentTarget.value; + if ('description' === property) { + setPollWidgetState({ ...pollWidgetState, description: newValue }); + } else if ('title' === property) { + setPollWidgetState({ ...pollWidgetState, title: newValue }); + } + }; + + const handleSelectChange = (e: SelectChangeEvent, property: string) => { + const newValue = e.target.value; + if ('status' === property) { + setPollWidgetState({ ...pollWidgetState, status: newValue }); + } + }; + + const handlePollAnswersChange = (answers: PollAnswer[]) => { + setPollAnswers(answers); + setPollWidgetState({ ...pollWidgetState, answers: [...answers] }); + }; + + const engagementPublishedAlert = ( + + Editing of the Poll details is not available once the engagement has been published. + + ); + + if (isLoadingPollWidget || !widget) { + return ( + + + + + + ); + } + + const pollTitleField = ( + + Title + The title must be less than 255 characters. + ) => { + handleTextChange(event, 'title'); + }} + /> + + ); + + const pollDescriptionField = ( + + Description + ) => { + handleTextChange(event, 'description'); + }} + /> + + ); + + const divider = ( + + + + ); + + const pollAnswersField = ( + + ); + + const pollStatusField = ( + + Status + + + ); + + const pollPreview = ( + + Preview + {divider} + + + ); + const pollFormButtons = ( + + + + Save & Close + + + + handleWidgetDrawerOpen(false)}>Cancel + + + ); + + return ( + + + + + + +
handleOnSubmit(event)} id="timelineForm"> + + + {engagementPublishedAlert} + + {(!isEngagementPublished || (isEngagementPublished && !pollWidget)) && ( + <> + {pollTitleField} + {pollDescriptionField} + {divider} + {pollAnswersField} + + )} + {divider} + {pollStatusField} + {divider} + {pollPreview} + {pollFormButtons} + +
+
+
+ ); +}; + +export default Form; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollAnswerForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollAnswerForm.tsx new file mode 100644 index 000000000..52f9b7931 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollAnswerForm.tsx @@ -0,0 +1,65 @@ +// PollAnswerForm.tsx +import React, { useState, useEffect } from 'react'; +import { Grid } from '@mui/material'; +import { PrimaryButton } from 'components/common'; +import { PollAnswer } from 'models/pollWidget'; +import Typography from '@mui/material/Typography'; +import PollAnswerItemForm from './PolllAnswerItemForm'; + +interface PollAnswerFormProps { + initialPollAnswers: PollAnswer[]; + onPollAnswersChange: (answers: PollAnswer[]) => void; +} + +const PollAnswerForm: React.FC = ({ initialPollAnswers, onPollAnswersChange }) => { + const [pollAnswers, setPollAnswers] = useState(initialPollAnswers); + + useEffect(() => { + setPollAnswers(initialPollAnswers); + }, [initialPollAnswers]); + + const handleAnswerTextChange = (answer_text: string, index: number) => { + const updatedAnswers = [...pollAnswers]; + updatedAnswers[index].answer_text = answer_text; + setPollAnswers(updatedAnswers); + onPollAnswersChange(updatedAnswers); + }; + + const handleRemoveAnswer = (index: number) => { + const updatedAnswers = pollAnswers.filter((_, i) => i !== index); + setPollAnswers(updatedAnswers); + onPollAnswersChange(updatedAnswers); + }; + + const handleAddAnswer = () => { + const newAnswer = { id: 0, answer_text: '' }; + const updatedAnswers = [...pollAnswers, newAnswer]; + setPollAnswers(updatedAnswers); + onPollAnswersChange(updatedAnswers); + }; + + return ( + + + + Poll Answers + + + {pollAnswers?.map((answer, index) => ( + 1} + /> + ))} + + handleAddAnswer()}>Add Answer + + + ); +}; + +export default PollAnswerForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollContext.tsx new file mode 100644 index 000000000..a939990ff --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { Widget, WidgetType } from 'models/widget'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { useAppDispatch } from 'hooks'; +import { fetchPollWidgets } from 'services/widgetService/PollService/index'; +import { PollWidget } from 'models/pollWidget'; +import { openNotification } from 'services/notificationService/notificationSlice'; + +export interface PollContextProps { + widget: Widget | null; + isLoadingPollWidget: boolean; + pollWidget: PollWidget | null; +} + +export type EngagementParams = { + engagementId: string; +}; + +export const PollContext = createContext({ + widget: null, + isLoadingPollWidget: true, + pollWidget: null, +}); + +export const PollContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const { widgets } = useContext(WidgetDrawerContext); + const dispatch = useAppDispatch(); + const widget = widgets.find((widget) => widget.widget_type_id === WidgetType.Poll) ?? null; + const [isLoadingPollWidget, setIsLoadingPollWidget] = useState(true); + const [pollWidget, setPollWidget] = useState(null); + + const loadPollWidget = async () => { + if (!widget) { + return; + } + try { + const result = await fetchPollWidgets(widget.id); + setPollWidget(result[result.length - 1]); + setIsLoadingPollWidget(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to load Poll data' })); + setIsLoadingPollWidget(false); + } + }; + + useEffect(() => { + loadPollWidget(); + }, [widget]); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx new file mode 100644 index 000000000..058e821c6 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx @@ -0,0 +1,51 @@ +import React, { useState } from 'react'; +import { Radio, RadioGroup, FormControlLabel, FormControl, FormLabel } from '@mui/material'; +import { PollWidget } from 'models/pollWidget'; + +// type for the component's props +interface PollDisplayProps { + pollWidget: PollWidget; + interactionEnabled: boolean; + onOptionChange?: (option: string) => void; +} + +const PollDisplay = ({ pollWidget, interactionEnabled, onOptionChange }: PollDisplayProps) => { + const [selectedOption, setSelectedOption] = useState(''); + + const handleRadioChange = (event: React.ChangeEvent) => { + setSelectedOption(event.target.value); + if (onOptionChange) { + onOptionChange(event.target.value); + } + }; + + if (!pollWidget) { + return null; + } + + return ( + + + {pollWidget.title} + +

{pollWidget.description}

+ + {pollWidget.answers.map((answer, index) => ( + } + label={answer.answer_text} + /> + ))} + +
+ ); +}; + +export default PollDisplay; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollOptionCard.tsx new file mode 100644 index 000000000..81b931ce3 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollOptionCard.tsx @@ -0,0 +1,101 @@ +import React, { useContext, useState } from 'react'; +import { MetPaper, MetLabel, MetDescription } from 'components/common'; +import { Grid, CircularProgress } from '@mui/material'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { WidgetType } from 'models/widget'; +import { Else, If, Then } from 'react-if'; +import { ActionContext } from '../../ActionContext'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { optionCardStyle } from '../constants'; +import { WidgetTabValues } from '../type'; +import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; +import PollIcon from '@mui/icons-material/Poll'; + +const Title = 'Poll'; +const PollOptionCard = () => { + const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = + useContext(WidgetDrawerContext); + const { savedEngagement } = useContext(ActionContext); + const dispatch = useAppDispatch(); + const [createWidget] = useCreateWidgetMutation(); + const [isCreatingWidget, setIsCreatingWidget] = useState(false); + + const handleCreateWidget = async () => { + const alreadyExists = widgets.some((widget) => widget.widget_type_id === WidgetType.Poll); + if (alreadyExists) { + handleWidgetDrawerTabValueChange(WidgetTabValues.POLL_FORM); + return; + } + + try { + setIsCreatingWidget(true); + await createWidget({ + widget_type_id: WidgetType.Poll, + engagement_id: savedEngagement.id, + title: Title, + }).unwrap(); + await loadWidgets(); + dispatch( + openNotification({ + severity: 'success', + text: 'Poll widget successfully created.', + }), + ); + setIsCreatingWidget(false); + handleWidgetDrawerTabValueChange(WidgetTabValues.POLL_FORM); + } catch (error) { + setIsCreatingWidget(false); + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating Poll widget' })); + handleWidgetDrawerOpen(false); + } + }; + + return ( + handleCreateWidget()} + > + + + + + + + + + + + + + + {Title} + + + Add a Poll to this engagement + + + + + + + ); +}; + +export default PollOptionCard; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollWidget.hook.ts b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollWidget.hook.ts new file mode 100644 index 000000000..6c740b31e --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollWidget.hook.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; +import { PollAnswer, PollWidget } from 'models/pollWidget'; +import { Engagement } from 'models/engagement'; +import { Widget } from 'models/widget'; +import { EngagementStatus, PollStatus } from 'constants/engagementStatus'; + +const usePollWidgetState = (pollWidget: PollWidget | null, savedEngagement: Engagement, widget: Widget | null) => { + const newAnswer = { id: 0, answer_text: '' }; + const initialWidgetState = { + id: pollWidget?.id || 0, + description: pollWidget?.description || '', + title: pollWidget?.title || '', + answers: pollWidget?.answers || [], + status: pollWidget?.status || PollStatus.Active, + widget_id: widget?.id || 0, + engagement_id: widget?.engagement_id || 0, + }; + + const [pollAnswers, setPollAnswers] = useState(pollWidget ? pollWidget.answers : [newAnswer]); + const [pollWidgetState, setPollWidgetState] = useState(initialWidgetState); + const [isEngagementPublished, setIsEngagementPublished] = useState(false); + + useEffect(() => { + if (pollWidget) { + setPollAnswers(pollWidget.answers); + setPollWidgetState(pollWidget); + } + + if (savedEngagement && savedEngagement.status_id === EngagementStatus.Published) { + setIsEngagementPublished(true); + } + }, [pollWidget, savedEngagement]); + + return { pollAnswers, setPollAnswers, pollWidgetState, setPollWidgetState, isEngagementPublished }; +}; + +export default usePollWidgetState; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PolllAnswerItemForm.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PolllAnswerItemForm.tsx new file mode 100644 index 000000000..a32b2eab9 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PolllAnswerItemForm.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Grid, TextField, Divider } from '@mui/material'; +import { SecondaryButton, MetLabel } from 'components/common'; +import { PollAnswer } from 'models/pollWidget'; + +interface PollAnswerItemProps { + index: number; + answer: PollAnswer; + onTextChange: (text: string, index: number) => void; + onRemove: (index: number) => void; + canRemove: boolean; +} + +const PollAnswerItemForm: React.FC = React.memo( + ({ index, answer, onTextChange, onRemove, canRemove }) => { + return ( + <> + + {'Answer Text ' + (index + 1)} + onTextChange(e.target.value, index)} + /> + + {canRemove && ( + + onRemove(index)}>Remove Answer + + )} + + + + + ); + }, +); + +export default PollAnswerItemForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/index.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/index.tsx new file mode 100644 index 000000000..73bb1cf9f --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { PollContextProvider } from './PollContext'; +import Form from './Form'; + +export const PollForm = () => { + return ( + +
+ + ); +}; + +export default PollForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx index 29396bec7..261d3d405 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx @@ -120,6 +120,19 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps }} /> + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.POLL_FORM); + handleWidgetDrawerOpen(true); + }} + /> + ); diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx index 62e862efd..3aba4ab4b 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx @@ -12,6 +12,7 @@ import MapForm from './Map'; import VideoForm from './Video'; import TimelineForm from './Timeline'; import SubscribeForm from './Subscribe'; +import PollForm from './Poll'; const WidgetDrawerTabs = () => { const { widgetDrawerTabValue } = useContext(WidgetDrawerContext); @@ -45,6 +46,9 @@ const WidgetDrawerTabs = () => { + + + ); diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx index 4fbfcf768..4eaf7d86e 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx @@ -9,6 +9,7 @@ import EventsOptionCard from './Events/EventsOptionCard'; import MapOptionCard from './Map/MapOptionCard'; import VideoOptionCard from './Video/VideoOptionCard'; import TimelineOptionCard from './Timeline/TimelineOptionCard'; +import PollOptionCard from './Poll/PollOptionCard'; const WidgetOptionCards = () => { return ( @@ -41,6 +42,9 @@ const WidgetOptionCards = () => { + + + ); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx index d66741f67..d54ca5632 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx @@ -9,4 +9,5 @@ export const WidgetTabValues = { MAP_FORM: 'MAP_FORM', VIDEO_FORM: 'VIDEO_FORM', TIMELINE_FORM: 'TIMELINE_FORM', + POLL_FORM: 'POLL_FORM', }; diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx new file mode 100644 index 000000000..e28f9a2a5 --- /dev/null +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect } from 'react'; +import { MetPaper, MetHeader2, PrimaryButton } from 'components/common'; +import { Grid, Skeleton, Divider } from '@mui/material'; +import PollDisplay from '../../../form/EngagementWidgets/Poll/PollDisplay'; +import { Widget } from 'models/widget'; +import { useAppDispatch, useSubmittedPolls, useAppSelector } from 'hooks'; +import { PollWidget } from 'models/pollWidget'; +import { fetchPollWidgets, postPollResponse } from 'services/widgetService/PollService/index'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { PollStatus } from 'constants/engagementStatus'; +interface PollWidgetViewProps { + widget: Widget; +} + +interface HttpResponseError extends Error { + response?: { + status: number; + }; +} + +interface ResponseMessage { + color: string; + message: string; +} + +const RESPONSE_MESSAGE_SUCCESS: ResponseMessage = { color: 'green', message: 'Thank you for the response.' }; +const RESPONSE_MESSAGE_RECORDED: ResponseMessage = { color: 'green', message: 'Response already recorded.' }; +const RESPONSE_MESSAGE_ERROR: ResponseMessage = { color: 'red', message: 'An error occurred.' }; +const RESPONSE_MESSAGE_LIMIT: ResponseMessage = { color: 'red', message: 'Limit exceeded for this poll.' }; + +const PollWidgetView = ({ widget }: PollWidgetViewProps) => { + const dispatch = useAppDispatch(); + const [pollWidget, setPollWidget] = useState({ + id: 0, + widget_id: 0, + engagement_id: 0, + title: '', + description: '', + status: '', + answers: [], + }); + + const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); + const [isLoading, setIsLoading] = useState(true); + const [selectedOption, setSelectedOption] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [interactionEnabled, setInteractionEnabled] = useState(true); + const [responseMessage, setResponseMessage] = useState(null); + const { getSubmittedPolls, addSubmittedPoll } = useSubmittedPolls(); + + useEffect(() => { + // Check if the current widget ID is in the submitted polls + if (getSubmittedPolls().includes(widget.id)) { + setIsSubmitted(true); + setResponseMessage(RESPONSE_MESSAGE_RECORDED); + } + fetchPollDetails(); + }, [widget]); + + const fetchPollDetails = async () => { + try { + const pollWidgets = await fetchPollWidgets(widget.id); + const pollWidget = pollWidgets[pollWidgets.length - 1]; + setPollWidget(pollWidget); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while fetching Engagement widgets information', + }), + ); + } + }; + // Type guard for HttpResponseError + const isHttpResponseError = (error: unknown): error is HttpResponseError => { + return error instanceof Error && 'response' in error; + }; + + const handleSubmit = async () => { + // Resetting error message + setResponseMessage(null); + + if (selectedOption == '') { + dispatch( + openNotification({ + severity: 'error', + text: 'You need to select an answer to the poll before submitting it.', + }), + ); + return; + } + setInteractionEnabled(false); + try { + await postPollResponse(widget.id, pollWidget.id, { + selected_answer_id: parseInt(selectedOption), + }); + + setIsSubmitted(true); + addSubmittedPoll(widget.id); + setResponseMessage(RESPONSE_MESSAGE_SUCCESS); + } catch (error: unknown) { + let responseMessage = RESPONSE_MESSAGE_ERROR; + if (isHttpResponseError(error) && error.response?.status === 400) { + // If exceed limit error, do not allow them to poll again and added poll to already poll list + responseMessage = RESPONSE_MESSAGE_LIMIT; + setIsSubmitted(true); + addSubmittedPoll(widget.id); + } else { + // If not exceed limit error, allow them to poll again + setIsSubmitted(false); + setInteractionEnabled(true); + } + setResponseMessage(responseMessage); + } + }; + + const handleOptionChange = (option: string) => { + setSelectedOption(option); + }; + + const isPollNotReady = () => { + if (pollWidget) { + return pollWidget.status === PollStatus.Inactive || pollWidget.answers.length == 0; + } else { + return true; + } + }; + + if (isPollNotReady()) { + return null; + } + + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } + + return ( + + + + {widget.title} + + + + + {!isLoggedIn && ( + <> + {!isSubmitted && ( + + handleSubmit()}>Submit + + )} + {responseMessage?.message && ( +

{responseMessage?.message}

+ )} + + )} +
+
+
+ ); +}; + +export default PollWidgetView; diff --git a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx index 2e1391cad..06ad0ddef 100644 --- a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx +++ b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx @@ -8,6 +8,7 @@ import EventsWidget from './Events/EventsWidget'; import MapWidget from './Map/MapWidget'; import VideoWidgetView from './Video/VideoWidgetView'; import TimelineWidgetView from './Timeline/TimelineWidgetView'; +import PollWidgetView from './Poll/PollWidgetView'; interface WidgetSwitchProps { widget: Widget; } @@ -37,6 +38,9 @@ export const WidgetSwitch = ({ widget }: WidgetSwitchProps) => { + + + ); diff --git a/met-web/src/constants/engagementStatus.ts b/met-web/src/constants/engagementStatus.ts index 8c41b7406..44f244f4e 100644 --- a/met-web/src/constants/engagementStatus.ts +++ b/met-web/src/constants/engagementStatus.ts @@ -30,3 +30,8 @@ export const SUBMISSION_STATUS: { [status: string]: SubmissionStatusTypes } = { OPEN: 'Open', CLOSED: 'Closed', }; + +export enum PollStatus { + Active = 'active', + Inactive = 'inactive', +} diff --git a/met-web/src/hooks.ts b/met-web/src/hooks.ts index a7d8dec25..832fe0aff 100644 --- a/met-web/src/hooks.ts +++ b/met-web/src/hooks.ts @@ -1,6 +1,7 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store'; import { useTranslation } from 'react-i18next'; +import Cookies from 'universal-cookie'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = () => useDispatch(); @@ -20,3 +21,20 @@ export const useAppTranslation = () => { return { ...translate, t: tDynamic }; }; + +export const useSubmittedPolls = () => { + const cookie_name = '_su_p_'; + const cookies = new Cookies(); + const getSubmittedPolls = () => cookies.get(cookie_name) || []; + const addSubmittedPoll = (widget_id: number) => { + const submittedPolls = getSubmittedPolls(); + if (!submittedPolls.includes(widget_id)) { + submittedPolls.push(widget_id); + // Calculate the expiry date one year from now + const oneYearFromNow = new Date(); + oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1); + cookies.set(cookie_name, submittedPolls, { path: '/', expires: oneYearFromNow }); + } + }; + return { getSubmittedPolls, addSubmittedPoll }; +}; diff --git a/met-web/src/models/pollWidget.tsx b/met-web/src/models/pollWidget.tsx new file mode 100644 index 000000000..69a047875 --- /dev/null +++ b/met-web/src/models/pollWidget.tsx @@ -0,0 +1,16 @@ +export interface PollWidget { + id: number; + engagement_id: number; + widget_id: number; + title: string; + description: string; + status: string; + answers: PollAnswer[]; +} +export interface PollAnswer { + id: number; + answer_text: string; +} +export interface PollResponse { + selected_answer_id: string; +} diff --git a/met-web/src/models/widget.tsx b/met-web/src/models/widget.tsx index 529c072e2..1db936478 100644 --- a/met-web/src/models/widget.tsx +++ b/met-web/src/models/widget.tsx @@ -23,4 +23,5 @@ export enum WidgetType { Video = 7, CACForm = 8, Timeline = 9, + Poll = 10, } diff --git a/met-web/src/services/widgetService/PollService/index.tsx b/met-web/src/services/widgetService/PollService/index.tsx new file mode 100644 index 000000000..88ead8ade --- /dev/null +++ b/met-web/src/services/widgetService/PollService/index.tsx @@ -0,0 +1,75 @@ +import http from 'apiManager/httpRequestHandler'; +import Endpoints from 'apiManager/endpoints'; +import { replaceAllInURL, replaceUrl } from 'helper'; +import { PollWidget, PollAnswer, PollResponse } from 'models/pollWidget'; + +interface PostPollRequest { + widget_id: number; + engagement_id: number; + title: string; + description: string; + answers: PollAnswer[]; + status: string; +} + +interface PostPollResponse { + selected_answer_id: number; +} + +interface PatchPollRequest { + answers?: PollAnswer[]; + title?: string; + description?: string; + status?: string; +} + +export const postPoll = async (widget_id: number, data: PostPollRequest): Promise => { + try { + const url = replaceUrl(Endpoints.PollWidgets.CREATE, 'widget_id', String(widget_id)); + const response = await http.PostRequest(url, data); + return response.data || Promise.reject('Failed to create Poll widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +export const patchPoll = async (widget_id: number, poll_id: number, data: PatchPollRequest): Promise => { + try { + const url = replaceAllInURL({ + URL: Endpoints.PollWidgets.UPDATE, + params: { + widget_id: String(widget_id), + poll_id: String(poll_id), + }, + }); + const response = await http.PatchRequest(url, data); + return response.data || Promise.reject('Failed to update Poll widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +export const fetchPollWidgets = async (widget_id: number): Promise => { + try { + const url = replaceUrl(Endpoints.PollWidgets.GET, 'widget_id', String(widget_id)); + const responseData = await http.GetRequest(url); + return responseData.data ?? []; + } catch (err) { + return Promise.reject(err); + } +}; + +export const postPollResponse = async ( + widget_id: number, + poll_id: number, + data: PostPollResponse, +): Promise => { + try { + let url = replaceUrl(Endpoints.PollWidgets.RECORD_RESPONSE, 'widget_id', String(widget_id)); + url = replaceUrl(url, 'poll_id', String(poll_id)); + const response = await http.PostRequest(url, data); + return response.data || Promise.reject('Failed to create Poll Response'); + } catch (err) { + return Promise.reject(err); + } +};