From 0cf44a60278d0311d20f783f6ef8589795f45dd6 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Thu, 25 Jan 2024 13:08:30 -0800 Subject: [PATCH 01/11] DESENG-464: Poll UI basic --- .../schemas/schemas/poll_widget_update.json | 2 +- met-web/package-lock.json | 44 +++ met-web/package.json | 1 + met-web/src/apiManager/endpoints/index.ts | 6 + .../form/EngagementWidgets/Poll/Form.tsx | 374 ++++++++++++++++++ .../EngagementWidgets/Poll/PollContext.tsx | 61 +++ .../EngagementWidgets/Poll/PollDisplay.tsx | 53 +++ .../EngagementWidgets/Poll/PollOptionCard.tsx | 101 +++++ .../form/EngagementWidgets/Poll/index.tsx | 13 + .../EngagementWidgets/WidgetCardSwitch.tsx | 13 + .../EngagementWidgets/WidgetDrawerTabs.tsx | 4 + .../EngagementWidgets/WidgetOptionCards.tsx | 4 + .../form/EngagementWidgets/type.tsx | 1 + .../view/widgets/Poll/PollWidgetView.tsx | 180 +++++++++ .../engagement/view/widgets/WidgetSwitch.tsx | 4 + met-web/src/models/pollWidget.tsx | 23 ++ 16 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PollContext.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PollOptionCard.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/index.tsx create mode 100644 met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx create mode 100644 met-web/src/models/pollWidget.tsx 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-web/package-lock.json b/met-web/package-lock.json index 3b7c378be..4f835c358 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" }, @@ -7390,6 +7391,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", @@ -25545,6 +25551,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", @@ -31954,6 +31977,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", @@ -45511,6 +45539,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 a228feb0b..32480f46e 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -159,6 +159,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/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx new file mode 100644 index 000000000..a0582bb9d --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -0,0 +1,374 @@ +import React, { useContext, useMemo, useEffect } 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, PollStatus } from 'models/pollWidget'; +import PollDisplay from './PollDisplay'; + +interface WidgetState { + id: number; + title: string; + description: string; + answers: PollAnswer[]; + status: string; + widget_id: number; + engagement_id: number; +} + +interface DetailsForm { + title: string; + description: string; + answers: PollAnswer[]; + status: string; +} + +interface StatusDropDownItem { + value: string; + label: string; +} + +const previewStyle = { + backgroundColor: '#f5f5f5', + padding: '1em', + borderRadius: '8px', + marginTop: '1em', + marginBottom: '1em', +}; + +const Form = () => { + const dispatch = useAppDispatch(); + const { widget, isLoadingPollWidget, pollWidget } = useContext(PollContext); + const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const [isCreating, setIsCreating] = React.useState(false); + const [interactionEnabled, setInteractionEnabled] = React.useState(false); + + const newAnswer: PollAnswer = { + id: 0, + answer_text: '', + }; + + const STATUS_ITEMS: StatusDropDownItem[] = useMemo( + () => [ + { + value: 'active', + label: 'Active', + }, + { + value: 'inactive', + label: 'InActive', + }, + ], + [], + ); + + const [pollAnswers, setPollAnswers] = React.useState(pollWidget ? pollWidget.answers : [newAnswer]); + const [pollWidgetState, setPollWidgetState] = React.useState({ + 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, + }); + + useEffect(() => { + if (pollWidget) { + setPollAnswers(pollWidget.answers); + setPollWidgetState(pollWidget); + } + }, [pollWidget]); + + const handleOnSubmit = (event: React.SyntheticEvent) => { + event.preventDefault(); + const answersForSubmission = [...pollAnswers]; + + 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) => { + if (!pollWidget) { + return createPoll(data); + } + return 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; + } + + await patchPoll(widget.id, pollWidget.id, { ...data }); + 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 handleaAnswerTextChange = (e: React.ChangeEvent, index: number, property: string) => { + if (!pollAnswers) { + return; + } + const newValue = e.currentTarget.value; + const newArray = [...pollAnswers]; + if ('answer_text' === property) { + newArray[index].answer_text = newValue; + setPollAnswers([...newArray]); + setPollWidgetState({ ...pollWidgetState, answers: [...newArray] }); + } + }; + + const handleRemoveAnswer = (event: React.ChangeEvent) => { + if (!pollAnswers) { + return; + } + const position = Number(event.target.value); + const dataToSplice: PollAnswer[] = [...pollAnswers]; + dataToSplice.splice(position, 1); + setPollAnswers([...dataToSplice]); + setPollWidgetState({ ...pollWidgetState, answers: [...dataToSplice] }); + }; + + const handleAddAnswer = () => { + if (!pollAnswers) { + return; + } + const newAnswerCorrectIndex = newAnswer; + setPollAnswers([...pollAnswers, newAnswerCorrectIndex]); + }; + + if (isLoadingPollWidget || !widget) { + return ( + + + + + + ); + } + + return ( + + + + + + +
handleOnSubmit(event)} id="timelineForm"> + + + Title + The title must be less than 255 characters. + ) => { + handleTextChange(event, 'title'); + }} + /> + + + Description + ) => { + handleTextChange(event, 'description'); + }} + /> + + + Status + + + + {pollAnswers && + pollAnswers.map((tAnswer, index) => ( + + {'ANSWER ' + (index + 1)} + + + Answer Text + ) => { + handleaAnswerTextChange(answer, index, 'answer_text'); + }} + /> + + + {1 < pollAnswers.length && ( + + ) => { + handleRemoveAnswer(event); + }} + > + Remove Answer + + + )} + + + + + + ))} + + handleAddAnswer()}>Add Answer + + + + Preview + + + + + + + + + Save & Close + + + + handleWidgetDrawerOpen(false)}>Cancel + + + +
+
+
+ ); +}; + +export default Form; 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..f4d7077a3 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import { Radio, RadioGroup, FormControlLabel, FormControl, FormLabel } from '@mui/material'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import { PollWidget } from 'models/pollWidget'; + +// Define a 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/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..2f92bddeb --- /dev/null +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -0,0 +1,180 @@ +import React, { useState, useEffect } from 'react'; +import { MetPaper, MetHeader2 } from 'components/common'; +import { Grid, Skeleton, Divider } from '@mui/material'; +import { Card, CardActions, CardContent } from '@mui/material'; +import { PrimaryButton } from 'components/common'; +import PollDisplay from '../../../form/EngagementWidgets/Poll/PollDisplay'; +import { Widget } from 'models/widget'; +import { useAppDispatch } from 'hooks'; +import { PollWidget } from 'models/pollWidget'; +import { fetchPollWidgets, postPollResponse } from 'services/widgetService/PollService/index'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import Cookies from 'universal-cookie'; + +interface PollWidgetViewProps { + widget: Widget; +} + +interface HttpResponseError extends Error { + response?: { + status: number; + data?: any; + // ... other relevant response properties + }; + // ... other relevant error properties +} + +const cookies = new Cookies(); + +const PollWidgetView = ({ widget }: PollWidgetViewProps) => { + const dispatch = useAppDispatch(); + const [pollWidget, setPollWidget] = useState({ + id: 0, + widget_id: 0, + engagement_id: 0, + title: '', + description: '', + status: '', + answers: [], + }); + const [isLoading, setIsLoading] = useState(true); + const [selectedOption, setSelectedOption] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [interactionEnabled, setInteractionEnabled] = useState(true); + const [responseMessage, setResponseMessage] = useState({ color: 'green', message: 'Thank you for the response.' }); + + const fetchPollDetails = async () => { + try { + const poll_widgets = await fetchPollWidgets(widget.id); + const poll_widget = poll_widgets[poll_widgets.length - 1]; + setPollWidget(poll_widget); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while fetching Engagement widgets information', + }), + ); + } + }; + + useEffect(() => { + // Retrieve the submitted polls array from the cookie + const submittedPolls = cookies.get('submitted_polls') || []; + + // Check if the current widget ID is in the array + if (submittedPolls.includes(widget.id)) { + setIsSubmitted(true); + } + fetchPollDetails(); + }, [widget]); + + // Type guard for HttpResponseError + const isHttpResponseError = (error: unknown): error is HttpResponseError => { + return error instanceof Error && 'response' in error; + }; + + const handleSubmit = async () => { + 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), + }); + } catch (error: unknown) { + if (isHttpResponseError(error)) { + if (error.response && error.response.status === 403) { + setResponseMessage({ color: 'red', message: 'Limit exceeded for this poll.' }); + } else { + setResponseMessage({ color: 'red', message: 'An error occurred while submitting the poll.' }); + } + } + } + // Irrespective of the error mark the poll as submitted to avoid re-submitting + setIsSubmitted(true); + // Set a cookie indicating this poll has been submitted + addToCookie(widget.id); + }; + + const addToCookie = (widget_id: number) => { + // Retrieve the current submitted polls array from the cookie, or initialize it if not present + const submittedPolls = cookies.get('submitted_polls') || []; + + // Add the current widget ID to the array if it's not already there + if (!submittedPolls.includes(widget_id)) { + submittedPolls.push(widget_id); + cookies.set('submitted_polls', submittedPolls, { path: '/' }); + } + }; + + const handleOptionChange = (option: string) => { + setSelectedOption(option); + }; + + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } + + return ( + + + + {widget.title} + + + {/* + {timelineWidget.description} + */} + + + {!isSubmitted ? ( + <> + + handleSubmit()}>Submit + + + ) : ( +

