diff --git a/backend/app/conversations/feedback/routes/routes.py b/backend/app/conversations/feedback/routes/routes.py index 607dbc372..91172385e 100644 --- a/backend/app/conversations/feedback/routes/routes.py +++ b/backend/app/conversations/feedback/routes/routes.py @@ -14,6 +14,7 @@ from app.context_vars import session_id_ctx_var, user_id_ctx_var, client_id_ctx_var from app.conversations.feedback.services.errors import InvalidOptionError, InvalidQuestionError, QuestionsFileError from app.conversations.feedback.services.service import IUserFeedbackService, UserFeedbackService, NewFeedbackSpec +from app.conversations.feedback.services.types import QuestionsConfig from app.errors.constants import NO_PERMISSION_FOR_SESSION from app.errors.errors import UnauthorizedSessionAccessError from app.metrics.services.get_metrics_service import get_metrics_service @@ -72,6 +73,32 @@ def add_user_feedback_routes(users_router: APIRouter, auth: Authentication): """ router = APIRouter(prefix="/feedback", tags=["users-feedback"]) + @router.get("/questions", + status_code=HTTPStatus.OK, + response_model=QuestionsConfig, + responses={ + HTTPStatus.INTERNAL_SERVER_ERROR: {"model": HTTPErrorResponse} + }, + name="get questions configuration", + description="Get the questions configuration for the feedback form" + ) + async def _get_questions_config( + user_feedback_service: IUserFeedbackService = Depends(_get_user_feedback_service) + ) -> QuestionsConfig: + """ + Get the questions configuration for the feedback form. + + :param user_feedback_service: Service for managing user feedback + :return: The questions configuration + :raises HTTPException: If there's an error loading the questions + """ + try: + questions_data = await user_feedback_service.get_questions_config() + return questions_data + except QuestionsFileError as e: + logger.exception(e) + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to load questions configuration") + @router.patch("", status_code=HTTPStatus.OK, response_model=FeedbackResponse, diff --git a/backend/app/conversations/feedback/routes/test_routes.py b/backend/app/conversations/feedback/routes/test_routes.py index 4d72254b1..17b726c97 100644 --- a/backend/app/conversations/feedback/routes/test_routes.py +++ b/backend/app/conversations/feedback/routes/test_routes.py @@ -12,11 +12,11 @@ from fastapi import FastAPI, APIRouter from fastapi.testclient import TestClient -from app.conversations.feedback.services.errors import InvalidQuestionError, InvalidOptionError +from app.conversations.feedback.services.errors import InvalidQuestionError, InvalidOptionError, QuestionsFileError from app.conversations.feedback.services.service import IUserFeedbackService from app.conversations.feedback.services.types import NewFeedbackSpec, NewFeedbackVersionSpec, NewFeedbackItemSpec, \ Feedback, \ - Version, FeedbackItem, Answer + Version, FeedbackItem, Answer, QuestionsConfig from app.users.auth import UserInfo from app.users.get_user_preferences_repository import get_user_preferences_repository from app.users.repositories import IUserPreferenceRepository @@ -84,6 +84,9 @@ async def upsert_user_feedback(self, user_id: str, session_id: int, feedback: Fe async def get_answered_questions(self, user_id: str) -> list[int]: raise NotImplementedError() + async def get_questions_config(self) -> QuestionsConfig: + raise NotImplementedError() + mocked_feedback_service = MockedFeedbackService() # Mock the user preferences repository @@ -351,3 +354,55 @@ async def test_upsert_feedback_payload_too_large(self, authenticated_client_with assert "payload" in response.json()["detail"].lower() # AND the service's upsert_user_feedback method was not called _upsert_spy.assert_not_called() + + @pytest.mark.asyncio + async def test_get_questions_config_success(self, authenticated_client_with_mocks: TestClientWithMocks): + client, mocked_service, _, _ = authenticated_client_with_mocks + # GIVEN a session ID for which the questions configuration is requested + given_session_id = 123 + + # GIVEN a valid questions configuration + given_config: QuestionsConfig = { + "test_question": { + "question_text": "Test question", + "description": "Test description", + "comment_placeholder": "Test placeholder", + "type": "yes_no", + "show_comments_on": "yes" + } + } + mocked_service.get_questions_config = AsyncMock(return_value=given_config) + + # WHEN getting the questions configuration + response = client.get(f"/conversations/{given_session_id}/feedback/questions",) + + # THEN the response should be OK + assert response.status_code == HTTPStatus.OK + + # AND the response should contain the questions configuration + actual_config = response.json() + assert actual_config == given_config + + # AND the service's get_questions_config method was called + mocked_service.get_questions_config.assert_called_once() + + @pytest.mark.asyncio + async def test_get_questions_config_error(self, authenticated_client_with_mocks: TestClientWithMocks): + client, mocked_service, _, _ = authenticated_client_with_mocks + # GIVEN a session ID for which the questions configuration is requested + given_session_id = 123 + + # GIVEN the service raises a QuestionsFileError + mocked_service.get_questions_config = AsyncMock(side_effect=QuestionsFileError("Test error")) + + # WHEN getting the questions configuration + response = client.get(f"/conversations/{given_session_id}/feedback/questions",) + + # THEN the response should be INTERNAL_SERVER_ERROR + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + # AND the response should contain the error message + assert response.json()["detail"] == "Failed to load questions configuration" + + # AND the service's get_questions_config method was called + mocked_service.get_questions_config.assert_called_once() diff --git a/backend/app/conversations/feedback/services/questions-en.json b/backend/app/conversations/feedback/services/questions-en.json index 905b951d6..d53d033e5 100644 --- a/backend/app/conversations/feedback/services/questions-en.json +++ b/backend/app/conversations/feedback/services/questions-en.json @@ -2,17 +2,29 @@ "interaction_ease": { "question_text": "How easy or difficult was it to interact with Compass and understand its responses?", "description": "This question is used to measure the Customer Effort Score (CES). The response is on a scale from 1 (Very difficult) to 5 (Very easy).", - "comment_placeholder": "Please explain your rating and share any suggestions for improving the ease of interaction with Compass." + "comment_placeholder": "Please explain your rating and share any suggestions for improving the ease of interaction with Compass.", + "low_rating_label": "Very difficult", + "high_rating_label": "Very easy", + "type": "rating", + "max_rating": 5, + "display_rating": true }, "clarity_of_skills": { "question_text": "Did Compass help you gain a clearer understanding of your skills? If not, why?", "description": "This question aims to measure Perceived Usefulness (PU). The user can answer TRUE (indicating Compass helped) or FALSE (indicating Compass did not help), with an optional text field for additional comments.", - "comment_placeholder": "Please share why Compass did not help you gain a clearer understanding of your skills." + "comment_placeholder": "Please share why Compass did not help you gain a clearer understanding of your skills.", + "type": "yes_no", + "show_comments_on": "no" }, "satisfaction_with_compass": { "question_text": "How satisfied are you with Compass?", "description": "This question is used to measure the Customer Satisfaction Score (CSAT). The response is on a scale from 1 (Very dissatisfied) to 5 (Very satisfied).", - "comment_placeholder": null + "comment_placeholder": null, + "type": "rating", + "max_rating": 5, + "display_rating": true, + "low_rating_label": "Very dissatisfied", + "high_rating_label": "Very satisfied" }, "work_experience_accuracy": { "question_text": "Were there any aspects of your work experience information identified by Compass that were inaccurate?", @@ -25,31 +37,47 @@ "other": "Other" }, "description": "This question to identify specific inaccuracies in the work experience information. The response is an array of selected option keys, with optional additional comments.", - "comment_placeholder": "Please provide more details about the inaccuracies in your work experience information identified by Compass." + "comment_placeholder": "Please provide more details about the inaccuracies in your work experience information identified by Compass.", + "type": "checkbox", + "low_rating_label": "Inaccurate", + "high_rating_label": "Very accurate" }, "incorrect_skills": { "question_text": "Are there any skills that Compass incorrectly identified for you?", "description": "This question aims to indentify incorrectly identified skills The user can answer TRUE (indicating incorrect skills) or FALSE (indicating no incorrect skills). In case of TRUE, the user can provide additional comments.", - "comment_placeholder": "Please list any skills that Compass incorrectly identified for you." + "comment_placeholder": "Please list any skills that Compass incorrectly identified for you.", + "type": "yes_no", + "show_comments_on": "yes" }, "missing_skills": { "question_text": "Are there any skills you have that Compass missed and did not identify?", "description": "This question aims to identify missing skills. Responses should be TRUE (indicating missing skills) or FALSE (indicating no missing skills). In case of TRUE, the user can provide additional comments.", - "comment_placeholder": "Please list any skills you have that Compass did not identify." + "comment_placeholder": "Please list any skills you have that Compass did not identify.", + "type": "yes_no", + "show_comments_on": "yes" }, "perceived_bias": { "question_text": "Did you feel that Compass treated you unfairly by making assumptions about your background, language, or other personal characteristics? If so, please describe your experience.", "description": "This question aims to identify perceived bias. Responses should be TRUE (indicating bias) or FALSE (indicating no bias), with an optional text field for additional comments.", - "comment_placeholder": "Please share more details about your experience. Include specific examples of when you felt Compass treated you unfairly or made assumptions about your background, language, or other personal characteristics." + "comment_placeholder": "Please share more details about your experience. Include specific examples of when you felt Compass treated you unfairly or made assumptions about your background, language, or other personal characteristics.", + "type": "yes_no", + "show_comments_on": "yes" }, "recommendation": { "question_text": "How likely are you to recommend Compass to other job seekers?", "description": "This question is used to measure the Net Promoter Score (NPS). The response is on a scale from 1 (Not at all likely) to 5 (Extremely likely).", - "comment_placeholder": null + "comment_placeholder": null, + "type": "rating", + "max_rating": 5, + "display_rating": true, + "low_rating_label": "Unlikely", + "high_rating_label": "Likely" }, "additional_feedback": { "question_text": "Please share any additional feedback or suggestions you have for improving Compass.", "description": "Used to collect any additional feedback or suggestions for improving Compass from the user. The response is in the form of a text.", - "comment_placeholder": "We'd love to hear your thoughts! Please share any additional feedback or suggestions you have for improving Compass." + "comment_placeholder": "We'd love to hear your thoughts! Please share any additional feedback or suggestions you have for improving Compass.", + "type": "rating", + "display_rating": false } } diff --git a/backend/app/conversations/feedback/services/service.py b/backend/app/conversations/feedback/services/service.py index f6662df00..758f769ec 100644 --- a/backend/app/conversations/feedback/services/service.py +++ b/backend/app/conversations/feedback/services/service.py @@ -5,14 +5,15 @@ import json import logging from abc import ABC, abstractmethod -from typing import Any, Dict, cast +from typing import Any, Dict from pathlib import Path from app.conversations.feedback.repository import IUserFeedbackRepository from app.app_config import get_application_config from app.metrics.services.service import IMetricsService from app.metrics.types import FeedbackProvidedEvent, FeedbackRatingValueEvent, FeedbackTypeLiteral -from .types import Feedback, NewFeedbackSpec, FeedbackItem, Version, AnsweredQuestions +from .types import Feedback, NewFeedbackSpec, FeedbackItem, Version, AnsweredQuestions, QuestionsConfig, Question, YesNoQuestion, RatingQuestion, \ + CheckboxQuestion from .errors import ( InvalidQuestionError, QuestionsFileError @@ -91,6 +92,11 @@ def calculate_ces_value(answer: int) -> int: async def load_questions() -> Dict[str, Any]: + """ + Load questions from the JSON file. + :return: Dictionary containing questions data + :raises QuestionsFileError: If there's an error loading the questions file + """ global questions_cache if not questions_cache: questions_file = Path(__file__).parent / "questions-en.json" @@ -134,6 +140,16 @@ async def get_answered_questions(self, user_id: str) -> AnsweredQuestions: """ raise NotImplementedError() + @abstractmethod + async def get_questions_config(self) -> QuestionsConfig: + """ + Get the questions configuration for the feedback form. + + :return: The questions configuration + :raises QuestionsFileError: If there's an error loading the questions file + """ + raise NotImplementedError() + class UserFeedbackService(IUserFeedbackService): """ @@ -145,6 +161,32 @@ def __init__(self, user_feedback_repository: IUserFeedbackRepository, metrics_se self._user_feedback_repository: IUserFeedbackRepository = user_feedback_repository self._metrics_service: IMetricsService = metrics_service + async def get_questions_config(self) -> QuestionsConfig: + """ + Get the questions configuration for the feedback form. + + :return: The questions configuration + :raises QuestionsFileError: If there's an error loading the questions file + """ + questions_data = await load_questions() + if not questions_data: + raise QuestionsFileError("No questions data available") + + # Convert the raw questions data to the proper Pydantic models + config: QuestionsConfig = {} + for question_id, question_data in questions_data.items(): + question_type = question_data.get("type") + if question_type == "yes_no": + config[question_id] = YesNoQuestion(**question_data) + elif question_type == "rating": + config[question_id] = RatingQuestion(**question_data) + elif question_type == "checkbox": + config[question_id] = CheckboxQuestion(**question_data) + else: + raise QuestionsFileError(f"Invalid question type '{question_type}' for question '{question_id}'") + + return config + async def upsert_user_feedback(self, user_id: str, session_id: int, feedback_spec: NewFeedbackSpec) -> Feedback: questions_data = await load_questions() if not questions_data: @@ -225,7 +267,7 @@ async def _capture_metrics(self, user_id: str, session_id: int, feedback_items: logger.error(f"Rating value {item.answer.rating_numeric} for question {item.question_id} is out of range (1-5)") continue feedback_type: FeedbackTypeLiteral - match item.question_id: + match item.question_id: # TODO: check unreachable case "recommendation": feedback_type = "NPS" value = calculate_nps_value(item.answer.rating_numeric) diff --git a/backend/app/conversations/feedback/services/test_service.py b/backend/app/conversations/feedback/services/test_service.py index 611789867..4df2f6ea4 100644 --- a/backend/app/conversations/feedback/services/test_service.py +++ b/backend/app/conversations/feedback/services/test_service.py @@ -20,7 +20,8 @@ from app.conversations.feedback.services.service import UserFeedbackService from app.conversations.feedback.services.service import questions_cache from app.conversations.feedback.services.types import Feedback, FeedbackItem, Version, Answer, NewFeedbackSpec, \ - NewFeedbackItemSpec, NewFeedbackVersionSpec, SimplifiedAnswer + NewFeedbackItemSpec, NewFeedbackVersionSpec, SimplifiedAnswer, QuestionsConfig, YesNoQuestion, RatingQuestion, \ + CheckboxQuestion from app.metrics.constants import EventType from app.metrics.services.service import IMetricsService from app.metrics.types import FeedbackProvidedEvent, FeedbackRatingValueEvent, AbstractCompassMetricEvent @@ -524,3 +525,85 @@ async def test_record_multiple_feedback_rating_value_events(self, assert actual_events[2].value == 1 assert actual_events[3].feedback_type == "CES" assert actual_events[3].value == 0 + + +@pytest.mark.asyncio +async def test_get_questions_config_success(_mock_feedback_repository: IUserFeedbackRepository, + _mock_metrics_service: IMetricsService): + # GIVEN a service instance + service = UserFeedbackService( + user_feedback_repository=_mock_feedback_repository, + metrics_service=_mock_metrics_service + ) + + # WHEN getting the questions configuration + config = await service.get_questions_config() + + # THEN the configuration should be properly loaded and converted to Pydantic models + assert isinstance(config, dict) + for question_id, question in config.items(): + assert question_id in actual_questions_json + raw_question = actual_questions_json[question_id] + + # Check base fields + assert question.question_text == raw_question["question_text"] + assert question.description == raw_question["description"] + assert question.comment_placeholder == raw_question["comment_placeholder"] + assert question.type == raw_question["type"] + + # Check type-specific fields + if isinstance(question, YesNoQuestion): + assert question.show_comments_on == raw_question["show_comments_on"] + elif isinstance(question, RatingQuestion): + assert question.max_rating == raw_question.get("max_rating") + assert question.display_rating == raw_question.get("display_rating") + assert question.low_rating_label == raw_question.get("low_rating_label") + assert question.high_rating_label == raw_question.get("high_rating_label") + elif isinstance(question, CheckboxQuestion): + assert question.options == raw_question["options"] + assert question.low_rating_label == raw_question.get("low_rating_label") + assert question.high_rating_label == raw_question.get("high_rating_label") + + +@pytest.mark.asyncio +async def test_get_questions_config_invalid_type(_mock_feedback_repository: IUserFeedbackRepository, + _mock_metrics_service: IMetricsService): + # GIVEN a service instance + service = UserFeedbackService( + user_feedback_repository=_mock_feedback_repository, + metrics_service=_mock_metrics_service + ) + + # AND a question with an invalid type + invalid_question = { + "question_text": "Test question", + "description": "Test description", + "comment_placeholder": "Test placeholder", + "type": "invalid_type" + } + + # WHEN loading questions with an invalid type + with patch('app.conversations.feedback.services.service.load_questions', + return_value={"test_question": invalid_question}): + # THEN a QuestionsFileError should be raised + with pytest.raises(QuestionsFileError) as exc_info: + await service.get_questions_config() + assert "Invalid question type" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_get_questions_config_empty(_mock_feedback_repository: IUserFeedbackRepository, + _mock_metrics_service: IMetricsService): + # GIVEN a service instance + service = UserFeedbackService( + user_feedback_repository=_mock_feedback_repository, + metrics_service=_mock_metrics_service + ) + + # AND no questions data available + with patch('app.conversations.feedback.services.service.load_questions', + return_value=None): + # THEN a QuestionsFileError should be raised + with pytest.raises(QuestionsFileError) as exc_info: + await service.get_questions_config() + assert "No questions data available" in str(exc_info.value) diff --git a/backend/app/conversations/feedback/services/types.py b/backend/app/conversations/feedback/services/types.py index 3a7a97347..40d1adddf 100644 --- a/backend/app/conversations/feedback/services/types.py +++ b/backend/app/conversations/feedback/services/types.py @@ -2,6 +2,8 @@ Module containing type definitions for the feedback feature. """ from datetime import datetime +from enum import Enum +from typing import TypeAlias, Literal from typing import TypeAlias from pydantic import BaseModel, Field @@ -10,6 +12,66 @@ from common_libs.time_utilities import get_now +class QuestionType(str, Enum): + """Type of feedback question.""" + YES_NO = "yes_no" + RATING = "rating" + CHECKBOX = "checkbox" + + +class BaseQuestion(BaseModel): + """Base model for all question types.""" + question_text: str + """Text of the question""" + description: str + """Description of the question""" + comment_placeholder: str | None + """Placeholder text for comments""" + type: QuestionType + """Type of the question""" + + class Config: + extra = "forbid" + + +class YesNoQuestion(BaseQuestion): + """Model for yes/no questions.""" + type: Literal[QuestionType.YES_NO] + show_comments_on: Literal["yes", "no"] + """Whether to show comments field on yes or no answer""" + + +class RatingQuestion(BaseQuestion): + """Model for rating questions.""" + type: Literal[QuestionType.RATING] + max_rating: int | None = None + """Maximum rating value""" + display_rating: bool | None = None + """Whether to display the rating""" + low_rating_label: str | None = None + """Label for low rating""" + high_rating_label: str | None = None + """Label for high rating""" + + +class CheckboxQuestion(BaseQuestion): + """Model for checkbox questions.""" + type: Literal[QuestionType.CHECKBOX] + options: dict[str, str] + """Available options as key-value pairs""" + low_rating_label: str | None = None + """Label for low rating""" + high_rating_label: str | None = None + """Label for high rating""" + + +Question = YesNoQuestion | RatingQuestion | CheckboxQuestion +"""Union type for all question types""" + +QuestionsConfig = dict[str, Question] +"""Type alias for the questions configuration""" + + class Answer(BaseModel): """ The core model for storing user answers to feedback questions. diff --git a/frontend-new/.storybook/preview.tsx b/frontend-new/.storybook/preview.tsx index 7c4606e70..7666d88fa 100644 --- a/frontend-new/.storybook/preview.tsx +++ b/frontend-new/.storybook/preview.tsx @@ -22,6 +22,7 @@ import SnackbarProvider from "../src/theme/SnackbarProvider/SnackbarProvider"; import { IsOnlineContext } from "../src/app/isOnlineProvider/IsOnlineProvider"; import { initSentry } from "../src/sentryInit"; import { ChatProvider } from "../src/chat/ChatContext"; +import { FeedbackProvider } from "../src/feedback/overallFeedback/feedbackContext/FeedbackContext"; const preview: Preview = { parameters: { @@ -119,9 +120,11 @@ export const decorators = [
- {}}> - - + + {}} addMessage={()=> {}} removeMessage={() => {}}> + + +
diff --git a/frontend-new/package.json b/frontend-new/package.json index eaf530d19..8d164174f 100644 --- a/frontend-new/package.json +++ b/frontend-new/package.json @@ -152,4 +152,4 @@ "last 1 safari version" ] } -} +} \ No newline at end of file diff --git a/frontend-new/src/chat/Chat.test.tsx b/frontend-new/src/chat/Chat.test.tsx index bc79af9ff..a2f4e354c 100644 --- a/frontend-new/src/chat/Chat.test.tsx +++ b/frontend-new/src/chat/Chat.test.tsx @@ -185,6 +185,15 @@ jest.mock("src/chat/ChatContext", () => { } }) +// mock the feedback context +jest.mock("src/feedback/overallFeedback/feedbackContext/FeedbackContext", () => { + const actual = jest.requireActual("src/feedback/overallFeedback/feedbackContext/FeedbackContext"); + return { + ...actual, + FeedbackProvider: jest.fn().mockImplementation(({ children }) =>
{children}
) + } +}) + describe("Chat", () => { // ExperienceService methods to be mocked const mockGetExperiences = jest.fn(); diff --git a/frontend-new/src/chat/Chat.tsx b/frontend-new/src/chat/Chat.tsx index c554873b5..e50f90c3c 100644 --- a/frontend-new/src/chat/Chat.tsx +++ b/frontend-new/src/chat/Chat.tsx @@ -37,6 +37,7 @@ import { CompassChatMessageProps } from "./chatMessage/compassChatMessage/Compas import { CONVERSATION_CONCLUSION_CHAT_MESSAGE_TYPE } from "./chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage"; +import { FeedbackProvider } from "src/feedback/overallFeedback/feedbackContext/FeedbackContext"; export const INACTIVITY_TIMEOUT = 3 * 60 * 1000; // in milliseconds // Set the interval to check every TIMEOUT/3, @@ -412,88 +413,89 @@ const Chat: React.FC = ({ showInactiveSessionAlert = false, disableIn {isLoggingOut ? ( ) : ( - - { - // expect(screen.getByTestId(DATA_TEST_ID.CHAT_CONTAINER)).toHaveAttribute("is-initialized", "true"); - // }); - // This technique can solve the "Warning: An update to Chat inside a test was not wrapped in act(...)" warning. - is-initialized={`${initialized}`} + + - - setNewConversationDialog(true)} - experiencesExplored={exploredExperiences} - exploredExperiencesNotification={exploredExperiencesNotification} - setExploredExperiencesNotification={setExploredExperiencesNotification} - conversationCompleted={conversationCompleted} - timeUntilNotification={timeUntilFeedbackNotification} - progressPercentage={currentPhase.percentage} - /> - - - + { + // expect(screen.getByTestId(DATA_TEST_ID.CHAT_CONTAINER)).toHaveAttribute("is-initialized", "true"); + // }); + // This technique can solve the "Warning: An update to Chat inside a test was not wrapped in act(...)" warning. + is-initialized={`${initialized}`} + > + + setNewConversationDialog(true)} + experiencesExplored={exploredExperiences} + exploredExperiencesNotification={exploredExperiencesNotification} + setExploredExperiencesNotification={setExploredExperiencesNotification} + conversationCompleted={conversationCompleted} + timeUntilNotification={timeUntilFeedbackNotification} + progressPercentage={currentPhase.percentage} + /> + + + + + + + + {showBackdrop && } + + + - - - - {showBackdrop && } - - + {newConversationDialog && ( + + Once you start a new conversation, all messages from the current conversation will be lost forever. +
+
+ Are you sure you want to start a new conversation? + + } + onCancel={() => setNewConversationDialog(false)} + onConfirm={handleConfirmNewConversation} + onDismiss={() => setNewConversationDialog(false)} + cancelButtonText="Cancel" + confirmButtonText="Yes, I'm sure" /> -
-
- - {newConversationDialog && ( - - Once you start a new conversation, all messages from the current conversation will be lost forever. -
-
- Are you sure you want to start a new conversation? - - } - onCancel={() => setNewConversationDialog(false)} - onConfirm={handleConfirmNewConversation} - onDismiss={() => setNewConversationDialog(false)} - cancelButtonText="Cancel" - confirmButtonText="Yes, I'm sure" - /> - )} -
+ )} +
+ )} ); diff --git a/frontend-new/src/chat/ChatContext.test.tsx b/frontend-new/src/chat/ChatContext.test.tsx index 4045c71fc..8a1b81d55 100644 --- a/frontend-new/src/chat/ChatContext.test.tsx +++ b/frontend-new/src/chat/ChatContext.test.tsx @@ -4,7 +4,7 @@ import "src/_test_utilities/consoleMock"; import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import { ChatProvider, useChatContext } from "./ChatContext"; -import { FeedbackStatus } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; +import { FeedbackStatus } from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { ConversationMessageSender } from "src/chat/ChatService/ChatService.types"; import CompassChatMessage, { CompassChatMessageProps } from "src/chat/chatMessage/compassChatMessage/CompassChatMessage"; diff --git a/frontend-new/src/chat/ChatContext.tsx b/frontend-new/src/chat/ChatContext.tsx index 59d0c898a..520e0658b 100644 --- a/frontend-new/src/chat/ChatContext.tsx +++ b/frontend-new/src/chat/ChatContext.tsx @@ -1,5 +1,5 @@ import React, { createContext, useContext, ReactNode, useState, useMemo } from 'react'; -import { FeedbackStatus } from 'src/feedback/overallFeedback/feedbackForm/FeedbackForm'; +import { FeedbackStatus } from 'src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm'; import { PersistentStorageService } from 'src/app/PersistentStorageService/PersistentStorageService'; import { IChatMessage } from "src/chat/Chat.types"; @@ -30,7 +30,7 @@ export const ChatProvider: React.FC = ({ children, handleOpen handleOpenExperiencesDrawer, removeMessage, addMessage, - feedbackStatus, + feedbackStatus, //TODO: move to feedback context setFeedbackStatus, isAccountConverted, setIsAccountConverted: (converted: boolean) => { diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage.stories.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage.stories.tsx index cd06dca60..527aee09f 100644 --- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage.stories.tsx +++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import ConversationConclusionChatMessage from "src/chat/chatMessage/conversationConclusionChatMessage/ConversationConclusionChatMessage"; import { ChatProvider, useChatContext } from "src/chat/ChatContext"; -import { FeedbackStatus } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; +import { FeedbackStatus } from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; import { useEffect } from "react"; const withChatContext = (feedbackStatus: FeedbackStatus) => (Story: any) => { diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx index e176b0b54..58214f033 100644 --- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx +++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.stories.tsx @@ -2,7 +2,7 @@ import React, { ReactNode, useMemo } from "react"; import { Meta, StoryObj } from "@storybook/react"; import ConversationConclusionFooter from "./ConversationConclusionFooter"; import { ChatProvider, useChatContext } from "src/chat/ChatContext"; -import { FeedbackItem, QUESTION_KEYS } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; import { TabiyaUser } from "src/auth/auth.types"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; @@ -12,7 +12,14 @@ import { } from "src/userPreferences/UserPreferencesService/userPreferences.types"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import { getBackendUrl } from "src/envService"; -import { FeedbackStatus } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; +import { FeedbackStatus } from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; + +import { + CUSTOMER_SATISFACTION_QUESTION_KEY +} from "src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/customerSatisfactionRating/constants"; +import { + ADDITIONAL_FEEDBACK_QUESTION_ID +} from "src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/additionalFeedback/constants"; // Mock feedback data const mockFeedbackInProgress: FeedbackItem[] = [ @@ -62,10 +69,10 @@ const StorybookWrapper = ({ const answeredQuestions = []; if (hasSubmittedFeedback) { - answeredQuestions.push(QUESTION_KEYS.OVERALL_SATISFACTION); + answeredQuestions.push(ADDITIONAL_FEEDBACK_QUESTION_ID); } if (hasSubmittedCustomerSatisfactionRating) { - answeredQuestions.push(QUESTION_KEYS.CUSTOMER_SATISFACTION); + answeredQuestions.push(CUSTOMER_SATISFACTION_QUESTION_KEY); } // Mock the session feedback on window diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.test.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.test.tsx index 019d28e50..2790915ad 100644 --- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.test.tsx +++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.test.tsx @@ -14,15 +14,19 @@ import authenticationStateService from "src/auth/services/AuthenticationState.se import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline"; import CustomerSatisfactionRating, { DATA_TEST_ID as CUSTOMER_SATISFACTION_RATING_DATA_TEST_ID, -} from "src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction"; +} from "src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/customerSatisfactionRating/CustomerSatisfaction"; +import { FeedbackProvider } from "src/feedback/overallFeedback/feedbackContext/FeedbackContext"; +import { resetAllMethodMocks } from "src/_test_utilities/resetAllMethodMocks"; +import { DATA_TEST_ID as OVERALL_FEEDBACK_FORM_DATA_TEST_ID } from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; +import { DATA_TEST_ID as ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID } from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; // Mock external dependencies jest.mock("src/app/PersistentStorageService/PersistentStorageService"); jest.mock("src/auth/services/AuthenticationState.service"); // mock the customer satisfaction component -jest.mock("src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction", () => { - const actual = jest.requireActual("src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction"); +jest.mock("src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/customerSatisfactionRating/CustomerSatisfaction", () => { + const actual = jest.requireActual("src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/customerSatisfactionRating/CustomerSatisfaction"); return { ...actual, __esModule: true, @@ -30,17 +34,37 @@ jest.mock("src/feedback/overallFeedback/feedbackForm/components/customerSatisfac }; }); +jest.mock("src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm", () => { + const actual = jest.requireActual("src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"); + return { + ...actual, + __esModule: true, + default: jest.fn(() =>
), + }; +}) + +jest.mock("src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog", () => { + const actual = jest.requireActual("src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"); + return { + ...actual, + __esModule: true, + default: jest.fn(() =>
), + }; +}); + describe("ConversationConclusionFooter", () => { const givenMockHandleOpenExperiencesDrawer = jest.fn(); const givenMockRemoveMessage = jest.fn(); const givenMockAddMessage = jest.fn(); const givenMockSetFeedbackStatus = jest.fn(); - const renderWithChatProvider = () => { + const renderWithProviders = () => { return render( - - - , + + + + + ); }; @@ -56,6 +80,15 @@ describe("ConversationConclusionFooter", () => { (authenticationStateService.getInstance as jest.Mock).mockReturnValue({ getUser: () => ({ name: "Test User", email: "test@example.com" }), }); + + const givenSessionId = 1234; + jest.spyOn(UserPreferencesStateService.getInstance(), "getActiveSessionId").mockReturnValue(givenSessionId); + jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasOverallFeedback").mockReturnValue(false); + jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValue(false); + }); + + afterEach(() => { + resetAllMethodMocks(UserPreferencesStateService.getInstance()) }); describe("render tests", () => { @@ -65,7 +98,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasOverallFeedback").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect no errors or warnings to have occurred expect(console.error).not.toHaveBeenCalled(); @@ -84,13 +117,12 @@ describe("ConversationConclusionFooter", () => { }); describe("feedback", () => { - test("should not show feedback request if rating is not submitted", () => { // GIVEN the user has not submitted a customer satisfaction rating jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the feedback message not to be displayed expect(screen.queryByTestId(DATA_TEST_ID.FEEDBACK_MESSAGE_TEXT)).not.toBeInTheDocument(); @@ -107,7 +139,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the feedback message to be displayed expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_MESSAGE_TEXT)).toBeInTheDocument(); @@ -141,7 +173,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasOverallFeedback").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the feedback in progress message to be displayed expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_IN_PROGRESS_MESSAGE)).toBeInTheDocument(); @@ -157,14 +189,14 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // AND the feedback button is clicked const feedbackButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_BUTTON); await userEvent.click(feedbackButton); // THEN expect the feedback form dialog to be open - expect(screen.getByTestId("feedback-form-dialog-c6ba52ec-c1de-46ac-950b-f5354c6785ac")).toBeInTheDocument(); + expect(screen.getByTestId(OVERALL_FEEDBACK_FORM_DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG)).toBeInTheDocument(); }); test("should maintain feedback status when form is closed", async () => { @@ -175,14 +207,14 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // AND the feedback button is clicked const feedbackButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_BUTTON); await userEvent.click(feedbackButton); // AND the form is closed - const closeButton = screen.getByTestId("feedback-form-dialog-button-c6ba52ec-c1de-46ac-950b-f5354c6785ac"); + const closeButton = screen.getByTestId(OVERALL_FEEDBACK_FORM_DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG); await userEvent.click(closeButton); // THEN expect the feedback status to remain unchanged @@ -196,7 +228,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the customer satisfaction rating component to be displayed expect(screen.getByTestId(CUSTOMER_SATISFACTION_RATING_DATA_TEST_ID.CUSTOMER_SATISFACTION_RATING_CONTAINER)).toBeInTheDocument(); @@ -211,7 +243,7 @@ describe("ConversationConclusionFooter", () => { }); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the create account message not to be displayed expect(screen.queryByTestId(DATA_TEST_ID.CREATE_ACCOUNT_MESSAGE)).not.toBeInTheDocument(); @@ -229,7 +261,7 @@ describe("ConversationConclusionFooter", () => { // WHEN the component is rendered with account not converted (PersistentStorageService.getAccountConverted as jest.Mock).mockReturnValue(false); - renderWithChatProvider(); + renderWithProviders(); // THEN expect the create account message to be displayed expect(screen.getByTestId(DATA_TEST_ID.CREATE_ACCOUNT_MESSAGE)).toBeInTheDocument(); @@ -242,7 +274,7 @@ describe("ConversationConclusionFooter", () => { test("should show verification message for users with converted account", () => { // GIVEN the component is rendered with account converted (PersistentStorageService.getAccountConverted as jest.Mock).mockReturnValue(true); - renderWithChatProvider(); + renderWithProviders(); // THEN expect the verification message to be displayed expect(screen.getByTestId(DATA_TEST_ID.VERIFICATION_REMINDER_MESSAGE)).toBeInTheDocument(); @@ -258,14 +290,14 @@ describe("ConversationConclusionFooter", () => { // WHEN the component is rendered with account not converted (PersistentStorageService.getAccountConverted as jest.Mock).mockReturnValue(false); - renderWithChatProvider(); + renderWithProviders(); // AND the create account link is clicked const createAccountLink = screen.getByTestId(DATA_TEST_ID.CREATE_ACCOUNT_LINK); await userEvent.click(createAccountLink); // THEN expect the account conversion dialog to be displayed - expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByTestId(ANONYMOUS_ACCOUNT_CONVERSION_DIALOG_DATA_TEST_ID.DIALOG)).toBeInTheDocument(); }); test("should submit customer satisfaction rating when rating is submitted", async () => { @@ -273,7 +305,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the Customer Satisfaction Component to be shown expect(screen.getByTestId(CUSTOMER_SATISFACTION_RATING_DATA_TEST_ID.CUSTOMER_SATISFACTION_RATING_CONTAINER)).toBeInTheDocument(); @@ -303,7 +335,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasOverallFeedback").mockReturnValueOnce(false); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // AND the experiences drawer button is clicked const experiencesDrawerButton = screen.getByTestId(DATA_TEST_ID.EXPERIENCES_DRAWER_BUTTON); @@ -326,7 +358,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the link to be disabled const customLink = screen.getByTestId(testId); @@ -367,7 +399,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the in progress button to be disabled const inProgressButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_IN_PROGRESS_BUTTON); @@ -387,12 +419,13 @@ describe("ConversationConclusionFooter", () => { // GIVEN the browser is offline mockBrowserIsOnLine(false); // AND the user is anonymous + (authenticationStateService.getInstance as jest.Mock).mockReturnValue({ getUser: () => ({ name: null, email: null }), }); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the create account button to be disabled const createAccountLink = screen.getByTestId(DATA_TEST_ID.CREATE_ACCOUNT_LINK); @@ -415,7 +448,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the thank you message to be displayed expect(screen.getByTestId(DATA_TEST_ID.THANK_YOU_FOR_RATING_MESSAGE)).toBeInTheDocument(); @@ -433,7 +466,7 @@ describe("ConversationConclusionFooter", () => { jest.spyOn(UserPreferencesStateService.getInstance(), "activeSessionHasCustomerSatisfactionRating").mockReturnValueOnce(true); // WHEN the component is rendered - renderWithChatProvider(); + renderWithProviders(); // THEN expect the thank you message to be displayed expect(screen.getByTestId(DATA_TEST_ID.THANK_YOU_FOR_FEEDBACK_MESSAGE)).toBeInTheDocument(); diff --git a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.tsx b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.tsx index e09ab1b45..73959e10e 100644 --- a/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.tsx +++ b/frontend-new/src/chat/chatMessage/conversationConclusionChatMessage/conversationConclusionFooter/ConversationConclusionFooter.tsx @@ -2,17 +2,17 @@ import React, { useState, useEffect } from "react"; import { Box, Typography, useTheme } from "@mui/material"; import CustomLink from "src/theme/CustomLink/CustomLink"; import { FIXED_MESSAGES_TEXT } from "src/chat/util"; -import FeedbackForm, { +import OverallFeedbackForm, { FeedbackStatus, FeedbackCloseEvent, -} from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; +} from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; import { useChatContext } from "src/chat/ChatContext"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; import authenticationStateService from "src/auth/services/AuthenticationState.service"; import AnonymousAccountConversionDialog from "src/auth/components/anonymousAccountConversionDialog/AnonymousAccountConversionDialog"; -import CustomerSatisfactionRating from "src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction"; +import CustomerSatisfactionRating from "src/feedback/overallFeedback/overallFeedbackForm/components/formContent/questionComponents/customerSatisfactionRating/CustomerSatisfaction"; import PermIdentityIcon from "@mui/icons-material/PermIdentity"; import FeedbackOutlinedIcon from "@mui/icons-material/FeedbackOutlined"; import BadgeOutlinedIcon from "@mui/icons-material/BadgeOutlined"; @@ -203,7 +203,7 @@ const ConversationConclusionFooter: React.FC = () => { )} - + setShowConversionDialog(false)} diff --git a/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.test.tsx new file mode 100644 index 000000000..ec0bfe93d --- /dev/null +++ b/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.test.tsx @@ -0,0 +1,235 @@ +//silence chatty console logs +import "src/_test_utilities/consoleMock"; +import React from "react"; +import { render, screen, waitFor, fireEvent } from "src/_test_utilities/test-utils"; +import { FeedbackProvider, useFeedback } from "src/feedback/overallFeedback/feedbackContext/FeedbackContext"; +import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; +import { mockQuestionsConfig } from "src/feedback/overallFeedback/overallFeedbackForm/overallFeedbackForm.test.utils"; + +// Mock the persistent storage service +jest.mock("src/app/PersistentStorageService/PersistentStorageService", () => ({ + PersistentStorageService: { + getOverallFeedback: jest.fn(), + setOverallFeedback: jest.fn(), + clearOverallFeedback: jest.fn(), + }, +})); + +// Test component that uses the feedback context +const TestComponent: React.FC = () => { + const { questionsConfig, setQuestionsConfig, answers, handleAnswerChange, clearAnswers } = useFeedback(); + return ( +
+
+ {questionsConfig ? "Config loaded" : "No config"} +
+ +
+ {answers.length} answers +
+ + +
+ ); +}; + +describe("FeedbackContext", () => { + beforeEach(() => { + jest.clearAllMocks(); + (PersistentStorageService.getOverallFeedback as jest.Mock).mockReturnValue([]); + }); + + test("should provide initial null questionsConfig", () => { + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // THEN expect the initial config status to be "No config" + expect(screen.getByTestId("config-status")).toHaveTextContent("No config"); + }); + + test("should update questionsConfig when setQuestionsConfig is called", async () => { + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // AND clicking the set config button + screen.getByTestId("set-config-button").click(); + + // THEN expect the config status to be "Config loaded" + await waitFor(() => { + expect(screen.getByTestId("config-status")).toHaveTextContent("Config loaded"); + }); + }); + + test("should throw error when useFeedback is used outside of FeedbackProvider", () => { + // GIVEN a component that uses the feedback context without a provider + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + // WHEN rendering the component + expect(() => { + render(); + }).toThrow("useFeedback must be used within a FeedbackProvider"); + + // THEN expect the error to be logged + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + test("should initialize answers from persistent storage", () => { + // GIVEN some existing feedback in persistent storage + const mockFeedback: FeedbackItem[] = [ + { + question_id: "test1", + simplified_answer: { + rating_numeric: 5, + comment: "test comment 1" + } + }, + { + question_id: "test2", + simplified_answer: { + rating_numeric: 4, + comment: "test comment 2" + } + } + ]; + (PersistentStorageService.getOverallFeedback as jest.Mock).mockReturnValue(mockFeedback); + + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // THEN expect the answers count to be 2 + expect(screen.getByTestId("answers-count")).toHaveTextContent("2 answers"); + }); + + test("should handle adding a new answer", () => { + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // AND clicking the add answer button + fireEvent.click(screen.getByTestId("add-answer-button")); + + // THEN expect the answers count to be 1 + expect(screen.getByTestId("answers-count")).toHaveTextContent("1 answers"); + // AND expect the persistent storage to be updated + expect(PersistentStorageService.setOverallFeedback).toHaveBeenCalledWith([ + { + question_id: "test", + simplified_answer: { + rating_numeric: 5, + comment: "test comment" + } + } + ]); + }); + + test("should handle updating an existing answer", () => { + // GIVEN an existing answer in the context + const mockFeedback: FeedbackItem[] = [ + { + question_id: "test", + simplified_answer: { + rating_numeric: 3, + comment: "old comment" + } + } + ]; + (PersistentStorageService.getOverallFeedback as jest.Mock).mockReturnValue(mockFeedback); + + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // AND clicking the add answer button to update the existing answer + fireEvent.click(screen.getByTestId("add-answer-button")); + + // THEN expect the answers count to still be 1 + expect(screen.getByTestId("answers-count")).toHaveTextContent("1 answers"); + // AND expect the persistent storage to be updated with the new answer + expect(PersistentStorageService.setOverallFeedback).toHaveBeenCalledWith([ + { + question_id: "test", + simplified_answer: { + rating_numeric: 5, + comment: "test comment" + } + } + ]); + }); + + test("should handle clearing answers", () => { + // GIVEN some existing answers in the context + const mockFeedback: FeedbackItem[] = [ + { + question_id: "test1", + simplified_answer: { + rating_numeric: 5, + comment: "test comment 1" + } + }, + { + question_id: "test2", + simplified_answer: { + rating_numeric: 4, + comment: "test comment 2" + } + } + ]; + (PersistentStorageService.getOverallFeedback as jest.Mock).mockReturnValue(mockFeedback); + + // WHEN rendering a component that uses the feedback context + render( + + + + ); + + // AND clicking the clear answers button + fireEvent.click(screen.getByTestId("clear-answers-button")); + + // THEN expect the answers count to be 0 + expect(screen.getByTestId("answers-count")).toHaveTextContent("0 answers"); + // AND expect the persistent storage to be cleared + expect(PersistentStorageService.clearOverallFeedback).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.tsx b/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.tsx new file mode 100644 index 000000000..8ff0f23b6 --- /dev/null +++ b/frontend-new/src/feedback/overallFeedback/feedbackContext/FeedbackContext.tsx @@ -0,0 +1,100 @@ +import React, { createContext, useContext, useState, useMemo, useEffect } from "react"; +import { QuestionsConfig } from "src/feedback/overallFeedback/overallFeedbackForm/overallFeedbackForm.types"; +import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import { FeedbackError } from "src/error/commonErrors"; +import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import { PersistentStorageService } from "src/app/PersistentStorageService/PersistentStorageService"; + +interface FeedbackContextType { + questionsConfig: QuestionsConfig | null; + setQuestionsConfig: (config: QuestionsConfig) => void; + isLoading: boolean; + error: Error | null; + answers: FeedbackItem[]; + handleAnswerChange: (feedback: FeedbackItem) => void; + clearAnswers: () => void; +} + +const FeedbackContext = createContext(undefined); + +export const FeedbackProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [questionsConfig, setQuestionsConfig] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [answers, setAnswers] = useState(() => { + return PersistentStorageService.getOverallFeedback(); + }); + + useEffect(() => { + const fetchQuestionsConfig = async () => { + try { + setIsLoading(true); + + const activeSessionId = UserPreferencesStateService.getInstance().getActiveSessionId() + if (!activeSessionId) { + throw new FeedbackError('No active session found'); + } + const service = OverallFeedbackService.getInstance(); + const config = await service.getQuestionsConfig(activeSessionId); + setQuestionsConfig(config); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch questions configuration')); + console.error('Error fetching questions configuration:', err); + } finally { + setIsLoading(false); + } + }; + + fetchQuestionsConfig(); + }, []); + + const handleAnswerChange = (feedback: FeedbackItem) => { + setAnswers((prevAnswers) => { + const existingIndex = prevAnswers.findIndex((item) => item.question_id === feedback.question_id); + + let updatedAnswers; + if (existingIndex !== -1) { + updatedAnswers = [...prevAnswers]; + updatedAnswers[existingIndex] = feedback; + } else { + updatedAnswers = [...prevAnswers, feedback]; + } + + // Save updated answers to persistent storage + PersistentStorageService.setOverallFeedback(updatedAnswers); + + return updatedAnswers; + }); + }; + + const clearAnswers = () => { + PersistentStorageService.clearOverallFeedback(); + setAnswers([]); + }; + + const value = useMemo(() => ({ + questionsConfig, + setQuestionsConfig, + isLoading, + error, + answers, + handleAnswerChange, + clearAnswers + }), [questionsConfig, isLoading, error, answers]); + + return ( + + {children} + + ); +}; + +export const useFeedback = () => { + const context = useContext(FeedbackContext); + if (context === undefined) { + throw new Error("useFeedback must be used within a FeedbackProvider"); + } + return context; +}; \ No newline at end of file diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.stories.tsx deleted file mode 100644 index b05c55d54..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.stories.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Meta, type StoryObj } from "@storybook/react"; -import { action } from "@storybook/addon-actions"; -import FeedbackForm from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; - -const meta: Meta = { - title: "Feedback/FeedbackForm", - component: FeedbackForm, - tags: ["autodocs"], - args: { - notifyOnClose: action("notifyOnClose"), - }, - argTypes: {}, -}; - -export default meta; - -type Story = StoryObj; - -export const Shown: Story = { - args: { - isOpen: true, - }, -}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion.stories.tsx deleted file mode 100644 index 0d81c2c94..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion.stories.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import CheckboxQuestion from "src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion"; -import { action } from "@storybook/addon-actions"; - -const meta: Meta = { - title: "Feedback/CheckboxQuestion", - component: CheckboxQuestion, - tags: ["autodocs"], - args: { - notifyChange: (selectedOptions, comments) => { - action("notifyChange")(selectedOptions, comments); - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Shown: Story = { - args: { - questionId: "accuracy_relevance", - questionText: "How accurate and relevant was the information provided?", - options: [ - { key: "accurate", value: "Accurate" }, - { key: "relevant", value: "Relevant" }, - { key: "upToDate", value: "Up-to-date" }, - { key: "easyToUnderstand", value: "Easy to understand" }, - ], - selectedOptions: [], - placeholder: "Please provide comments", - }, -}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating.stories.tsx deleted file mode 100644 index 1ba933d94..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import CustomRating from "src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating"; -import { action } from "@storybook/addon-actions"; - -const meat: Meta = { - title: "Feedback/CustomRating", - component: CustomRating, - tags: ["autodocs"], - args: { - notifyChange: (value, comments) => { - action("notifyChange")(value, comments); - }, - }, -}; - -export default meat; - -type Story = StoryObj; - -export const Shown: Story = { - args: { - questionText: "How easy was it to interact with the system?", - questionId: "interaction_ease", - lowRatingLabel: "Not easy", - highRatingLabel: "Very easy", - placeholder: "Please provide comments", - }, -}; - -export const ShownWithNoRating: Story = { - args: { - questionText: "How easy was it to interact with the system?", - questionId: "interaction_ease", - displayRating: false, - placeholder: "Please provide comments", - }, -}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.stories.tsx deleted file mode 100644 index 53dcd18d6..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/CustomerSatisfaction.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Meta, type StoryObj } from "@storybook/react"; -import { action } from "@storybook/addon-actions"; -import CustomerSatisfactionRating from "./CustomerSatisfaction"; - -const meta: Meta = { - title: "Feedback/CustomerSatisfactionRating", - component: CustomerSatisfactionRating, - tags: ["autodocs"], - args: { - notifyOnCustomerSatisfactionRatingSubmitted: action("notifyOnCustomerSatisfactionRatingSubmitted"), - } -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; \ No newline at end of file diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/__snapshots__/CustomerSatisfaction.test.tsx.snap b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/__snapshots__/CustomerSatisfaction.test.tsx.snap deleted file mode 100644 index dea08d7f2..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/customerSatisfactionRating/__snapshots__/CustomerSatisfaction.test.tsx.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CustomerSatisfactionRating should render component successfully 1`] = ` - -`; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.stories.tsx deleted file mode 100644 index 7429d494e..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Meta, type StoryObj } from "@storybook/react"; -import FeedbackContent from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"; - -const meta: Meta = { - title: "Feedback/FeedbackContent", - component: FeedbackContent, - tags: ["autodocs"], - args: { - notifySubmit: () => {}, - }, -}; -export default meta; - -type Story = StoryObj; - -export const Shown: Story = { - args: {}, -}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.test.tsx deleted file mode 100644 index eae8d98ba..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent.test.tsx +++ /dev/null @@ -1,344 +0,0 @@ -// mute the console -import "src/_test_utilities/consoleMock"; - -import React from "react"; -import { render, screen, act, fireEvent } from "src/_test_utilities/test-utils"; -import { DATA_TEST_ID as CUSTOM_RATING_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating"; -import { DATA_TEST_ID as YES_NO_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion"; -import FeedbackFormContent, { - DATA_TEST_ID, -} from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"; -import { DATA_TEST_ID as CHECKBOX_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion"; -import feedbackFormContentSteps from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/feedbackFormContentSteps"; -import { DATA_TEST_ID as COMMENT_TEXT_FIELD_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/commentTextField/CommentTextField"; -import { useSwipeable } from "react-swipeable"; -import { mockBrowserIsOnLine } from "src/_test_utilities/mockBrowserIsOnline"; - -// mock the swipeable hook -jest.mock("react-swipeable"); - -// mock the framer-motion library -jest.mock("framer-motion", () => ({ - motion: { - div: ({ children, ...props }: { children: React.ReactElement }) =>
{children}
, - }, - AnimatePresence: ({ children }: { children: React.ReactElement }) => <>{children}, -})); - -describe("FeedbackFormContent", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - }); - - test("should render component successfully", () => { - // GIVEN the component - const givenFeedbackFormContent = ; - - // WHEN the component is rendered - render(givenFeedbackFormContent); - - // THEN expect no errors or warning to have occurred - expect(console.error).not.toHaveBeenCalled(); - expect(console.warn).not.toHaveBeenCalled(); - // AND the feedback form content to be in the document - const feedbackFormContent = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT); - expect(feedbackFormContent).toBeInTheDocument(); - // AND the feedback form content title to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toBeInTheDocument(); - // AND the feedback form content divider to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_DIVIDER)).toBeInTheDocument(); - // AND the feedback form content questions to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_QUESTIONS)).toBeInTheDocument(); - // AND the feedback form next button to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_NEXT_BUTTON)).toBeInTheDocument(); - // AND the feedback form back button to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_BACK_BUTTON)).toBeInTheDocument(); - // AND to match the snapshot - expect(feedbackFormContent).toMatchSnapshot(); - }); - - describe("action tests", () => { - test("should call handleNext when next button is clicked", () => { - // GIVEN the component - const givenFeedbackFormContent = ; - // AND the component is rendered - render(givenFeedbackFormContent); - - // WHEN the next button is clicked - const nextButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_NEXT_BUTTON); - fireEvent.click(nextButton); - - // THEN expect to go to the next step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[1].label - ); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should call handlePrevious when back button is clicked", () => { - // GIVEN the component - const givenFeedbackFormContent = ; - // AND the component is rendered - render(givenFeedbackFormContent); - - // WHEN the next button is clicked to move to the next step - const nextButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_NEXT_BUTTON); - fireEvent.click(nextButton); - // AND the back button is clicked - const backButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_BACK_BUTTON); - fireEvent.click(backButton); - - // THEN expect to go to the previous step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[0].label - ); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should call handleSubmit with exact answer data when answering each step", () => { - // GIVEN the FeedbackFormContent component - const mockHandleSubmit = jest.fn(); - const givenFeedbackFormContent = ; - // AND the component is rendered - render(givenFeedbackFormContent); - - // WHEN on the first step, select a checkbox option - const checkboxInput = screen.getAllByTestId(CHECKBOX_DATA_TEST_ID.CHECKBOX_OPTION)[0]; - fireEvent.click(checkboxInput); - - // AND move to next step - const nextButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_NEXT_BUTTON); - fireEvent.click(nextButton); - - // AND on the second step, answer the yes/no question - const yesNoInput = screen.getAllByTestId(YES_NO_DATA_TEST_ID.RADIO_YES)[1]; - fireEvent.click(yesNoInput); - - // AND move to next step - fireEvent.click(nextButton); - - // AND on the last step, provide a custom rating comment - const customRatingInput = screen.getAllByTestId(CUSTOM_RATING_DATA_TEST_ID.CUSTOM_RATING_ICON)[4]; - fireEvent.click(customRatingInput); - - // AND submit the form - fireEvent.click(nextButton); - - // THEN expect mockHandleSubmit to have been called exactly once - expect(mockHandleSubmit).toHaveBeenCalledTimes(1); - - // AND expect the exact answer data structure - const submittedAnswers = mockHandleSubmit.mock.calls[0][0]; - expect(submittedAnswers).toHaveLength(3); - - // First step answer (checkbox) - expect(submittedAnswers[0]).toEqual({ - question_id: feedbackFormContentSteps[0].questions[1].questionId, - simplified_answer: { - comment: "", - rating_boolean: undefined, - rating_numeric: undefined, - selected_options_keys: [feedbackFormContentSteps[0].questions[1].options![0].key], - }, - }); - - // Second step answer (yes/no) - expect(submittedAnswers[1]).toEqual({ - question_id: feedbackFormContentSteps[1].questions[1].questionId, - simplified_answer: { - comment: "", - rating_boolean: true, - rating_numeric: undefined, - selected_options_keys: undefined, - }, - }); - - // Last step answer (custom rating) - expect(submittedAnswers[2]).toEqual({ - question_id: feedbackFormContentSteps[2].questions[0].questionId, - simplified_answer: { - comment: "", - rating_boolean: undefined, - rating_numeric: 5, - selected_options_keys: undefined, - } - }); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should call handleAnswerChange when a question is answered", () => { - // GIVEN the FeedbackFormContent component - const givenFeedbackFormContent = ; - // AND the component is rendered - render(givenFeedbackFormContent); - - // WHEN question is answered - const yesNoInput = screen.getByTestId(YES_NO_DATA_TEST_ID.RADIO_YES); - fireEvent.click(yesNoInput); - const input = screen.getByTestId(COMMENT_TEXT_FIELD_TEST_ID.COMMENT_TEXT_FIELD); - fireEvent.change(input, { target: { value: "This is a comment" } }); - - // THEN expect the answer to be saved - expect(input).toHaveValue("This is a comment"); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should go to the next step when swiping left", () => { - // GIVEN the component is rendered - render(); - // AND the swipe handlers - const swipeHandlers = (useSwipeable as jest.Mock).mock.calls[0][0]; - - // WHEN the component is swiped left - act(() => { - swipeHandlers.onSwipedLeft(); - }); - - // THEN expect to go to the next step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[1].label - ); - - // AND WHEN the component is swiped left for again - act(() => { - swipeHandlers.onSwipedLeft(); - }); - - // THEN expect to go to the next step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[2].label - ); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should go to the previous step when swiping right", () => { - // GIVEN the component is rendered - render(); - - // WHEN the component is swiped left twice - act(() => { - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedLeft(); - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedLeft(); - }); - - // THEN expect to go to the third - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[2].label - ); - - // WHEN the component is swiped right - act(() => { - (useSwipeable as jest.Mock).mock.calls.at(-1)[0].onSwipedRight(); - }); - - // THEN expect to go to the previous step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[1].label - ); - - // AND WHEN the component is swiped right again - act(() => { - (useSwipeable as jest.Mock).mock.calls.at(-1)[0].onSwipedRight(); - }); - - // THEN expect to go to the previous step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[0].label - ); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should do nothing when swiping right on the first step", () => { - // GIVEN the component is rendered - render(); - - // WHEN the component is swiped right on the first step - act(() => { - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedRight(); - }); - - // THEN expect to stay on the first step - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE)).toHaveTextContent( - feedbackFormContentSteps[0].label - ); - }); - - test("should do nothing when swiping left on the last step", () => { - // GIVEN the component is rendered - render(); - - // WHEN the component is swiped left to reach the last step - act(() => { - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedLeft(); - }); - act(() => { - (useSwipeable as jest.Mock).mock.calls[1][0].onSwipedLeft(); - }); - - // THEN expect to reach the last step - const lastStepTitle = feedbackFormContentSteps[2].label; - const lastStepTitleElement = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_CONTENT_TITLE); - expect(lastStepTitleElement).toHaveTextContent(lastStepTitle); - - // WHEN the component is swiped left on the last step - act(() => { - (useSwipeable as jest.Mock).mock.calls[2][0].onSwipedLeft(); - }); - - // THEN expect to stay on the last step - expect(lastStepTitleElement).toHaveTextContent(lastStepTitle); - - // AND no errors or warnings to be shown - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - - test("should enable/disable the submit button when the browser online status changes", async () => { - // GIVEN the browser is offline - mockBrowserIsOnLine(false); - - // WHEN the component is rendered - render(); - // AND we are on the last step - act(() => { - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedLeft(); - (useSwipeable as jest.Mock).mock.calls[0][0].onSwipedLeft(); - }); - // AND a question is answered - const customRating = screen.getAllByTestId(CUSTOM_RATING_DATA_TEST_ID.CUSTOM_RATING_ICON)[4]; - fireEvent.click(customRating); - - // THEN expect the submit button to be disabled - const submitButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_NEXT_BUTTON); - expect(submitButton).toBeDisabled(); - - // WHEN the browser goes online - mockBrowserIsOnLine(true); - - // THEN expect the submit button to be enabled - expect(submitButton).toBeEnabled(); - // AND expect no errors or warnings to be logged - expect(console.warn).not.toHaveBeenCalled(); - expect(console.error).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/__snapshots__/FeedbackFormContent.test.tsx.snap b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/__snapshots__/FeedbackFormContent.test.tsx.snap deleted file mode 100644 index 24945ea3f..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/__snapshots__/FeedbackFormContent.test.tsx.snap +++ /dev/null @@ -1,428 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FeedbackFormContent should render component successfully 1`] = ` -
-
-
-

- Bias & Experience Accuracy -

-
-
-
-
- -

- Did you feel that Compass treated you unfairly by making assumptions about your background, language, or other personal characteristics? If so, please describe your experience. -

-
-
- - -
-
-
-
-
- -

- Were there any aspects of your work experience information identified by Compass that were inaccurate? -

-
-
- - - - - - -
-
-
-
-
-
-
-
-
- -
-
-
-
-
- -
-
-`; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/feedbackFormContentSteps.ts b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/feedbackFormContentSteps.ts deleted file mode 100644 index 78ee4aceb..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/feedbackFormContentSteps.ts +++ /dev/null @@ -1,92 +0,0 @@ -import questions from "src/feedback/overallFeedback/feedbackForm/questions-en.json"; -import { - DetailedQuestion, - QuestionType, - YesNoEnum, -} from "src/feedback/overallFeedback/feedbackForm/feedbackForm.types"; - -interface Step { - label: string; - questions: DetailedQuestion[]; -} - -const feedbackFormContentSteps: Step[] = [ - { - label: "Bias & Experience Accuracy", - questions: [ - { - type: QuestionType.YesNo, - questionId: "perceived_bias", - questionText: questions["perceived_bias"].question_text, - showCommentsOn: YesNoEnum.Yes, - placeholder: questions["perceived_bias"].comment_placeholder, - }, - { - type: QuestionType.Checkbox, - questionId: "work_experience_accuracy", - questionText: questions["work_experience_accuracy"].question_text, - options: Object.entries(questions["work_experience_accuracy"].options).map(([key, value]) => ({ key, value })), - lowRatingLabel: "Inaccurate", - highRatingLabel: "Very accurate", - placeholder: questions["work_experience_accuracy"].comment_placeholder, - }, - ], - }, - { - label: "Skill Accuracy", - questions: [ - { - type: QuestionType.YesNo, - questionId: "clarity_of_skills", - questionText: questions["clarity_of_skills"].question_text, - showCommentsOn: YesNoEnum.No, - placeholder: questions["clarity_of_skills"].comment_placeholder, - }, - { - type: QuestionType.YesNo, - questionId: "incorrect_skills", - questionText: questions["incorrect_skills"].question_text, - showCommentsOn: YesNoEnum.Yes, - placeholder: questions["incorrect_skills"].comment_placeholder, - }, - { - type: QuestionType.YesNo, - questionId: "missing_skills", - questionText: questions["missing_skills"].question_text, - showCommentsOn: YesNoEnum.Yes, - placeholder: questions["missing_skills"].comment_placeholder, - }, - ], - }, - { - label: "Final feedback", - questions: [ - { - type: QuestionType.Rating, - questionId: "interaction_ease", - questionText: questions["interaction_ease"].question_text, - lowRatingLabel: "Difficult", - highRatingLabel: "Easy", - maxRating: 5, - placeholder: questions["interaction_ease"].comment_placeholder, - }, - { - type: QuestionType.Rating, - questionId: "recommendation", - questionText: questions["recommendation"].question_text, - lowRatingLabel: "Unlikely", - highRatingLabel: "Likely", - maxRating: 5, - }, - { - type: QuestionType.Rating, - questionId: "additional_feedback", - questionText: questions["additional_feedback"].question_text, - displayRating: false, - placeholder: questions["additional_feedback"].comment_placeholder, - }, - ], - }, -]; - -export default feedbackFormContentSteps; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.test.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.test.tsx deleted file mode 100644 index 1d3bb741f..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -// mute the console -import "src/_test_utilities/consoleMock"; - -import { fireEvent } from "@testing-library/react"; -import { render, screen } from "src/_test_utilities/test-utils"; -import StepsComponent, { - DATA_TEST_ID, -} from "src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent"; -import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; -import { - DetailedQuestion, - QuestionType, - YesNoEnum, -} from "src/feedback/overallFeedback/feedbackForm/feedbackForm.types"; -import { DATA_TEST_ID as CUSTOM_RATING_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating"; -import { DATA_TEST_ID as YES_NO_QUESTION_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion"; -import { DATA_TEST_ID as CHECKBOX_QUESTION_DATA_TEST_ID } from "src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion"; - -describe("StepsComponent", () => { - const mockQuestions: DetailedQuestion[] = [ - { - questionId: "q1", - type: QuestionType.Checkbox, - questionText: "Select options", - options: [ - { key: "option1", value: "option1" }, - { key: "option2", value: "option2" }, - ], - }, - { - questionId: "q2", - type: QuestionType.Rating, - questionText: "Rate this", - displayRating: true, - lowRatingLabel: "Low", - highRatingLabel: "High", - maxRating: 5, - }, - { - questionId: "q3", - type: QuestionType.YesNo, - questionText: "Yes or No?", - showCommentsOn: YesNoEnum.Yes, - }, - ]; - - const mockAnswers: FeedbackItem[] = [ - { question_id: "q1", simplified_answer: { selected_options_keys: ["option1"] } }, - { question_id: "q2", simplified_answer: { rating_numeric: 3 } }, - { question_id: "q3", simplified_answer: { rating_boolean: true, comment: "Yes comment" }}, - ]; - - const mockOnChange = jest.fn(); - - test("should render component successfully", () => { - // Given the component - const givenStepsComponent = ( - - ); - - // When the component is rendered - render(givenStepsComponent); - - // THEN expect no errors or warning to have occurred - expect(console.error).not.toHaveBeenCalled(); - expect(console.warn).not.toHaveBeenCalled(); - // AND expect the component to be in the document - const stepsComponent = screen.getByTestId(DATA_TEST_ID.STEPS_COMPONENT); - expect(stepsComponent).toBeInTheDocument(); - // AND the rating question to be in the document - expect(screen.getByTestId(CUSTOM_RATING_DATA_TEST_ID.CUSTOM_RATING_CONTAINER)).toBeInTheDocument(); - // AND the yes/no question to be in the document - expect(screen.getByTestId(YES_NO_QUESTION_DATA_TEST_ID.FORM_CONTROL)).toBeInTheDocument(); - // AND the checkbox question to be in the document - expect(screen.getByTestId(CHECKBOX_QUESTION_DATA_TEST_ID.FORM_CONTROL)).toBeInTheDocument(); - // AND to match the snapshot - expect(stepsComponent).toMatchSnapshot(); - }); - - test("should call onChange when checkbox question is answered", () => { - // Given the component is rendered - render(); - - // When the checkbox question is answered - const checkbox = screen.getAllByTestId(CHECKBOX_QUESTION_DATA_TEST_ID.CHECKBOX_OPTION)[0]; - fireEvent.click(checkbox); - - // Then expect onChange to be called - expect(mockOnChange).toHaveBeenCalled(); - }); - - test("should call onChange when rating question is answered", () => { - // Given the component is rendered - render(); - - // When the rating question is answered - const starsIcon = screen.getAllByTestId(CUSTOM_RATING_DATA_TEST_ID.CUSTOM_RATING_ICON)[4]; - fireEvent.click(starsIcon); - - // Then expect onChange to be called - expect(mockOnChange).toHaveBeenCalled(); - }); - - test("should call onChange when yes/no question is answered", () => { - // Given the component is rendered - render(); - - // When the yes/no question is answered - const radioNo = screen.getByTestId(YES_NO_QUESTION_DATA_TEST_ID.RADIO_NO); - fireEvent.click(radioNo); - - // Then expect onChange to be called - expect(mockOnChange).toHaveBeenCalled(); - }); -}); diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.tsx deleted file mode 100644 index ab1dce33e..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/stepsComponent/StepsComponent.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; -import { Box } from "@mui/material"; -import { DetailedQuestion, QuestionType } from "src/feedback/overallFeedback/feedbackForm/feedbackForm.types"; -import { - SimplifiedAnswer, - FeedbackItem, -} from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; -import CustomRating from "src/feedback/overallFeedback/feedbackForm/components/customRating/CustomRating"; -import YesNoQuestion from "src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion"; -import CheckboxQuestion from "src/feedback/overallFeedback/feedbackForm/components/checkboxQuestion/CheckboxQuestion"; -import { useIsSmallOrShortScreen } from "src/feedback/overallFeedback/feedbackForm/useIsSmallOrShortScreen"; - -interface StepProps { - questions: DetailedQuestion[]; - feedbackItems: FeedbackItem[]; - onChange: (data: FeedbackItem) => void; -} - -const uniqueId = "3bf900a2-f9fa-4b28-8c20-8bc21570635c"; - -export const DATA_TEST_ID = { - STEPS_COMPONENT: `steps-component-${uniqueId}`, -}; - -const StepsComponent: React.FC = ({ questions, feedbackItems, onChange }) => { - const isSmallOrShortScreen = useIsSmallOrShortScreen() - - const getAnswerByQuestionId = (questionId: string): SimplifiedAnswer | undefined => { - return feedbackItems.find((item: FeedbackItem) => item.question_id === questionId)?.simplified_answer; - }; - - const handleInputChange = (questionId: string, value: SimplifiedAnswer) => { - const formattedData: FeedbackItem = { - question_id: questionId, - simplified_answer: value, - }; - onChange(formattedData); - }; - - return ( - (isSmallOrShortScreen ? theme.tabiyaSpacing.xl * 3 : theme.tabiyaSpacing.xl * 1.2)} - data-testid={DATA_TEST_ID.STEPS_COMPONENT} - > - {questions.map((question) => { - const answer = getAnswerByQuestionId(question.questionId) || {}; - - return ( - - {question.type === QuestionType.Checkbox && ( - - handleInputChange(question.questionId, { selected_options_keys: selectedOptions, comment: comments }) - } - comments={answer.comment ?? ""} - placeholder={question.placeholder} - /> - )} - {question.type === QuestionType.Rating && ( - - handleInputChange(question.questionId, { rating_numeric: value, comment: comments }) - } - lowRatingLabel={question.lowRatingLabel ?? ""} - highRatingLabel={question.highRatingLabel ?? ""} - comments={answer.comment ?? ""} - maxRating={question.maxRating ?? 5} - placeholder={question.placeholder} - /> - )} - {question.type === QuestionType.YesNo && ( - - handleInputChange(question.questionId, { rating_boolean: value, comment: comments }) - } - showCommentsOn={question.showCommentsOn ?? undefined} - comments={answer.comment ?? ""} - placeholder={question.placeholder} - /> - )} - - ); - })} - - ); -}; - -export default StepsComponent; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion.stories.tsx b/frontend-new/src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion.stories.tsx deleted file mode 100644 index d4dc47e21..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import YesNoQuestion from "src/feedback/overallFeedback/feedbackForm/components/yesNoQuestion/YesNoQuestion"; -import { YesNoEnum } from "src/feedback/overallFeedback/feedbackForm/feedbackForm.types"; -import { action } from "@storybook/addon-actions"; - -const meta: Meta = { - title: "Feedback/YesNoQuestion", - component: YesNoQuestion, - tags: ["autodocs"], - args: { - notifyChange: (value, comments) => { - action("notifyChange")(value, comments); - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const ShowCommentsWhenYesSelected: Story = { - args: { - questionText: "Is this a question?", - questionId: "is_question", - showCommentsOn: YesNoEnum.Yes, - placeholder: "Please provide comments", - }, -}; - -export const ShowCommentsWhenNoSelected: Story = { - args: { - questionText: "Is this not a question?", - questionId: "is_not_question", - showCommentsOn: YesNoEnum.No, - placeholder: "Please provide comments", - }, -}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/feedbackForm.types.ts b/frontend-new/src/feedback/overallFeedback/feedbackForm/feedbackForm.types.ts deleted file mode 100644 index 58cef58ca..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/feedbackForm.types.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface Option { - key: string; - value: string; -} - -export enum QuestionType { - Rating = "rating", - YesNo = "yesNo", - Checkbox = "checkbox", -} - -export enum YesNoEnum { - Yes = "yes", - No = "no", -} - -export interface BaseQuestion { - type: QuestionType; - questionId: string; - questionText: string; - placeholder?: string; -} - -export interface DetailedQuestion extends BaseQuestion { - lowRatingLabel?: string; - highRatingLabel?: string; - showCommentsOn?: YesNoEnum; - displayRating?: boolean; - options?: Option[]; - maxRating?: number; -} diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-en.json b/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-en.json deleted file mode 100644 index 905b951d6..000000000 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/questions-en.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "interaction_ease": { - "question_text": "How easy or difficult was it to interact with Compass and understand its responses?", - "description": "This question is used to measure the Customer Effort Score (CES). The response is on a scale from 1 (Very difficult) to 5 (Very easy).", - "comment_placeholder": "Please explain your rating and share any suggestions for improving the ease of interaction with Compass." - }, - "clarity_of_skills": { - "question_text": "Did Compass help you gain a clearer understanding of your skills? If not, why?", - "description": "This question aims to measure Perceived Usefulness (PU). The user can answer TRUE (indicating Compass helped) or FALSE (indicating Compass did not help), with an optional text field for additional comments.", - "comment_placeholder": "Please share why Compass did not help you gain a clearer understanding of your skills." - }, - "satisfaction_with_compass": { - "question_text": "How satisfied are you with Compass?", - "description": "This question is used to measure the Customer Satisfaction Score (CSAT). The response is on a scale from 1 (Very dissatisfied) to 5 (Very satisfied).", - "comment_placeholder": null - }, - "work_experience_accuracy": { - "question_text": "Were there any aspects of your work experience information identified by Compass that were inaccurate?", - "options": { - "experience_title": "Incorrect Experience title", - "experience_dates": "Incorrect start/end dates", - "experience_location": "Incorrect location", - "duplicate_experience": "Duplicate Experiences", - "missing_experience": "Missing Experiences", - "other": "Other" - }, - "description": "This question to identify specific inaccuracies in the work experience information. The response is an array of selected option keys, with optional additional comments.", - "comment_placeholder": "Please provide more details about the inaccuracies in your work experience information identified by Compass." - }, - "incorrect_skills": { - "question_text": "Are there any skills that Compass incorrectly identified for you?", - "description": "This question aims to indentify incorrectly identified skills The user can answer TRUE (indicating incorrect skills) or FALSE (indicating no incorrect skills). In case of TRUE, the user can provide additional comments.", - "comment_placeholder": "Please list any skills that Compass incorrectly identified for you." - }, - "missing_skills": { - "question_text": "Are there any skills you have that Compass missed and did not identify?", - "description": "This question aims to identify missing skills. Responses should be TRUE (indicating missing skills) or FALSE (indicating no missing skills). In case of TRUE, the user can provide additional comments.", - "comment_placeholder": "Please list any skills you have that Compass did not identify." - }, - "perceived_bias": { - "question_text": "Did you feel that Compass treated you unfairly by making assumptions about your background, language, or other personal characteristics? If so, please describe your experience.", - "description": "This question aims to identify perceived bias. Responses should be TRUE (indicating bias) or FALSE (indicating no bias), with an optional text field for additional comments.", - "comment_placeholder": "Please share more details about your experience. Include specific examples of when you felt Compass treated you unfairly or made assumptions about your background, language, or other personal characteristics." - }, - "recommendation": { - "question_text": "How likely are you to recommend Compass to other job seekers?", - "description": "This question is used to measure the Net Promoter Score (NPS). The response is on a scale from 1 (Not at all likely) to 5 (Extremely likely).", - "comment_placeholder": null - }, - "additional_feedback": { - "question_text": "Please share any additional feedback or suggestions you have for improving Compass.", - "description": "Used to collect any additional feedback or suggestions for improving Compass from the user. The response is in the form of a text.", - "comment_placeholder": "We'd love to hear your thoughts! Please share any additional feedback or suggestions you have for improving Compass." - } -} diff --git a/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.stories.tsx b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.stories.tsx new file mode 100644 index 000000000..bd9cb703b --- /dev/null +++ b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.stories.tsx @@ -0,0 +1,100 @@ +import { Meta, type StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import OverallFeedbackForm from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; +import { FeedbackProvider } from "src/feedback/overallFeedback/feedbackContext/FeedbackContext"; +import { getBackendUrl } from "src/envService"; +import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; +import AuthenticationServiceFactory from "src/auth/services/Authentication.service.factory"; +import AuthenticationStateService from "src/auth/services/AuthenticationState.service"; +import { TabiyaUser } from "src/auth/auth.types"; +import AuthenticationService from "src/auth/services/Authentication.service"; +import { mockQuestionsConfig } from "src/feedback/overallFeedback/overallFeedbackForm/overallFeedbackForm.test.utils"; + +// Mock authentication service to provide token validation and user info for API calls +class MockAuthenticationService extends AuthenticationService { + private static instance: MockAuthenticationService; + + private constructor() { + super(); + } + + static getInstance(): MockAuthenticationService { + if (!this.instance) { + this.instance = new this(); + } + return this.instance; + } + + async refreshToken(): Promise {} + cleanup(): void {} + async logout(): Promise {} + getUser(token: string): TabiyaUser | null { + return { id: "1", name: "Test User", email: "test@example.com" } as TabiyaUser; + } + getToken(): string { + return "foo token"; + } + isTokenValid(token: string): { isValid: boolean; decodedToken: any; failureCause?: string } { + return { isValid: true, decodedToken: { sub: "1", email: "test@example.com" } }; + } +} + +const meta: Meta = { + title: "Feedback/OverallFeedback/OverallFeedbackForm", + component: OverallFeedbackForm, + tags: ["autodocs"], + args: { + notifyOnClose: action("notifyOnClose"), + }, + parameters: { + mockData: [ + // Mock the questions config endpoint that FeedbackProvider uses to fetch question data + { + url: getBackendUrl() + "/conversations/123/feedback/questions", + method: "GET", + status: 200, + response: mockQuestionsConfig, + }, + // Mock the feedback submission endpoint that OverallFeedbackService uses to save ratings + { + url: getBackendUrl() + "/conversations/123/feedback", + method: "PATCH", + status: 200, + response: { + message: "Feedback submitted successfully", + }, + }, + ], + }, + decorators: [ + (Story) => { + // Mock session ID for API endpoint construction + const mockUserPrefsService = UserPreferencesStateService.getInstance(); + mockUserPrefsService.getActiveSessionId = () => 123; + + // Mock auth state for API authentication and user identification + const mockAuthStateService = AuthenticationStateService.getInstance(); + mockAuthStateService.getToken = () => "foo token"; + mockAuthStateService.getUser = () => ({ id: "1", name: "Test User", email: "test@example.com" } as TabiyaUser); + + // Mock auth service factory to provide our mock auth service + AuthenticationServiceFactory.getCurrentAuthenticationService = () => MockAuthenticationService.getInstance(); + + return ( + + + + ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Shown: Story = { + args: { + isOpen: true, + }, +}; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.test.tsx similarity index 72% rename from frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx rename to frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.test.tsx index 7f0f8ac4e..150b7f37c 100644 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.test.tsx +++ b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.test.tsx @@ -1,10 +1,10 @@ // mute the console import "src/_test_utilities/consoleMock"; -import FeedbackForm, { DATA_TEST_ID, FeedbackCloseEvent } from "src/feedback/overallFeedback/feedbackForm/FeedbackForm"; +import OverallFeedbackForm, { DATA_TEST_ID, FeedbackCloseEvent } from "src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm"; import { render, screen } from "src/_test_utilities/test-utils"; import { act, fireEvent, waitFor } from "@testing-library/react"; -import FeedbackFormContent from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"; +import FormContent from "./components/formContent/FormContent"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { useSnackbar } from "src/theme/SnackbarProvider/SnackbarProvider"; import { @@ -14,6 +14,9 @@ import { import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service"; import { FeedbackError } from "src/error/commonErrors"; import { FeedbackResponse } from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service.types"; +import { FeedbackProvider } from "src/feedback/overallFeedback/feedbackContext/FeedbackContext"; +import React from "react"; +import { mockQuestionsConfig } from "src/feedback/overallFeedback/overallFeedbackForm/overallFeedbackForm.test.utils"; // mock the snackbar provider jest.mock("src/theme/SnackbarProvider/SnackbarProvider", () => { @@ -38,8 +41,8 @@ jest.mock("src/userPreferences/UserPreferencesStateService", () => ({ })); // mock the feedback form content -jest.mock("src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent", () => { - const actual = jest.requireActual("src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"); +jest.mock("src/feedback/overallFeedback/overallFeedbackForm/components/formContent/FormContent", () => { + const actual = jest.requireActual("src/feedback/overallFeedback/overallFeedbackForm/components/formContent/FormContent"); return { ...actual, __esModule: true, @@ -47,6 +50,14 @@ jest.mock("src/feedback/overallFeedback/feedbackForm/components/feedbackFormCont }; }); +// Mock OverallFeedbackService.getQuestionsConfig +jest.spyOn(OverallFeedbackService.getInstance(), "getQuestionsConfig").mockResolvedValue(mockQuestionsConfig); + +// Helper to wrap in FeedbackProvider +const renderWithFeedbackProvider = (ui: React.ReactElement) => { + return render({ui}); +}; + const mockFeedbackResponse: FeedbackResponse = { id: "foo", version: { @@ -62,10 +73,10 @@ const mockFeedbackResponse: FeedbackResponse = { created_at: new Date().toISOString() } -describe("FeedbackForm", () => { +describe("OverallFeedbackForm", () => { test("should render component successfully", () => { // GIVEN the component - const givenFeedbackForm = ; + const givenFeedbackForm = ; // WHEN the component is rendered render(givenFeedbackForm); @@ -74,16 +85,16 @@ describe("FeedbackForm", () => { expect(console.error).not.toHaveBeenCalled(); expect(console.warn).not.toHaveBeenCalled(); // AND the feedback form dialog to be in the document - const feedbackFormContainer = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG); + const feedbackFormContainer = screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG); expect(feedbackFormContainer).toBeInTheDocument(); // AND the feedback form dialog title to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG_TITLE)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG_TITLE)).toBeInTheDocument(); // AND the feedback form dialog button to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG_BUTTON)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG_BUTTON)).toBeInTheDocument(); // AND the feedback form dialog icon button to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG_ICON_BUTTON)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG_ICON_BUTTON)).toBeInTheDocument(); // AND the feedback form dialog content to be in the document - expect(screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG_CONTENT)).toBeInTheDocument(); + expect(screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG_CONTENT)).toBeInTheDocument(); // AND to match the snapshot expect(feedbackFormContainer).toMatchSnapshot(); }); @@ -91,19 +102,19 @@ describe("FeedbackForm", () => { test("should call handleClose when close button is clicked", () => { // GIVEN the component const mockHandleClose = jest.fn(); - const givenFeedbackForm = ; + const givenFeedbackForm = ; // AND the component is rendered - render(givenFeedbackForm); + renderWithFeedbackProvider(givenFeedbackForm); // WHEN the close button is clicked - const closeButton = screen.getByTestId(DATA_TEST_ID.FEEDBACK_FORM_DIALOG_ICON_BUTTON); + const closeButton = screen.getByTestId(DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG_ICON_BUTTON); fireEvent.click(closeButton); // THEN expect the notifyOnClose function to have been called expect(mockHandleClose).toHaveBeenCalled(); }); - describe("FeedbackForm handleFeedbackSubmit", () => { + describe("OverallFeedbackForm handleFeedbackSubmit", () => { const mockSendFeedback = jest.fn(); beforeEach(() => { @@ -116,10 +127,10 @@ describe("FeedbackForm", () => { jest.spyOn(OverallFeedbackService.getInstance(), "sendFeedback").mockResolvedValueOnce(mockFeedbackResponse) // AND the component is rendered const mockHandleClose = jest.fn(); - const givenFeedbackForm = ; - render(givenFeedbackForm); + const givenFeedbackForm = ; + renderWithFeedbackProvider(givenFeedbackForm); // AND when the submit button is clicked - const submitCallback = (FeedbackFormContent as jest.Mock).mock.calls.at(-1)[0].notifySubmit; + const submitCallback = (FormContent as jest.Mock).mock.calls.at(-1)[0].notifySubmit; await act(async () => { submitCallback([ { @@ -160,9 +171,9 @@ describe("FeedbackForm", () => { }); // WHEN the component is rendered - render(); + renderWithFeedbackProvider(); // AND when the submit button is clicked - const submitCallback = (FeedbackFormContent as jest.Mock).mock.calls.at(-1)[0].notifySubmit; + const submitCallback = (FormContent as jest.Mock).mock.calls.at(-1)[0].notifySubmit; await act(async () => { submitCallback([ { diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.tsx b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.tsx similarity index 76% rename from frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.tsx rename to frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.tsx index b3d598569..3530d1905 100644 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/FeedbackForm.tsx +++ b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/OverallFeedbackForm.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Dialog, DialogContent, DialogTitle, Typography, useTheme } from "@mui/material"; -import FeedbackFormContent from "src/feedback/overallFeedback/feedbackForm/components/feedbackFormContent/FeedbackFormContent"; +import FormContent from "src/feedback/overallFeedback/overallFeedbackForm/components/formContent/FormContent"; import PrimaryIconButton from "src/theme/PrimaryIconButton/PrimaryIconButton"; import CloseIcon from "@mui/icons-material/Close"; import OverallFeedbackService from "src/feedback/overallFeedback/overallFeedbackService/OverallFeedback.service"; @@ -9,7 +9,7 @@ import { FeedbackItem } from "src/feedback/overallFeedback/overallFeedbackServic import { Backdrop } from "src/theme/Backdrop/Backdrop"; import UserPreferencesStateService from "src/userPreferences/UserPreferencesStateService"; import { FeedbackError } from "src/error/commonErrors"; -import { useIsSmallOrShortScreen } from "src/feedback/overallFeedback/feedbackForm/useIsSmallOrShortScreen"; +import { useIsSmallOrShortScreen } from "src/feedback/overallFeedback/overallFeedbackForm/useIsSmallOrShortScreen"; export interface FeedbackFormProps { isOpen: boolean; @@ -30,14 +30,14 @@ export enum FeedbackCloseEvent { const uniqueId = "c6ba52ec-c1de-46ac-950b-f5354c6785ac"; export const DATA_TEST_ID = { - FEEDBACK_FORM_DIALOG: `feedback-form-dialog-${uniqueId}`, - FEEDBACK_FORM_DIALOG_TITLE: `feedback-form-dialog-title-${uniqueId}`, - FEEDBACK_FORM_DIALOG_BUTTON: `feedback-form-dialog-button-${uniqueId}`, - FEEDBACK_FORM_DIALOG_ICON_BUTTON: `feedback-form-dialog-icon-button-${uniqueId}`, - FEEDBACK_FORM_DIALOG_CONTENT: `feedback-form-dialog-content-${uniqueId}`, + OVERALL_FEEDBACK_FORM_DIALOG: `overall-feedback-form-dialog-${uniqueId}`, + OVERALL_FEEDBACK_FORM_DIALOG_TITLE: `overall-feedback-form-dialog-title-${uniqueId}`, + OVERALL_FEEDBACK_FORM_DIALOG_BUTTON: `overall-feedback-form-dialog-button-${uniqueId}`, + OVERALL_FEEDBACK_FORM_DIALOG_ICON_BUTTON: `overall-feedback-form-dialog-icon-button-${uniqueId}`, + OVERALL_FEEDBACK_FORM_DIALOG_CONTENT: `overall-feedback-form-dialog-content-${uniqueId}`, }; -const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => { +const OverallFeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => { const theme = useTheme(); const { enqueueSnackbar } = useSnackbar(); const isSmallOrShortScreen = useIsSmallOrShortScreen(); @@ -49,7 +49,6 @@ const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => const handleFeedbackSubmit = async (formData: FeedbackItem[]): Promise => { setIsSubmitting(true); - notifyOnClose(FeedbackCloseEvent.SUBMIT); try { const userPreferences = UserPreferencesStateService.getInstance().getUserPreferences(); if (!userPreferences?.sessions.length) { @@ -61,6 +60,7 @@ const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => await overallFeedbackService.sendFeedback(sessionId, formData); enqueueSnackbar("Feedback submitted successfully!", { variant: "success" }); + notifyOnClose(FeedbackCloseEvent.SUBMIT); } catch (error) { console.error(new FeedbackError("Failed to submit feedback", error)); enqueueSnackbar("Failed to submit feedback. Please try again later.", { variant: "error" }); @@ -77,7 +77,7 @@ const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => maxWidth="sm" fullWidth={true} fullScreen={isSmallOrShortScreen} - data-testid={DATA_TEST_ID.FEEDBACK_FORM_DIALOG} + data-testid={DATA_TEST_ID.OVERALL_FEEDBACK_FORM_DIALOG} sx={{ "& .MuiDialog-paper": { height: isSmallOrShortScreen ? "100%" : "85%", @@ -101,20 +101,20 @@ const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => paddingBottom: 0, }} > - + Help us improve! - + = ({ isOpen, notifyOnClose }) => : theme.fixedSpacing(theme.tabiyaSpacing.md), }} > - + @@ -130,4 +130,4 @@ const FeedbackForm: React.FC = ({ isOpen, notifyOnClose }) => ); }; -export default FeedbackForm; +export default OverallFeedbackForm; diff --git a/frontend-new/src/feedback/overallFeedback/feedbackForm/__snapshots__/FeedbackForm.test.tsx.snap b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/__snapshots__/OverallFeedbackForm.test.tsx.snap similarity index 82% rename from frontend-new/src/feedback/overallFeedback/feedbackForm/__snapshots__/FeedbackForm.test.tsx.snap rename to frontend-new/src/feedback/overallFeedback/overallFeedbackForm/__snapshots__/OverallFeedbackForm.test.tsx.snap index 36be63abd..899836444 100644 --- a/frontend-new/src/feedback/overallFeedback/feedbackForm/__snapshots__/FeedbackForm.test.tsx.snap +++ b/frontend-new/src/feedback/overallFeedback/overallFeedbackForm/__snapshots__/OverallFeedbackForm.test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FeedbackForm should render component successfully 1`] = ` +exports[`OverallFeedbackForm should render component successfully 1`] = `