From 50999d41ef3426486aba59596c6b44fb223ef86d Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R <108045773+ratheesh-aot@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:13:43 -0700 Subject: [PATCH] [TO MAIN] DESENG-513 - Add poll results to results tab (#2422) * DESENG-513: Poll results view * DESENG-513: Lint fixes * DESENG-513: Updated changelog * DESENG-513: Votes text update * Removed comments * DESENG-513: Fixing review comments --- CHANGELOG.MD | 5 + met-api/src/met_api/resources/widget_poll.py | 12 ++ .../met_api/services/poll_response_service.py | 72 ++++++++++- met-api/tests/unit/api/test_widget_poll.py | 49 +++++++- .../Results/EngagementResults.tsx | 4 + .../PollResults/EngagementPollContext.tsx | 118 ++++++++++++++++++ .../Results/PollResults/PollResult.tsx | 37 ++++++ .../Results/PollResults/PollResultsView.tsx | 13 ++ .../Results/PollResults/ResultView.tsx | 74 +++++++++++ met-web/src/models/pollWidget.tsx | 14 +++ .../widgetService/PollService/index.tsx | 13 +- .../widgets/PollWidgetResultsView.test.tsx | 98 +++++++++++++++ 12 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/EngagementPollContext.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResult.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResultsView.tsx create mode 100644 met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/ResultView.tsx create mode 100644 met-web/tests/unit/components/widgets/PollWidgetResultsView.test.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index f02e9400a..5c49f3af8 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,10 @@ ## March 19, 2024 +- **Task**: Add poll results to results tab [DESENG-513](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-513) + - Added poll results to results tab. + - Added poll results API. + - Added Unit tests. + - **Task**: Change static english text to be able to support string translations [DESENG-467](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-467) - Implemented a language selector in the public header. - Incorporated logic to dynamically adjust the unauthenticated route based on the selected language and load the appropriate translation file. diff --git a/met-api/src/met_api/resources/widget_poll.py b/met-api/src/met_api/resources/widget_poll.py index 7eb24550b..785c88183 100644 --- a/met-api/src/met_api/resources/widget_poll.py +++ b/met-api/src/met_api/resources/widget_poll.py @@ -10,6 +10,7 @@ from met_api.schemas import utils as schema_utils from met_api.schemas.widget_poll import WidgetPollSchema from met_api.services.widget_poll_service import WidgetPollService +from met_api.services.poll_response_service import PollResponseService from met_api.utils.util import allowedorigins, cors_preflight from met_api.utils.ip_util import hash_ip @@ -165,3 +166,14 @@ def post(widget_id, poll_widget_id): except BusinessException as err: return err.error, err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def get(poll_widget_id, **_): + """Get poll responses for a given widget.""" + try: + poll_results = PollResponseService().get_poll_details_with_response_counts(poll_widget_id) + return jsonify(poll_results), HTTPStatus.OK + except BusinessException as err: + return err.error, err.status_code diff --git a/met-api/src/met_api/services/poll_response_service.py b/met-api/src/met_api/services/poll_response_service.py index 4dcdaf554..dd070f2ad 100644 --- a/met-api/src/met_api/services/poll_response_service.py +++ b/met-api/src/met_api/services/poll_response_service.py @@ -1,9 +1,15 @@ """Service for Poll Response management.""" + from http import HTTPStatus +from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from met_api.exceptions.business_exception import BusinessException from met_api.models.poll_responses import PollResponse as PollResponseModel from met_api.services.poll_answers_service import PollAnswerService +from met_api.models import Poll, PollAnswer, db +from met_api.services import authorization +from met_api.constants.membership_type import MembershipType +from met_api.utils.roles import Role class PollResponseService: @@ -31,8 +37,7 @@ def create_response(response_data: dict) -> PollResponseModel: return poll_response except SQLAlchemyError as e: # Log the exception or handle it as needed - raise BusinessException(f'Error creating poll response: {e}', - HTTPStatus.INTERNAL_SERVER_ERROR) from e + raise BusinessException(f'Error creating poll response: {e}', HTTPStatus.INTERNAL_SERVER_ERROR) from e @staticmethod def get_poll_count(poll_id: int, ip_addr: str = None) -> int: @@ -46,5 +51,64 @@ def get_poll_count(poll_id: int, ip_addr: str = None) -> int: return len(responses) except SQLAlchemyError as e: # Log the exception or handle it as needed - raise BusinessException(f'Error creating poll response: {e}', - HTTPStatus.INTERNAL_SERVER_ERROR) from e + raise BusinessException(f'Error creating poll response: {e}', HTTPStatus.INTERNAL_SERVER_ERROR) from e + + @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, + ) + + @staticmethod + def get_poll_details_with_response_counts(poll_id): + """ + Get poll details along with response counts for each answer for a specific poll. + + :param poll_id: The ID of the poll. + :return: Poll details and response counts for each answer in a structured format. + """ + poll = Poll.query.get(poll_id) + if not poll: + raise BusinessException('Poll not found', HTTPStatus.NOT_FOUND) + # Check authorization + PollResponseService._check_authorization(poll.engagement_id) + + # Query to join PollAnswer and PollResponse and count responses for each answer + poll_data = ( + db.session.query( + PollAnswer.id.label('answer_id'), + PollAnswer.answer_text, + func.count(PollResponseModel.selected_answer_id).label('response_count') + ) + .select_from(PollAnswer) + .outerjoin(PollResponseModel, PollAnswer.id == PollResponseModel.selected_answer_id) + .filter(PollAnswer.poll_id == poll_id) + .group_by(PollAnswer.id, PollAnswer.answer_text) + .all() + ) + + # Calculate total responses + total_responses = sum(response_count for _, _, response_count in poll_data) + + # Construct response dictionary + response = { + 'poll_id': poll_id, + 'title': poll.title, + 'description': poll.description, + 'total_response': total_responses, + 'answers': [ + { + 'answer_id': answer_id, + 'answer_text': answer_text, + 'total_response': response_count, + 'percentage': (response_count / total_responses * 100) if total_responses > 0 else 0 + } for answer_id, answer_text, response_count in poll_data + ] + } + + return response diff --git a/met-api/tests/unit/api/test_widget_poll.py b/met-api/tests/unit/api/test_widget_poll.py index b38e5d8da..50ded71a4 100644 --- a/met-api/tests/unit/api/test_widget_poll.py +++ b/met-api/tests/unit/api/test_widget_poll.py @@ -26,7 +26,8 @@ from met_api.utils.enums import ContentType from tests.utilities.factory_scenarios import TestJwtClaims, TestPollAnswerInfo, TestWidgetPollInfo from tests.utilities.factory_utils import ( - factory_auth_header, factory_engagement_model, factory_poll_answer_model, factory_poll_model, factory_widget_model) + factory_auth_header, factory_engagement_model, factory_poll_answer_model, factory_poll_model, + factory_poll_response_model, factory_widget_model) fake = Faker() @@ -211,3 +212,49 @@ def test_record_poll_response(client, session, jwt): ) assert rv.status_code == HTTPStatus.BAD_REQUEST + + +def test_get_poll_response(client, jwt, session, setup_admin_user_and_claims): + """Assert that a response for a poll widget can be retrieved.""" + # Test setup: create a poll widget and a response model + _, claims = setup_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + engagement = factory_engagement_model() + widget = factory_widget_model({'engagement_id': engagement.id}) + poll = factory_poll_model(widget, TestWidgetPollInfo.poll1) + answer1 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer1) + answer2 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer2) + answer3 = factory_poll_answer_model(poll, TestPollAnswerInfo.answer3) + + # Recording 2 votes for answer1 + factory_poll_response_model(poll, answer1) + factory_poll_response_model(poll, answer1) + + # Recording 2 votes for answer2 + factory_poll_response_model(poll, answer2) + factory_poll_response_model(poll, answer2) + + # Sending GET request + rv = client.get( + f'/api/widgets/{widget.id}/polls/{poll.id}/responses', + headers=headers, + content_type=ContentType.JSON.value, + ) + + # Checking response + assert rv.status_code == HTTPStatus.OK + # Testing Poll title and total_response + assert rv.json.get('title') == poll.title + assert rv.json.get('total_response') == 4 + # Testing First answer + assert rv.json.get('answers')[0].get('answer_id') == answer1.id + assert rv.json.get('answers')[0].get('total_response') == 2 + assert rv.json.get('answers')[0].get('percentage') == 50 + # Testing Second answer + assert rv.json.get('answers')[1].get('answer_id') == answer2.id + assert rv.json.get('answers')[1].get('total_response') == 2 + assert rv.json.get('answers')[1].get('percentage') == 50 + # Testing Third answer + assert rv.json.get('answers')[2].get('answer_id') == answer3.id + assert rv.json.get('answers')[2].get('total_response') == 0 + assert rv.json.get('answers')[2].get('percentage') == 0 diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Results/EngagementResults.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Results/EngagementResults.tsx index 390a8512a..a3a017edf 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/Results/EngagementResults.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Results/EngagementResults.tsx @@ -3,6 +3,7 @@ import { Grid, Box } from '@mui/material'; import { MetPaper, PrimaryButton, SecondaryButton } from 'components/common'; import { EngagementTabsContext } from '../EngagementTabsContext'; import { ActionContext } from '../../ActionContext'; +import PollResultsView from './PollResults/PollResultsView'; const EngagementResults = () => { const { isSaving } = useContext(ActionContext); @@ -19,6 +20,9 @@ const EngagementResults = () => { spacing={2} sx={{ padding: '2em' }} > + + + ({ + widget: null, + isWidgetsLoading: true, + pollWidget: null, + isLoadingPollWidget: true, + pollResults: null, + isPollResultsLoading: true, +}); + +export const EngagementPollContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const { savedEngagement } = useContext(ActionContext); + const [widgets, setWidgets] = useState(null); + const [widget, setWidget] = useState(null); + const [isWidgetsLoading, setIsWidgetsLoading] = useState(true); + const [pollWidget, setPollWidget] = useState(null); + const [isLoadingPollWidget, setIsLoadingPollWidget] = useState(true); + const [pollResults, setPollResults] = useState(null); + const [isPollResultsLoading, setIsPollResultsLoading] = useState(true); + const dispatch = useAppDispatch(); + + const loadWidgets = async () => { + if (!savedEngagement.id) { + setIsWidgetsLoading(false); + return; + } + + try { + const widgetsList = await getWidgets(savedEngagement.id); + setWidgets(widgetsList); + setIsWidgetsLoading(false); + } catch (err) { + setIsWidgetsLoading(false); + dispatch(openNotification({ severity: 'error', text: 'Error fetching engagement widgets' })); + } finally { + setIsWidgetsLoading(false); + } + }; + + const loadPollWidget = async () => { + const widget = widgets?.find((w) => w.widget_type_id === WidgetType.Poll) ?? null; + setWidget(widget); + if (!widget) { + setIsLoadingPollWidget(false); + return; + } + try { + const result = await fetchPollWidgets(widget.id); + setPollWidget(result.at(-1)); + setIsLoadingPollWidget(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to load Poll data' })); + setIsLoadingPollWidget(false); + } finally { + setIsLoadingPollWidget(false); + } + }; + + const getPollResults = async () => { + try { + if (!widget || !pollWidget) { + return; + } + const data = await fetchPollResults(widget.id, pollWidget.id); + setPollResults(data); + setIsPollResultsLoading(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'Error fetching poll results' })); + setIsPollResultsLoading(false); + } finally { + setIsPollResultsLoading(false); + } + }; + + useEffect(() => { + loadWidgets(); + }, [savedEngagement.id]); + + useEffect(() => { + loadPollWidget(); + }, [widgets]); + + useEffect(() => { + getPollResults(); + }, [widget, pollWidget]); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResult.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResult.tsx new file mode 100644 index 000000000..917281e09 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResult.tsx @@ -0,0 +1,37 @@ +import React, { useContext } from 'react'; +import { Grid } from '@mui/material'; +import { EngagementPollContext } from './EngagementPollContext'; +import ResultView from './ResultView'; +import { MidScreenLoader } from 'components/common'; + +export const PollResult = () => { + const { widget, isWidgetsLoading, isLoadingPollWidget, pollWidget, pollResults, isPollResultsLoading } = + useContext(EngagementPollContext); + + // Show a loader while the data is being loaded + if (isLoadingPollWidget || isWidgetsLoading || isPollResultsLoading) { + return ( + + + + + + ); + } + + // Check if both widget and pollWidget are available + if ( + widget && + pollWidget && + widget.id !== undefined && + pollWidget.id !== undefined && + pollResults?.poll_id !== undefined + ) { + return ; + } + + // Display a message or handle the null case when widgetId or pollId is not available + return null; +}; + +export default PollResult; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResultsView.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResultsView.tsx new file mode 100644 index 000000000..a2b31cfd9 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/PollResultsView.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { EngagementPollContextProvider } from './EngagementPollContext'; +import PollResult from './PollResult'; + +export const PollResultsView = () => { + return ( + + + + ); +}; + +export default PollResultsView; diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/ResultView.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/ResultView.tsx new file mode 100644 index 000000000..5717c80a6 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementFormTabs/Results/PollResults/ResultView.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { PollResultResponse } from 'models/pollWidget'; +import { Box, Typography, LinearProgress, Stack } from '@mui/material'; +import { Widget } from 'models/widget'; +import { Grid } from '@mui/material'; +import { MetHeader4 } from '../../../../../common'; +import Divider from '@mui/material/Divider'; + +interface PollResultViewProps { + widget: Widget; + pollResult: PollResultResponse; +} + +interface PollOptionProps { + label: string; + votes: number; + percentage: number; +} + +function PollOption({ label, votes, percentage }: PollOptionProps) { + return ( + + {label} + + + {`${percentage}%`} + + + {votes > 1 ? `${votes} votes` : `${votes} vote`} + + + + ); +} + +const ResultView: React.FC = ({ pollResult, widget }) => { + if (!pollResult) { + return

No Poll Result

; + } + + return ( + + + {widget.title} - Results + + + + + {pollResult.title} + + + {pollResult.description} + + + + {pollResult.answers.map((answer, index) => ( + + ))} + + + Total Votes: {pollResult.total_response} + + + + + ); +}; + +export default ResultView; diff --git a/met-web/src/models/pollWidget.tsx b/met-web/src/models/pollWidget.tsx index 69a047875..919da2c7b 100644 --- a/met-web/src/models/pollWidget.tsx +++ b/met-web/src/models/pollWidget.tsx @@ -10,7 +10,21 @@ export interface PollWidget { export interface PollAnswer { id: number; answer_text: string; + percentage?: number; } export interface PollResponse { selected_answer_id: string; } + +export interface PollResultResponse { + answers: Array<{ + answer_id: number; + answer_text: string; + percentage: number; + total_response: number; + }>; + description: string; + poll_id: number; + title: string; + total_response: number; +} diff --git a/met-web/src/services/widgetService/PollService/index.tsx b/met-web/src/services/widgetService/PollService/index.tsx index 88ead8ade..2cf5ee0ec 100644 --- a/met-web/src/services/widgetService/PollService/index.tsx +++ b/met-web/src/services/widgetService/PollService/index.tsx @@ -1,7 +1,7 @@ import http from 'apiManager/httpRequestHandler'; import Endpoints from 'apiManager/endpoints'; import { replaceAllInURL, replaceUrl } from 'helper'; -import { PollWidget, PollAnswer, PollResponse } from 'models/pollWidget'; +import { PollWidget, PollAnswer, PollResponse, PollResultResponse } from 'models/pollWidget'; interface PostPollRequest { widget_id: number; @@ -73,3 +73,14 @@ export const postPollResponse = async ( return Promise.reject(err); } }; + +export const fetchPollResults = async (widget_id: number, poll_id: number): 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.GetRequest(url); + return response.data || Promise.reject('Failed to fetch Poll Results'); + } catch (err) { + return Promise.reject(err); + } +}; diff --git a/met-web/tests/unit/components/widgets/PollWidgetResultsView.test.tsx b/met-web/tests/unit/components/widgets/PollWidgetResultsView.test.tsx new file mode 100644 index 000000000..c0f00e228 --- /dev/null +++ b/met-web/tests/unit/components/widgets/PollWidgetResultsView.test.tsx @@ -0,0 +1,98 @@ +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; +import EngagementForm from '../../../../src/components/engagement/form'; +import * as widgetService from 'services/widgetService'; +import * as pollService from 'services/widgetService/PollService'; +import { draftEngagement, pollWidget } from '../factory'; +import { USER_ROLES } from 'services/userService/constants'; +import { setupWidgetTestEnvMock, setupWidgetTestEnvSpy } from './setupWidgetTestEnv'; + +jest.mock('components/map', () => () =>
); +jest.mock('axios'); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.VIEW_PRIVATE_ENGAGEMENTS, USER_ROLES.EDIT_ENGAGEMENT, USER_ROLES.CREATE_ENGAGEMENT], + assignedEngagements: [draftEngagement.id], + }; + }), +})); +const mockCreateWidget = jest.fn(() => Promise.resolve(pollWidget)); + +jest.mock('apiManager/apiSlices/widgets', () => ({ + ...jest.requireActual('apiManager/apiSlices/widgets'), + useCreateWidgetMutation: () => [mockCreateWidget], + useCreateWidgetItemsMutation: () => [mockCreateWidget], + useUpdateWidgetMutation: () => [jest.fn(() => Promise.resolve(pollWidget))], + useDeleteWidgetMutation: () => [jest.fn(() => Promise.resolve())], + useSortWidgetsMutation: () => [jest.fn(() => Promise.resolve())], +})); + +describe('Poll Widget tests', () => { + const mockPoll = { + id: 1, + widget_id: pollWidget.id, + engagement_id: draftEngagement.id, + title: 'Test Poll', + description: 'Description', + status: 'active', + answers: [ + { id: 1, answer_text: 'Option 1' }, + { id: 2, answer_text: 'Option 2' }, + { id: 3, answer_text: 'Option 3' }, + ], + }; + + const mockPollResult = { + poll_id: mockPoll.id, + title: mockPoll.title, + description: mockPoll.description, + total_response: 4, + answers: [ + { answer_id: 1, answer_text: 'Option 1', total_response: 2, percentage: 50 }, + { answer_id: 2, answer_text: 'Option 2', total_response: 2, percentage: 50 }, + { answer_id: 3, answer_text: 'Option 3', total_response: 0, percentage: 0 }, + ], + }; + + beforeAll(() => { + setupWidgetTestEnvMock(); + setupWidgetTestEnvSpy(); + jest.spyOn(widgetService, 'getWidgets').mockReturnValue(Promise.resolve([pollWidget])); + jest.spyOn(pollService, 'fetchPollWidgets').mockReturnValue(Promise.resolve([mockPoll])); + jest.spyOn(pollService, 'fetchPollResults').mockReturnValue(Promise.resolve(mockPollResult)); + }); + + async function renderPollWidgetView() { + await waitFor(() => expect(screen.getByText('Results')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Results')); + expect(screen.getByText('Poll')).toBeVisible(); + await waitFor(() => expect(screen.getByText('Test Poll')).toBeInTheDocument()); + } + + test('Poll widget results is rendered', async () => { + render(); + await renderPollWidgetView(); + expect(screen.getByText(mockPollResult.description)).toBeVisible(); + expect(screen.getByText('Total Votes: 4')).toBeVisible(); + }); + + test('Poll widget answer options and its percentage is rendered', async () => { + render(); + await renderPollWidgetView(); + expect(screen.getByText(mockPollResult.answers[0].answer_text)).toBeVisible(); + expect(screen.getByText(mockPollResult.answers[1].answer_text)).toBeVisible(); + const twoVotes = screen.getAllByText('2 votes'); + expect(twoVotes.length).toBe(2); + expect(twoVotes[0]).toBeVisible(); + expect(twoVotes[1]).toBeVisible(); + expect(screen.getByText('0 votes')).toBeVisible(); + expect(screen.getByText('0%')).toBeVisible(); + const fiftyPercent = screen.getAllByText('50%'); + expect(fiftyPercent.length).toBe(2); + expect(fiftyPercent[0]).toBeVisible(); + expect(fiftyPercent[1]).toBeVisible(); + }); +});