{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/models/pollWidget.tsx b/met-web/src/models/pollWidget.tsx new file mode 100644 index 000000000..474036b22 --- /dev/null +++ b/met-web/src/models/pollWidget.tsx @@ -0,0 +1,23 @@ +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 enum PollStatus { + Active = 'active', + Inactive = 'inactive', +} + +export interface PollResponse { + selected_answer_id: string; +} From 41680d613a8f624af95b68ed573397e3a9de2d53 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Thu, 25 Jan 2024 13:09:44 -0800 Subject: [PATCH 02/11] DESENG-464: Updated service --- .../widgetService/PollService/index.tsx | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 met-web/src/services/widgetService/PollService/index.tsx 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); + } +}; From a28a0b88156bb11be5d336c8ab07d47d7f9b5929 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Thu, 25 Jan 2024 13:10:31 -0800 Subject: [PATCH 03/11] DESENG-464: Updating MODEL --- met-web/src/models/widget.tsx | 1 + 1 file changed, 1 insertion(+) 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, } From bfd0fff3a729f9d253a701a7e07bdb2f56c43eb5 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Thu, 25 Jan 2024 14:50:43 -0800 Subject: [PATCH 04/11] DESENG-464: Poll Editing restricted --- .../form/EngagementWidgets/Poll/Form.tsx | 213 ++++++++++-------- .../view/widgets/Poll/PollWidgetView.tsx | 25 +- 2 files changed, 143 insertions(+), 95 deletions(-) diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index a0582bb9d..ead6243bc 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -11,6 +11,9 @@ import { patchPoll, postPoll } from 'services/widgetService/PollService'; import { WidgetTitle } from '../WidgetTitle'; import { PollAnswer, PollStatus } from 'models/pollWidget'; import PollDisplay from './PollDisplay'; +import { ActionContext } from '../../ActionContext'; +import { EngagementStatus } from 'constants/engagementStatus'; +import Alert from '@mui/material/Alert'; interface WidgetState { id: number; @@ -48,6 +51,8 @@ const Form = () => { const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); const [isCreating, setIsCreating] = React.useState(false); const [interactionEnabled, setInteractionEnabled] = React.useState(false); + const { savedEngagement } = useContext(ActionContext); + const [isEngagementPublished, setIsEngagementPublished] = React.useState(false); const newAnswer: PollAnswer = { id: 0, @@ -84,6 +89,10 @@ const Form = () => { setPollAnswers(pollWidget.answers); setPollWidgetState(pollWidget); } + + if (savedEngagement && savedEngagement.status_id === EngagementStatus.Published) { + setIsEngagementPublished(true); + } }, [pollWidget]); const handleOnSubmit = (event: React.SyntheticEvent) => { @@ -204,6 +213,12 @@ const Form = () => { setPollAnswers([...pollAnswers, newAnswerCorrectIndex]); }; + const engagementPublishedAlert = ( + + Editing of the Poll details is not available once the engagement has been published. + + ); + if (isLoadingPollWidget || !widget) { return ( @@ -224,40 +239,116 @@ const Form = () => { handleOnSubmit(event)} id="timelineForm"> - Title - The title must be less than 255 characters. - ) => { - handleTextChange(event, 'title'); - }} - /> + {engagementPublishedAlert} + {(!isEngagementPublished || (isEngagementPublished && !pollWidget)) && ( + <> + + Title + The title must be less than 255 characters. + ) => { + handleTextChange(event, 'title'); + }} + /> + + + Description + ) => { + handleTextChange(event, 'description'); + }} + /> + + + + + + {pollAnswers && + pollAnswers.map((tAnswer, index) => ( + + + {'ANSWER ' + (index + 1)} + + + + Answer Text + ) => { + handleaAnswerTextChange(answer, index, 'answer_text'); + }} + /> + + + {1 < pollAnswers.length && ( + + ) => { + handleRemoveAnswer(event); + }} + > + Remove Answer + + + )} + + + + + + ))} + + handleAddAnswer()}>Add Answer + + + + )} + - Description - ) => { - handleTextChange(event, 'description'); - }} - /> + + Status - - {pollAnswers && - pollAnswers.map((tAnswer, index) => ( - - {'ANSWER ' + (index + 1)} - - - Answer Text - ) => { - handleaAnswerTextChange(answer, index, 'answer_text'); - }} - /> - - - {1 < pollAnswers.length && ( - - ) => { - handleRemoveAnswer(event); - }} - > - Remove Answer - - - )} - - - - - ))} - - handleAddAnswer()}>Add Answer - + + + Preview diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx index 2f92bddeb..0cda180ae 100644 --- a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -10,6 +10,7 @@ import { PollWidget } from 'models/pollWidget'; import { fetchPollWidgets, postPollResponse } from 'services/widgetService/PollService/index'; import { openNotification } from 'services/notificationService/notificationSlice'; import Cookies from 'universal-cookie'; +import { useAppSelector } from 'hooks'; interface PollWidgetViewProps { widget: Widget; @@ -37,6 +38,8 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { status: '', answers: [], }); + + const isLoggedIn = useAppSelector((state) => state.user.authentication.authenticated); const [isLoading, setIsLoading] = useState(true); const [selectedOption, setSelectedOption] = useState(''); const [isSubmitted, setIsSubmitted] = useState(false); @@ -122,6 +125,10 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { setSelectedOption(option); }; + if (pollWidget && pollWidget.status === 'inactive') { + return null; + } + if (isLoading) { return ( @@ -159,17 +166,21 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { - {!isSubmitted ? ( + {!isLoggedIn && ( <> - - handleSubmit()}>Submit - + {!isSubmitted ? ( + <> + + handleSubmit()}>Submit + + + ) : ( +

{responseMessage.message}

+ )} - ) : ( -

{responseMessage.message}

)}
From 5e2a50aafaec16f08af81169d2305f56ff0a3f58 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Thu, 25 Jan 2024 17:04:32 -0800 Subject: [PATCH 05/11] DESENG-464: Poll UI wrapping up --- .../form/EngagementWidgets/Poll/Form.tsx | 363 +++++++++--------- .../EngagementWidgets/Poll/PollDisplay.tsx | 4 +- .../view/widgets/Poll/PollWidgetView.tsx | 89 +++-- met-web/src/constants/engagementStatus.ts | 5 + met-web/src/models/pollWidget.tsx | 7 - 5 files changed, 238 insertions(+), 230 deletions(-) diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index ead6243bc..f0acdc7cd 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -9,10 +9,10 @@ import { WidgetDrawerContext } from '../WidgetDrawerContext'; import { PollContext } from './PollContext'; import { patchPoll, postPoll } from 'services/widgetService/PollService'; import { WidgetTitle } from '../WidgetTitle'; -import { PollAnswer, PollStatus } from 'models/pollWidget'; +import { PollAnswer } from 'models/pollWidget'; import PollDisplay from './PollDisplay'; import { ActionContext } from '../../ActionContext'; -import { EngagementStatus } from 'constants/engagementStatus'; +import { EngagementStatus, PollStatus } from 'constants/engagementStatus'; import Alert from '@mui/material/Alert'; interface WidgetState { @@ -45,34 +45,23 @@ const previewStyle = { marginBottom: '1em', }; +const STATUS_ITEMS = [ + { value: PollStatus.Active, label: 'Active' }, + { value: PollStatus.Inactive, label: 'InActive' }, +]; + +const interactionEnabled = false; + +const newAnswer = { id: 0, answer_text: '' }; + const Form = () => { const dispatch = useAppDispatch(); const { widget, isLoadingPollWidget, pollWidget } = useContext(PollContext); const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); const [isCreating, setIsCreating] = React.useState(false); - const [interactionEnabled, setInteractionEnabled] = React.useState(false); const { savedEngagement } = useContext(ActionContext); const [isEngagementPublished, setIsEngagementPublished] = React.useState(false); - const newAnswer: PollAnswer = { - id: 0, - answer_text: '', - }; - - const STATUS_ITEMS: StatusDropDownItem[] = useMemo( - () => [ - { - value: 'active', - label: 'Active', - }, - { - value: 'inactive', - label: 'InActive', - }, - ], - [], - ); - const [pollAnswers, setPollAnswers] = React.useState(pollWidget ? pollWidget.answers : [newAnswer]); const [pollWidgetState, setPollWidgetState] = React.useState({ id: pollWidget?.id || 0, @@ -99,6 +88,7 @@ const Form = () => { 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, @@ -229,6 +219,170 @@ const Form = () => { ); } + const pollTitleField = ( + + Title + The title must be less than 255 characters. + ) => { + handleTextChange(event, 'title'); + }} + /> + + ); + + const pollDescriptionField = ( + + Description + ) => { + handleTextChange(event, 'description'); + }} + /> + + ); + + const divider = ( + + + + ); + + const pollAnswersField = ( + + {pollAnswers && + pollAnswers.map((tAnswer, index) => ( + + {'ANSWER ' + (index + 1)} + + + Answer Text + ) => { + handleaAnswerTextChange(answer, index, 'answer_text'); + }} + /> + + + {1 < pollAnswers.length && ( + + ) => { + handleRemoveAnswer(event); + }} + > + Remove Answer + + + )} + + + + + + ))} + + handleAddAnswer()}>Add Answer + + + ); + + const pollStatusField = ( + + Status + + + ); + + const pollPreview = ( + + Preview + {divider} + + + ); + const pollFormButtons = ( + + + + Save & Close + + + + handleWidgetDrawerOpen(false)}>Cancel + + + ); + return ( @@ -243,164 +397,17 @@ const Form = () => { {(!isEngagementPublished || (isEngagementPublished && !pollWidget)) && ( <> - - Title - The title must be less than 255 characters. - ) => { - handleTextChange(event, 'title'); - }} - /> - - - Description - ) => { - handleTextChange(event, 'description'); - }} - /> - - - - - - {pollAnswers && - pollAnswers.map((tAnswer, index) => ( - - - {'ANSWER ' + (index + 1)} - - - - Answer Text - ) => { - handleaAnswerTextChange(answer, index, 'answer_text'); - }} - /> - - - {1 < pollAnswers.length && ( - - ) => { - handleRemoveAnswer(event); - }} - > - Remove Answer - - - )} - - - - - - ))} - - handleAddAnswer()}>Add Answer - - + {pollTitleField} + {pollDescriptionField} + {divider} + {pollAnswersField} )} - - - - - - - Status - - - - - - - - - Preview - - - - - - - - - Save & Close - - - - handleWidgetDrawerOpen(false)}>Cancel - - + {divider} + {pollStatusField} + {divider} + {pollPreview} + {pollFormButtons}
diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx index f4d7077a3..7775d7cfd 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx @@ -1,10 +1,8 @@ import React, { useState } from 'react'; import { Radio, RadioGroup, FormControlLabel, FormControl, FormLabel } from '@mui/material'; -import Card from '@mui/material/Card'; -import CardContent from '@mui/material/CardContent'; import { PollWidget } from 'models/pollWidget'; -// Define a type for the component's props +// type for the component's props interface PollDisplayProps { pollWidget: PollWidget; interactionEnabled: boolean; diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx index 0cda180ae..1a8662987 100644 --- a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { MetPaper, MetHeader2 } from 'components/common'; import { Grid, Skeleton, Divider } from '@mui/material'; -import { Card, CardActions, CardContent } from '@mui/material'; import { PrimaryButton } from 'components/common'; import PollDisplay from '../../../form/EngagementWidgets/Poll/PollDisplay'; import { Widget } from 'models/widget'; @@ -11,7 +10,7 @@ import { fetchPollWidgets, postPollResponse } from 'services/widgetService/PollS import { openNotification } from 'services/notificationService/notificationSlice'; import Cookies from 'universal-cookie'; import { useAppSelector } from 'hooks'; - +import { PollStatus } from 'constants/engagementStatus'; interface PollWidgetViewProps { widget: Widget; } @@ -19,14 +18,29 @@ interface PollWidgetViewProps { interface HttpResponseError extends Error { response?: { status: number; - data?: any; - // ... other relevant response properties }; - // ... other relevant error properties } +const RESPONSE_MESSAGE_SUCCESS = { color: 'green', message: 'Thank you for the response.' }; +const RESPONSE_MESSAGE_ERROR = { color: 'red', message: 'An error occurred while submitting the poll.' }; +const RESPONSE_MESSAGE_LIMIT = { color: 'red', message: 'Limit exceeded for this poll.' }; + const cookies = new Cookies(); +// Custom hook for cookie management +const useSubmittedPolls = () => { + const cookies = new Cookies(); + const getSubmittedPolls = () => cookies.get('submitted_polls') || []; + const addSubmittedPoll = (widget_id: number) => { + const submittedPolls = getSubmittedPolls(); + if (!submittedPolls.includes(widget_id)) { + submittedPolls.push(widget_id); + cookies.set('submitted_polls', submittedPolls, { path: '/' }); + } + }; + return { getSubmittedPolls, addSubmittedPoll }; +}; + const PollWidgetView = ({ widget }: PollWidgetViewProps) => { const dispatch = useAppDispatch(); const [pollWidget, setPollWidget] = useState({ @@ -44,7 +58,16 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { const [selectedOption, setSelectedOption] = useState(''); const [isSubmitted, setIsSubmitted] = useState(false); const [interactionEnabled, setInteractionEnabled] = useState(true); - const [responseMessage, setResponseMessage] = useState({ color: 'green', message: 'Thank you for the response.' }); + const [responseMessage, setResponseMessage] = useState(RESPONSE_MESSAGE_SUCCESS); + const { getSubmittedPolls, addSubmittedPoll } = useSubmittedPolls(); + + useEffect(() => { + // Check if the current widget ID is in the submitted polls + if (getSubmittedPolls().includes(widget.id)) { + setIsSubmitted(true); + } + fetchPollDetails(); + }, [widget]); const fetchPollDetails = async () => { try { @@ -63,18 +86,6 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { ); } }; - - useEffect(() => { - // Retrieve the submitted polls array from the cookie - const submittedPolls = cookies.get('submitted_polls') || []; - - // Check if the current widget ID is in the array - if (submittedPolls.includes(widget.id)) { - setIsSubmitted(true); - } - fetchPollDetails(); - }, [widget]); - // Type guard for HttpResponseError const isHttpResponseError = (error: unknown): error is HttpResponseError => { return error instanceof Error && 'response' in error; @@ -96,36 +107,33 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { selected_answer_id: parseInt(selectedOption), }); } catch (error: unknown) { - if (isHttpResponseError(error)) { - if (error.response && error.response.status === 403) { - setResponseMessage({ color: 'red', message: 'Limit exceeded for this poll.' }); - } else { - setResponseMessage({ color: 'red', message: 'An error occurred while submitting the poll.' }); - } - } + const responseMessage = + isHttpResponseError(error) && error.response?.status === 403 + ? RESPONSE_MESSAGE_LIMIT + : RESPONSE_MESSAGE_ERROR; + setResponseMessage(responseMessage); + } finally { + setInteractionEnabled(true); } // Irrespective of the error mark the poll as submitted to avoid re-submitting setIsSubmitted(true); - // Set a cookie indicating this poll has been submitted - addToCookie(widget.id); - }; - - const addToCookie = (widget_id: number) => { - // Retrieve the current submitted polls array from the cookie, or initialize it if not present - const submittedPolls = cookies.get('submitted_polls') || []; - - // Add the current widget ID to the array if it's not already there - if (!submittedPolls.includes(widget_id)) { - submittedPolls.push(widget_id); - cookies.set('submitted_polls', submittedPolls, { path: '/' }); - } + // Add poll to the submitted list of polls + addSubmittedPoll(widget.id); }; const handleOptionChange = (option: string) => { setSelectedOption(option); }; - if (pollWidget && pollWidget.status === 'inactive') { + const isPollNotReady = () => { + if (pollWidget) { + return pollWidget.status === PollStatus.Inactive || pollWidget.answers.length == 0; + } else { + return true; + } + }; + + if (isPollNotReady()) { return null; } @@ -160,9 +168,6 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { {widget.title}
- {/* - {timelineWidget.description} - */} Date: Fri, 26 Jan 2024 16:30:23 -0800 Subject: [PATCH 06/11] DESENF-464: Poll UI wrapped up --- met-api/src/met_api/resources/widget_poll.py | 97 +++++++++++++++---- .../met_api/services/widget_poll_service.py | 43 ++++++-- met-api/tests/unit/api/test_widget_poll.py | 35 +++++-- .../unit/services/test_widget_poll_service.py | 46 +++++++-- .../form/EngagementWidgets/Poll/Form.tsx | 16 +-- .../view/widgets/Poll/PollWidgetView.tsx | 20 +--- met-web/src/hooks.ts | 18 ++++ 7 files changed, 208 insertions(+), 67 deletions(-) diff --git a/met-api/src/met_api/resources/widget_poll.py b/met-api/src/met_api/resources/widget_poll.py index 02dab70ed..5e146b011 100644 --- a/met-api/src/met_api/resources/widget_poll.py +++ b/met-api/src/met_api/resources/widget_poll.py @@ -13,7 +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') +API = Namespace( + 'widget_polls', description='Endpoints for Poll Widget Management' +) INVALID_REQUEST_MESSAGE = 'Invalid request format' @@ -28,7 +30,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 @@ -41,8 +46,13 @@ def post(widget_id): request_json = request.get_json() valid_format, errors = Polls.validate_request_format(request_json) if not valid_format: - return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST - widget_poll = WidgetPollService().create_poll(widget_id, request_json) + return { + 'message': INVALID_REQUEST_MESSAGE, + 'errors': errors, + }, 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 @@ -70,9 +80,22 @@ def patch(widget_id, poll_widget_id): request_json = request.get_json() valid_format, errors = Poll.validate_request_format(request_json) 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) + return { + 'message': INVALID_REQUEST_MESSAGE, + 'errors': errors, + }, HTTPStatus.BAD_REQUEST + # Check if the poll engagement is published + if Poll.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): + return { + 'message': 'Cannot update poll widget as the engagement is published' + }, 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 @@ -80,11 +103,18 @@ def patch(widget_id, poll_widget_id): @staticmethod def validate_request_format(data): """Validate request format.""" - valid_format, errors = schema_utils.validate(data, 'poll_widget_update') + valid_format, errors = schema_utils.validate( + data, 'poll_widget_update' + ) if not valid_format: errors = schema_utils.serialize(errors) return valid_format, errors + @staticmethod + def is_poll_engagement_published(poll_id): + """Check if engagement of this poll is published or not.""" + return WidgetPollService.is_poll_engagement_published(poll_id) + @cors_preflight('POST') @API.route('//responses') @@ -97,17 +127,37 @@ def post(widget_id, poll_widget_id): """Record a response for a given poll widget.""" try: response_data = request.get_json() - valid_format, errors = PollResponseRecord.validate_request_format(response_data) + valid_format, errors = PollResponseRecord.validate_request_format( + response_data + ) if not valid_format: - return {'message': INVALID_REQUEST_MESSAGE, 'errors': errors}, HTTPStatus.BAD_REQUEST + return { + 'message': INVALID_REQUEST_MESSAGE, + 'errors': errors, + }, HTTPStatus.BAD_REQUEST - response_dict = PollResponseRecord.prepare_response_data(response_data, widget_id, poll_widget_id) + 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 { + 'message': 'Poll is not active' + }, HTTPStatus.BAD_REQUEST + + if not PollResponseRecord.is_poll_engagement_published( + poll_widget_id + ): + return { + 'message': 'Poll engagement is not published' + }, 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) @@ -136,16 +186,27 @@ def is_poll_active(poll_id): """Check if poll active or not.""" return WidgetPollService.is_poll_active(poll_id) + @staticmethod + def is_poll_engagement_published(poll_id): + """Check if engagement of this poll is published or not.""" + return WidgetPollService.is_poll_engagement_published(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) + 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 recorded successfully' + }, HTTPStatus.CREATED - return {'message': 'Response failed to record'}, HTTPStatus.INTERNAL_SERVER_ERROR + return { + 'message': 'Response failed to record' + }, HTTPStatus.INTERNAL_SERVER_ERROR 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..87222a185 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 active.""" + 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,8 +120,13 @@ 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): diff --git a/met-api/tests/unit/api/test_widget_poll.py b/met-api/tests/unit/api/test_widget_poll.py index 13ae08479..41d7c76b0 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,27 @@ 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 + rv = client.patch( + f'/api/widgets/{widget.id}/polls/{poll.id}', + data=json.dumps(data), + headers=headers, + content_type=ContentType.JSON.value, + ) + + # 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 @@ -135,15 +156,13 @@ def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): ) # Checking response - assert rv.status_code == HTTPStatus.OK - assert rv.json.get('title') == data.get('title') - assert len(rv.json.get('answers')) == 1 + 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/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index f0acdc7cd..b9c9d8d3c 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -32,11 +32,6 @@ interface DetailsForm { status: string; } -interface StatusDropDownItem { - value: string; - label: string; -} - const previewStyle = { backgroundColor: '#f5f5f5', padding: '1em', @@ -52,9 +47,8 @@ const STATUS_ITEMS = [ const interactionEnabled = false; -const newAnswer = { id: 0, answer_text: '' }; - const Form = () => { + const newAnswer = { id: 0, answer_text: '' }; const dispatch = useAppDispatch(); const { widget, isLoadingPollWidget, pollWidget } = useContext(PollContext); const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); @@ -133,7 +127,13 @@ const Form = () => { return; } - await patchPoll(widget.id, pollWidget.id, { ...data }); + 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' })); }; diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx index 1a8662987..9ec766e65 100644 --- a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -4,11 +4,11 @@ import { Grid, Skeleton, Divider } from '@mui/material'; import { PrimaryButton } from 'components/common'; import PollDisplay from '../../../form/EngagementWidgets/Poll/PollDisplay'; import { Widget } from 'models/widget'; -import { useAppDispatch } from 'hooks'; +import { useAppDispatch, useSubmittedPolls } from 'hooks'; import { PollWidget } from 'models/pollWidget'; import { fetchPollWidgets, postPollResponse } from 'services/widgetService/PollService/index'; import { openNotification } from 'services/notificationService/notificationSlice'; -import Cookies from 'universal-cookie'; + import { useAppSelector } from 'hooks'; import { PollStatus } from 'constants/engagementStatus'; interface PollWidgetViewProps { @@ -25,22 +25,6 @@ const RESPONSE_MESSAGE_SUCCESS = { color: 'green', message: 'Thank you for the r const RESPONSE_MESSAGE_ERROR = { color: 'red', message: 'An error occurred while submitting the poll.' }; const RESPONSE_MESSAGE_LIMIT = { color: 'red', message: 'Limit exceeded for this poll.' }; -const cookies = new Cookies(); - -// Custom hook for cookie management -const useSubmittedPolls = () => { - const cookies = new Cookies(); - const getSubmittedPolls = () => cookies.get('submitted_polls') || []; - const addSubmittedPoll = (widget_id: number) => { - const submittedPolls = getSubmittedPolls(); - if (!submittedPolls.includes(widget_id)) { - submittedPolls.push(widget_id); - cookies.set('submitted_polls', submittedPolls, { path: '/' }); - } - }; - return { getSubmittedPolls, addSubmittedPoll }; -}; - const PollWidgetView = ({ widget }: PollWidgetViewProps) => { const dispatch = useAppDispatch(); const [pollWidget, setPollWidget] = useState({ 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 }; +}; From 61777974e1ea1108c929cb927a6c8ff165935883 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Fri, 26 Jan 2024 16:32:45 -0800 Subject: [PATCH 07/11] Changelog updated --- CHANGELOG.MD | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 0c7dcb001..d1e9d2bc1 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,3 +1,8 @@ +## 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 24, 2024 - **Task** Update default project type to GDX for all deployments by default. [🎟️DESENG-472](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-472) - Set the default project type to GDX on all continuous deployment (CD) files. From d6780cc05be96362cd60dc9506c3d278b82145c8 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Fri, 26 Jan 2024 16:58:39 -0800 Subject: [PATCH 08/11] Fixing issues --- .../form/EngagementWidgets/Poll/Form.tsx | 83 +++++++++---------- .../view/widgets/Poll/PollWidgetView.tsx | 15 ++-- 2 files changed, 46 insertions(+), 52 deletions(-) diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index b9c9d8d3c..952933393 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useMemo, useEffect } from 'react'; +import React, { useContext, useEffect } 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'; @@ -171,7 +171,7 @@ const Form = () => { } }; - const handleaAnswerTextChange = (e: React.ChangeEvent, index: number, property: string) => { + const handleAnswerTextChange = (e: React.ChangeEvent, index: number, property: string) => { if (!pollAnswers) { return; } @@ -279,51 +279,50 @@ const Form = () => { spacing={2} mt={'3em'} > - {pollAnswers && - pollAnswers.map((tAnswer, index) => ( - - {'ANSWER ' + (index + 1)} + {pollAnswers?.map((tAnswer, index) => ( + + {'ANSWER ' + (index + 1)} + + + Answer Text + ) => { + handleAnswerTextChange(e, index, 'answer_text'); + }} + /> + - - Answer Text - ) => { - handleaAnswerTextChange(answer, index, 'answer_text'); + {pollAnswers.length > 1 && ( + + ) => { + handleRemoveAnswer(e); }} - /> + > + Remove Answer + + )} - {1 < pollAnswers.length && ( - - ) => { - handleRemoveAnswer(event); - }} - > - Remove Answer - - - )} - - - - + + - ))} + + ))} handleAddAnswer()}>Add Answer diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx index 9ec766e65..4e02e99b0 100644 --- a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -1,15 +1,12 @@ import React, { useState, useEffect } from 'react'; -import { MetPaper, MetHeader2 } from 'components/common'; +import { MetPaper, MetHeader2, PrimaryButton } from 'components/common'; import { Grid, Skeleton, Divider } from '@mui/material'; -import { PrimaryButton } from 'components/common'; import PollDisplay from '../../../form/EngagementWidgets/Poll/PollDisplay'; import { Widget } from 'models/widget'; -import { useAppDispatch, useSubmittedPolls } from 'hooks'; +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 { useAppSelector } from 'hooks'; import { PollStatus } from 'constants/engagementStatus'; interface PollWidgetViewProps { widget: Widget; @@ -161,11 +158,9 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { {!isLoggedIn && ( <> {!isSubmitted ? ( - <> - - handleSubmit()}>Submit - - + + handleSubmit()}>Submit + ) : (

{responseMessage.message}

)} From d620ab565f70395396e8681434f3d79be7f12be7 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Sat, 27 Jan 2024 20:04:06 -0800 Subject: [PATCH 09/11] DESENG-464: Refactoring Poll Widget --- .../met_api/services/widget_poll_service.py | 14 +- .../form/EngagementWidgets/Poll/Form.tsx | 120 ++---------------- .../EngagementWidgets/Poll/PollAnswerForm.tsx | 65 ++++++++++ .../EngagementWidgets/Poll/PollWidget.hook.ts | 37 ++++++ .../Poll/PolllAnswerItemForm.tsx | 42 ++++++ 5 files changed, 162 insertions(+), 116 deletions(-) create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PollAnswerForm.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PollWidget.hook.ts create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Poll/PolllAnswerItemForm.tsx 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 87222a185..57d54881b 100644 --- a/met-api/src/met_api/services/widget_poll_service.py +++ b/met-api/src/met_api/services/widget_poll_service.py @@ -90,7 +90,7 @@ def is_poll_active(poll_id: int) -> bool: @staticmethod def is_poll_engagement_published(poll_id: int) -> bool: - """Check if the poll is active.""" + """Check if the poll is published.""" try: poll = WidgetPollService.get_poll_by_id(poll_id) engagement = EngagementService().get_engagement(poll.engagement_id) @@ -131,6 +131,12 @@ def _check_authorization(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 Exception as exc: + raise BusinessException(str(exc), HTTPStatus.BAD_REQUEST) from exc diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index 952933393..e2caf4264 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -14,6 +14,8 @@ import PollDisplay from './PollDisplay'; import { ActionContext } from '../../ActionContext'; import { EngagementStatus, PollStatus } from 'constants/engagementStatus'; import Alert from '@mui/material/Alert'; +import usePollWidgetState from './PollWidget.hook'; +import PollAnswerForm from './PollAnswerForm'; interface WidgetState { id: number; @@ -48,35 +50,13 @@ const STATUS_ITEMS = [ const interactionEnabled = false; const Form = () => { - const newAnswer = { id: 0, answer_text: '' }; const dispatch = useAppDispatch(); const { widget, isLoadingPollWidget, pollWidget } = useContext(PollContext); const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); const [isCreating, setIsCreating] = React.useState(false); const { savedEngagement } = useContext(ActionContext); - const [isEngagementPublished, setIsEngagementPublished] = React.useState(false); - - const [pollAnswers, setPollAnswers] = React.useState(pollWidget ? pollWidget.answers : [newAnswer]); - const [pollWidgetState, setPollWidgetState] = React.useState({ - 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, - }); - - useEffect(() => { - if (pollWidget) { - setPollAnswers(pollWidget.answers); - setPollWidgetState(pollWidget); - } - - if (savedEngagement && savedEngagement.status_id === EngagementStatus.Published) { - setIsEngagementPublished(true); - } - }, [pollWidget]); + const { pollAnswers, setPollAnswers, pollWidgetState, setPollWidgetState, isEngagementPublished } = + usePollWidgetState(pollWidget, savedEngagement, widget); const handleOnSubmit = (event: React.SyntheticEvent) => { event.preventDefault(); @@ -171,36 +151,9 @@ const Form = () => { } }; - const handleAnswerTextChange = (e: React.ChangeEvent, index: number, property: string) => { - if (!pollAnswers) { - return; - } - const newValue = e.currentTarget.value; - const newArray = [...pollAnswers]; - if ('answer_text' === property) { - newArray[index].answer_text = newValue; - setPollAnswers([...newArray]); - setPollWidgetState({ ...pollWidgetState, answers: [...newArray] }); - } - }; - - const handleRemoveAnswer = (event: React.ChangeEvent) => { - if (!pollAnswers) { - return; - } - const position = Number(event.target.value); - const dataToSplice: PollAnswer[] = [...pollAnswers]; - dataToSplice.splice(position, 1); - setPollAnswers([...dataToSplice]); - setPollWidgetState({ ...pollWidgetState, answers: [...dataToSplice] }); - }; - - const handleAddAnswer = () => { - if (!pollAnswers) { - return; - } - const newAnswerCorrectIndex = newAnswer; - setPollAnswers([...pollAnswers, newAnswerCorrectIndex]); + const handlePollAnswersChange = (answers: PollAnswer[]) => { + setPollAnswers(answers); + setPollWidgetState({ ...pollWidgetState, answers: [...answers] }); }; const engagementPublishedAlert = ( @@ -269,64 +222,7 @@ const Form = () => { ); const pollAnswersField = ( - - {pollAnswers?.map((tAnswer, index) => ( - - {'ANSWER ' + (index + 1)} - - - Answer Text - ) => { - handleAnswerTextChange(e, index, 'answer_text'); - }} - /> - - - {pollAnswers.length > 1 && ( - - ) => { - handleRemoveAnswer(e); - }} - > - Remove Answer - - - )} - - - - - - ))} - - handleAddAnswer()}>Add Answer - - + ); const pollStatusField = ( 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/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; From 4d4b1388fa8bbad541e7060a8fbc50d6ff005473 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Mon, 29 Jan 2024 09:14:47 -0800 Subject: [PATCH 10/11] Fixed Build issues with github --- .../components/engagement/form/ActionContext.tsx | 8 ++------ .../form/EngagementWidgets/Poll/Form.tsx | 14 ++------------ 2 files changed, 4 insertions(+), 18 deletions(-) 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 index e2caf4264..a1a203307 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from 'react'; +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'; @@ -12,21 +12,11 @@ import { WidgetTitle } from '../WidgetTitle'; import { PollAnswer } from 'models/pollWidget'; import PollDisplay from './PollDisplay'; import { ActionContext } from '../../ActionContext'; -import { EngagementStatus, PollStatus } from 'constants/engagementStatus'; +import { PollStatus } from 'constants/engagementStatus'; import Alert from '@mui/material/Alert'; import usePollWidgetState from './PollWidget.hook'; import PollAnswerForm from './PollAnswerForm'; -interface WidgetState { - id: number; - title: string; - description: string; - answers: PollAnswer[]; - status: string; - widget_id: number; - engagement_id: number; -} - interface DetailsForm { title: string; description: string; From da2ce7fccc5ace5b0e7a09757ac2b8dfd2381280 Mon Sep 17 00:00:00 2001 From: Ratheesh kumar R Date: Tue, 30 Jan 2024 16:02:37 -0800 Subject: [PATCH 11/11] Fixing review comments --- met-api/src/met_api/resources/widget_poll.py | 182 +++++++----------- .../met_api/services/widget_poll_service.py | 2 +- met-api/tests/unit/api/test_widget_poll.py | 2 + .../form/EngagementWidgets/Poll/Form.tsx | 7 +- .../EngagementWidgets/Poll/PollDisplay.tsx | 2 +- .../view/widgets/Poll/PollWidgetView.tsx | 56 ++++-- 6 files changed, 111 insertions(+), 140 deletions(-) diff --git a/met-api/src/met_api/resources/widget_poll.py b/met-api/src/met_api/resources/widget_poll.py index 5e146b011..37fe30320 100644 --- a/met-api/src/met_api/resources/widget_poll.py +++ b/met-api/src/met_api/resources/widget_poll.py @@ -16,7 +16,6 @@ API = Namespace( 'widget_polls', description='Endpoints for Poll Widget Management' ) -INVALID_REQUEST_MESSAGE = 'Invalid request format' @cors_preflight('GET, POST') @@ -44,12 +43,15 @@ 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 + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) + widget_poll = WidgetPollService().create_poll( widget_id, request_json ) @@ -57,14 +59,6 @@ def post(widget_id): 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('/') @@ -78,20 +72,23 @@ 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 + raise BusinessException( + error=schema_utils.serialize(errors), + status_code=HTTPStatus.BAD_REQUEST, + ) # Check if the poll engagement is published - if Poll.is_poll_engagement_published(poll_widget_id): + 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): - return { - 'message': 'Cannot update poll widget as the engagement is published' - }, HTTPStatus.BAD_REQUEST + 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 @@ -100,21 +97,6 @@ def patch(widget_id, poll_widget_id): 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 - - @staticmethod - def is_poll_engagement_published(poll_id): - """Check if engagement of this poll is published or not.""" - return WidgetPollService.is_poll_engagement_published(poll_id) - @cors_preflight('POST') @API.route('//responses') @@ -124,89 +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 not PollResponseRecord.is_poll_engagement_published( + 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 ): - return { - 'message': 'Poll engagement is not published' - }, HTTPStatus.BAD_REQUEST - - if PollResponseRecord.is_poll_limit_exceeded( - poll_widget_id, response_dict['participant_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': 'Limit exceeded for this poll' - }, HTTPStatus.FORBIDDEN + 'message': 'Response recorded successfully' + }, HTTPStatus.CREATED - return PollResponseRecord.record_poll_response(response_dict) + 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_engagement_published(poll_id): - """Check if engagement of this poll is published or not.""" - return WidgetPollService.is_poll_engagement_published(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/services/widget_poll_service.py b/met-api/src/met_api/services/widget_poll_service.py index 57d54881b..116732fa6 100644 --- a/met-api/src/met_api/services/widget_poll_service.py +++ b/met-api/src/met_api/services/widget_poll_service.py @@ -138,5 +138,5 @@ def _handle_poll_answers(poll_id: int, poll_data: dict): PollAnswerService.create_bulk_poll_answers( poll_id, answers_data ) - except Exception as exc: + 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 41d7c76b0..b38e5d8da 100644 --- a/met-api/tests/unit/api/test_widget_poll.py +++ b/met-api/tests/unit/api/test_widget_poll.py @@ -135,6 +135,8 @@ def test_update_poll_widget(client, jwt, session, setup_admin_user_and_claims): content_type=ContentType.JSON.value, ) + assert rv.status_code == HTTPStatus.OK + # Checking updating title if Engagement is published engagement = factory_engagement_model(status=Status.Published.value) diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx index a1a203307..df4e41de4 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/Form.tsx @@ -64,12 +64,7 @@ const Form = () => { onSubmit(restructuredData); }; - const savePollWidget = (data: DetailsForm) => { - if (!pollWidget) { - return createPoll(data); - } - return updatePoll(data); - }; + const savePollWidget = (data: DetailsForm) => (!pollWidget ? createPoll(data) : updatePoll(data)); const createPoll = async (data: DetailsForm) => { if (!widget) { diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx index 7775d7cfd..058e821c6 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/Poll/PollDisplay.tsx @@ -37,7 +37,7 @@ const PollDisplay = ({ pollWidget, interactionEnabled, onOptionChange }: PollDis > {pollWidget.answers.map((answer, index) => ( } label={answer.answer_text} diff --git a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx index 4e02e99b0..e28f9a2a5 100644 --- a/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx +++ b/met-web/src/components/engagement/view/widgets/Poll/PollWidgetView.tsx @@ -18,9 +18,15 @@ interface HttpResponseError extends Error { }; } -const RESPONSE_MESSAGE_SUCCESS = { color: 'green', message: 'Thank you for the response.' }; -const RESPONSE_MESSAGE_ERROR = { color: 'red', message: 'An error occurred while submitting the poll.' }; -const RESPONSE_MESSAGE_LIMIT = { color: 'red', message: 'Limit exceeded for this poll.' }; +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(); @@ -39,22 +45,23 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { const [selectedOption, setSelectedOption] = useState(''); const [isSubmitted, setIsSubmitted] = useState(false); const [interactionEnabled, setInteractionEnabled] = useState(true); - const [responseMessage, setResponseMessage] = useState(RESPONSE_MESSAGE_SUCCESS); + 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 poll_widgets = await fetchPollWidgets(widget.id); - const poll_widget = poll_widgets[poll_widgets.length - 1]; - setPollWidget(poll_widget); + const pollWidgets = await fetchPollWidgets(widget.id); + const pollWidget = pollWidgets[pollWidgets.length - 1]; + setPollWidget(pollWidget); setIsLoading(false); } catch (error) { setIsLoading(false); @@ -73,6 +80,9 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { }; const handleSubmit = async () => { + // Resetting error message + setResponseMessage(null); + if (selectedOption == '') { dispatch( openNotification({ @@ -87,19 +97,24 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { await postPollResponse(widget.id, pollWidget.id, { selected_answer_id: parseInt(selectedOption), }); + + setIsSubmitted(true); + addSubmittedPoll(widget.id); + setResponseMessage(RESPONSE_MESSAGE_SUCCESS); } catch (error: unknown) { - const responseMessage = - isHttpResponseError(error) && error.response?.status === 403 - ? RESPONSE_MESSAGE_LIMIT - : RESPONSE_MESSAGE_ERROR; + 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); - } finally { - setInteractionEnabled(true); } - // Irrespective of the error mark the poll as submitted to avoid re-submitting - setIsSubmitted(true); - // Add poll to the submitted list of polls - addSubmittedPoll(widget.id); }; const handleOptionChange = (option: string) => { @@ -157,12 +172,13 @@ const PollWidgetView = ({ widget }: PollWidgetViewProps) => { /> {!isLoggedIn && ( <> - {!isSubmitted ? ( + {!isSubmitted && ( handleSubmit()}>Submit - ) : ( -

{responseMessage.message}

+ )} + {responseMessage?.message && ( +

{responseMessage?.message}

)} )}