diff --git a/api-docs/.openapi-generator/FILES b/api-docs/.openapi-generator/FILES index 9f0d0be5..d7a5f550 100644 --- a/api-docs/.openapi-generator/FILES +++ b/api-docs/.openapi-generator/FILES @@ -12,6 +12,7 @@ Models/AnswerDetails.md Models/AuthToken.md Models/CodeExecution.md Models/DifficultyLevel.md +Models/DifficultyLevelRequestDto.md Models/Error.md Models/ErrorResponseObject.md Models/ExecutionResult.md @@ -21,6 +22,7 @@ Models/NewQuestion.md Models/NewTag.md Models/Profile.md Models/QuestionDetails.md +Models/QuestionRateResponseDto.md Models/QuestionSummary.md Models/SuccessResponseObject.md Models/SuccessResponseObject_data.md @@ -39,10 +41,12 @@ Models/createAnswer_201_response.md Models/createQuestion_201_response.md Models/createTag_201_response.md Models/executeCode_200_response.md +Models/getBookmarkedQuestions_200_response.md Models/getQuestionAnswers_200_response.md Models/getQuestionAnswers_200_response_allOf_data.md Models/getUserFollowers_200_response.md Models/getUserProfile_200_response.md +Models/rateQuestion_200_response.md Models/resetPassword_request.md Models/searchQuestions_200_response.md Models/searchQuestions_200_response_allOf_data.md diff --git a/api-docs/Apis/QuestionsApi.md b/api-docs/Apis/QuestionsApi.md index 20fcf6a0..d25b5ebf 100644 --- a/api-docs/Apis/QuestionsApi.md +++ b/api-docs/Apis/QuestionsApi.md @@ -4,12 +4,14 @@ All URIs are relative to *http://localhost:5173/api/v1* | Method | HTTP request | Description | |------------- | ------------- | -------------| -| [**bookmarkQuestion**](QuestionsApi.md#bookmarkQuestion) | **POST** /questions/{questionId}/bookmark | Bookmark a question | +| [**bookmarkQuestion**](QuestionsApi.md#bookmarkQuestion) | **POST** /questions/{questionId}/bookmarks | Bookmark a question | | [**createQuestion**](QuestionsApi.md#createQuestion) | **POST** /questions | Create a new question | | [**deleteQuestion**](QuestionsApi.md#deleteQuestion) | **DELETE** /questions/{questionId} | Delete a question | | [**downvoteQuestion**](QuestionsApi.md#downvoteQuestion) | **POST** /questions/{questionId}/downvote | Downvote a question | +| [**getBookmarkedQuestions**](QuestionsApi.md#getBookmarkedQuestions) | **GET** /questions/bookmarked | Get bookmarked questions | | [**getQuestionDetails**](QuestionsApi.md#getQuestionDetails) | **GET** /questions/{questionId} | Get question details | -| [**removeQuestionBookmark**](QuestionsApi.md#removeQuestionBookmark) | **DELETE** /questions/{questionId}/bookmark | Remove bookmark from a question | +| [**rateQuestion**](QuestionsApi.md#rateQuestion) | **POST** /questions/{id}/vote-difficulty | Rate a question's level of difficulty. | +| [**removeQuestionBookmark**](QuestionsApi.md#removeQuestionBookmark) | **DELETE** /questions/{questionId}/bookmarks | Remove bookmark from a question | | [**updateQuestion**](QuestionsApi.md#updateQuestion) | **PUT** /questions/{questionId} | Update a question | | [**upvoteQuestion**](QuestionsApi.md#upvoteQuestion) | **POST** /questions/{questionId}/upvote | Upvote a question | @@ -114,6 +116,28 @@ null (empty response body) - **Content-Type**: Not defined - **Accept**: application/json + +# **getBookmarkedQuestions** +> getBookmarkedQuestions_200_response getBookmarkedQuestions() + +Get bookmarked questions + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**getBookmarkedQuestions_200_response**](../Models/getBookmarkedQuestions_200_response.md) + +### Authorization + +[bearerAuth](../README.md#bearerAuth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + # **getQuestionDetails** > createQuestion_201_response getQuestionDetails(questionId) @@ -139,6 +163,32 @@ No authorization required - **Content-Type**: Not defined - **Accept**: application/json + +# **rateQuestion** +> rateQuestion_200_response rateQuestion(id, DifficultyLevelRequestDto) + +Rate a question's level of difficulty. + +### Parameters + +|Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **id** | **Long**| | [default to null] | +| **DifficultyLevelRequestDto** | [**DifficultyLevelRequestDto**](../Models/DifficultyLevelRequestDto.md)| | | + +### Return type + +[**rateQuestion_200_response**](../Models/rateQuestion_200_response.md) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: application/json + # **removeQuestionBookmark** > removeQuestionBookmark(questionId) diff --git a/api-docs/Apis/SearchApi.md b/api-docs/Apis/SearchApi.md index 9901d6a4..7462cb0d 100644 --- a/api-docs/Apis/SearchApi.md +++ b/api-docs/Apis/SearchApi.md @@ -11,7 +11,7 @@ All URIs are relative to *http://localhost:5173/api/v1* # **searchQuestions** -> searchQuestions_200_response searchQuestions(q, tags, difficulty, page, pageSize) +> searchQuestions_200_response searchQuestions(q, tags, difficulty, page, pageSize, sortBy) Search questions @@ -24,6 +24,7 @@ Search questions | **difficulty** | [**DifficultyLevel**](../Models/.md)| Filter by difficulty level | [optional] [default to null] [enum: EASY, MEDIUM, HARD] | | **page** | **Integer**| Page number | [optional] [default to 1] | | **pageSize** | **Integer**| Number of items per page | [optional] [default to 20] | +| **sortBy** | **String**| Sorting type | [optional] [default to recommended] | ### Return type diff --git a/api-docs/Apis/TagsApi.md b/api-docs/Apis/TagsApi.md index 32db1fc6..d04931d6 100644 --- a/api-docs/Apis/TagsApi.md +++ b/api-docs/Apis/TagsApi.md @@ -45,7 +45,7 @@ Follow a tag |Name | Type | Description | Notes | |------------- | ------------- | ------------- | -------------| -| **tagId** | **String**| | [default to null] | +| **tagId** | **Integer**| | [default to null] | ### Return type @@ -70,7 +70,7 @@ Get tag details |Name | Type | Description | Notes | |------------- | ------------- | ------------- | -------------| -| **tagId** | **String**| | [default to null] | +| **tagId** | **Integer**| | [default to null] | ### Return type @@ -95,7 +95,7 @@ Unfollow a tag |Name | Type | Description | Notes | |------------- | ------------- | ------------- | -------------| -| **tagId** | **String**| | [default to null] | +| **tagId** | **Integer**| | [default to null] | ### Return type diff --git a/api-docs/Models/DifficultyLevelRequestDto.md b/api-docs/Models/DifficultyLevelRequestDto.md new file mode 100644 index 00000000..86f22e20 --- /dev/null +++ b/api-docs/Models/DifficultyLevelRequestDto.md @@ -0,0 +1,9 @@ +# DifficultyLevelRequestDto +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **difficulty** | **String** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/api-docs/Models/NewQuestion.md b/api-docs/Models/NewQuestion.md index 31318eaf..e26447f4 100644 --- a/api-docs/Models/NewQuestion.md +++ b/api-docs/Models/NewQuestion.md @@ -6,7 +6,7 @@ | **title** | **String** | | [default to null] | | **content** | **String** | | [default to null] | | **tagIds** | **List** | | [optional] [default to null] | -| **difficultyLevel** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | +| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/api-docs/Models/QuestionDetails.md b/api-docs/Models/QuestionDetails.md index 522a8136..d60ce415 100644 --- a/api-docs/Models/QuestionDetails.md +++ b/api-docs/Models/QuestionDetails.md @@ -12,10 +12,15 @@ | **tags** | [**List**](TagSummary.md) | | [default to null] | | **likeCount** | **Integer** | | [default to null] | | **dislikeCount** | **Integer** | | [default to null] | +| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | | **commentCount** | **Integer** | | [default to null] | | **viewCount** | **Integer** | | [optional] [default to null] | -| **bookmarked** | **Boolean** | | [optional] [default to null] | -| **selfVoted** | **Integer** | | [optional] [default to null] | +| **bookmarked** | **Boolean** | | [default to null] | +| **selfVoted** | **Integer** | | [default to null] | +| **selfDifficultyVote** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | +| **easyCount** | **Integer** | | [default to null] | +| **mediumCount** | **Integer** | | [default to null] | +| **hardCount** | **Integer** | | [default to null] | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/api-docs/Models/QuestionRateResponseDto.md b/api-docs/Models/QuestionRateResponseDto.md new file mode 100644 index 00000000..c4df3acb --- /dev/null +++ b/api-docs/Models/QuestionRateResponseDto.md @@ -0,0 +1,13 @@ +# QuestionRateResponseDto +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **questionId** | **Long** | | [optional] [default to null] | +| **easyCount** | **Long** | | [optional] [default to null] | +| **mediumCount** | **Long** | | [optional] [default to null] | +| **hardCount** | **Long** | | [optional] [default to null] | +| **totalCount** | **Long** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/api-docs/Models/QuestionSummary.md b/api-docs/Models/QuestionSummary.md index e235f557..705c3b0f 100644 --- a/api-docs/Models/QuestionSummary.md +++ b/api-docs/Models/QuestionSummary.md @@ -5,13 +5,14 @@ |------------ | ------------- | ------------- | -------------| | **id** | **Integer** | | [default to null] | | **title** | **String** | | [default to null] | -| **questionBody** | **String** | | [optional] [default to null] | +| **content** | **String** | | [optional] [default to null] | | **author** | [**UserSummary**](UserSummary.md) | | [optional] [default to null] | | **createdAt** | **Date** | | [default to null] | -| **difficultyLevel** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | +| **difficulty** | [**DifficultyLevel**](DifficultyLevel.md) | | [default to null] | | **tags** | [**List**](TagSummary.md) | | [default to null] | -| **likeCount** | **Integer** | | [default to null] | -| **commentCount** | **Integer** | | [default to null] | +| **upvoteCount** | **Integer** | | [default to null] | +| **downvoteCount** | **Integer** | | [default to null] | +| **answerCount** | **Integer** | | [default to null] | | **viewCount** | **Integer** | | [optional] [default to null] | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/api-docs/Models/TagDetails.md b/api-docs/Models/TagDetails.md index 071dfa9f..7f98e067 100644 --- a/api-docs/Models/TagDetails.md +++ b/api-docs/Models/TagDetails.md @@ -3,7 +3,7 @@ | Name | Type | Description | Notes | |------------ | ------------- | ------------- | -------------| -| **tagId** | **String** | | [default to null] | +| **tagId** | **Integer** | | [default to null] | | **name** | **String** | | [default to null] | | **tagType** | [**TagType**](TagType.md) | | [optional] [default to null] | | **description** | **String** | | [default to null] | diff --git a/api-docs/Models/TagSummary.md b/api-docs/Models/TagSummary.md index 50324417..bee584f1 100644 --- a/api-docs/Models/TagSummary.md +++ b/api-docs/Models/TagSummary.md @@ -5,6 +5,7 @@ |------------ | ------------- | ------------- | -------------| | **id** | **String** | | [optional] [default to null] | | **name** | **String** | | [optional] [default to null] | +| **tagType** | [**TagType**](TagType.md) | | [optional] [default to null] | | **questionCount** | **Integer** | | [optional] [default to null] | | **photo** | **String** | | [optional] [default to null] | diff --git a/api-docs/Models/UserProfile.md b/api-docs/Models/UserProfile.md index 6901036d..145c95f4 100644 --- a/api-docs/Models/UserProfile.md +++ b/api-docs/Models/UserProfile.md @@ -19,6 +19,7 @@ | **answerCount** | **Integer** | | [optional] [default to null] | | **answers** | [**List**](AnswerDetails.md) | | [optional] [default to null] | | **questions** | [**List**](QuestionSummary.md) | | [optional] [default to null] | +| **followedTags** | [**List**](TagSummary.md) | | [optional] [default to null] | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/api-docs/Models/getBookmarkedQuestions_200_response.md b/api-docs/Models/getBookmarkedQuestions_200_response.md new file mode 100644 index 00000000..6d84de91 --- /dev/null +++ b/api-docs/Models/getBookmarkedQuestions_200_response.md @@ -0,0 +1,10 @@ +# getBookmarkedQuestions_200_response +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **status** | **Integer** | Internal status code of the response. An HTTP 200 response with an internal 500 status code is an error response. Prioritize the inner status over the HTTP status. | [default to null] | +| **data** | [**List**](QuestionSummary.md) | | [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/api-docs/Models/rateQuestion_200_response.md b/api-docs/Models/rateQuestion_200_response.md new file mode 100644 index 00000000..1057874f --- /dev/null +++ b/api-docs/Models/rateQuestion_200_response.md @@ -0,0 +1,10 @@ +# rateQuestion_200_response +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **status** | **Integer** | Internal status code of the response. An HTTP 200 response with an internal 500 status code is an error response. Prioritize the inner status over the HTTP status. | [default to null] | +| **data** | [**QuestionRateResponseDto**](QuestionRateResponseDto.md) | | [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/api-docs/README.md b/api-docs/README.md index 9f7d6913..3cba0da2 100644 --- a/api-docs/README.md +++ b/api-docs/README.md @@ -20,12 +20,14 @@ All URIs are relative to *http://localhost:5173/api/v1* *AuthApi* | [**verifyEmail**](Apis/AuthApi.md#verifyemail) | **POST** /auth/verify-email | Verify user's email | | *CodeExecutionApi* | [**executeCode**](Apis/CodeExecutionApi.md#executecode) | **POST** /execute-code | Execute a code snippet | | *FeedApi* | [**getUserFeed**](Apis/FeedApi.md#getuserfeed) | **GET** /feed | Get user feed | -| *QuestionsApi* | [**bookmarkQuestion**](Apis/QuestionsApi.md#bookmarkquestion) | **POST** /questions/{questionId}/bookmark | Bookmark a question | +| *QuestionsApi* | [**bookmarkQuestion**](Apis/QuestionsApi.md#bookmarkquestion) | **POST** /questions/{questionId}/bookmarks | Bookmark a question | *QuestionsApi* | [**createQuestion**](Apis/QuestionsApi.md#createquestion) | **POST** /questions | Create a new question | *QuestionsApi* | [**deleteQuestion**](Apis/QuestionsApi.md#deletequestion) | **DELETE** /questions/{questionId} | Delete a question | *QuestionsApi* | [**downvoteQuestion**](Apis/QuestionsApi.md#downvotequestion) | **POST** /questions/{questionId}/downvote | Downvote a question | +*QuestionsApi* | [**getBookmarkedQuestions**](Apis/QuestionsApi.md#getbookmarkedquestions) | **GET** /questions/bookmarked | Get bookmarked questions | *QuestionsApi* | [**getQuestionDetails**](Apis/QuestionsApi.md#getquestiondetails) | **GET** /questions/{questionId} | Get question details | -*QuestionsApi* | [**removeQuestionBookmark**](Apis/QuestionsApi.md#removequestionbookmark) | **DELETE** /questions/{questionId}/bookmark | Remove bookmark from a question | +*QuestionsApi* | [**rateQuestion**](Apis/QuestionsApi.md#ratequestion) | **POST** /questions/{id}/vote-difficulty | Rate a question's level of difficulty. | +*QuestionsApi* | [**removeQuestionBookmark**](Apis/QuestionsApi.md#removequestionbookmark) | **DELETE** /questions/{questionId}/bookmarks | Remove bookmark from a question | *QuestionsApi* | [**updateQuestion**](Apis/QuestionsApi.md#updatequestion) | **PUT** /questions/{questionId} | Update a question | *QuestionsApi* | [**upvoteQuestion**](Apis/QuestionsApi.md#upvotequestion) | **POST** /questions/{questionId}/upvote | Upvote a question | | *SearchApi* | [**searchQuestions**](Apis/SearchApi.md#searchquestions) | **GET** /search/questions | Search questions | @@ -51,6 +53,7 @@ All URIs are relative to *http://localhost:5173/api/v1* - [AuthToken](./Models/AuthToken.md) - [CodeExecution](./Models/CodeExecution.md) - [DifficultyLevel](./Models/DifficultyLevel.md) + - [DifficultyLevelRequestDto](./Models/DifficultyLevelRequestDto.md) - [Error](./Models/Error.md) - [ErrorResponseObject](./Models/ErrorResponseObject.md) - [ExecutionResult](./Models/ExecutionResult.md) @@ -60,6 +63,7 @@ All URIs are relative to *http://localhost:5173/api/v1* - [NewTag](./Models/NewTag.md) - [Profile](./Models/Profile.md) - [QuestionDetails](./Models/QuestionDetails.md) + - [QuestionRateResponseDto](./Models/QuestionRateResponseDto.md) - [QuestionSummary](./Models/QuestionSummary.md) - [SuccessResponseObject](./Models/SuccessResponseObject.md) - [SuccessResponseObject_data](./Models/SuccessResponseObject_data.md) @@ -78,10 +82,12 @@ All URIs are relative to *http://localhost:5173/api/v1* - [createQuestion_201_response](./Models/createQuestion_201_response.md) - [createTag_201_response](./Models/createTag_201_response.md) - [executeCode_200_response](./Models/executeCode_200_response.md) + - [getBookmarkedQuestions_200_response](./Models/getBookmarkedQuestions_200_response.md) - [getQuestionAnswers_200_response](./Models/getQuestionAnswers_200_response.md) - [getQuestionAnswers_200_response_allOf_data](./Models/getQuestionAnswers_200_response_allOf_data.md) - [getUserFollowers_200_response](./Models/getUserFollowers_200_response.md) - [getUserProfile_200_response](./Models/getUserProfile_200_response.md) + - [rateQuestion_200_response](./Models/rateQuestion_200_response.md) - [resetPassword_request](./Models/resetPassword_request.md) - [searchQuestions_200_response](./Models/searchQuestions_200_response.md) - [searchQuestions_200_response_allOf_data](./Models/searchQuestions_200_response_allOf_data.md) diff --git a/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html new file mode 100644 index 00000000..c18ad8ef --- /dev/null +++ b/frontend/coverage/frontend/src/components/HighlightedQuestionCard.tsx.html @@ -0,0 +1,286 @@ + + + + + + Code coverage report for frontend/src/components/HighlightedQuestionCard.tsx + + + + + + + + + +
+
+

All files / frontend/src/components HighlightedQuestionCard.tsx

+
+ +
+ 4.54% + Statements + 2/44 +
+ + +
+ 100% + Branches + 0/0 +
+ + +
+ 0% + Functions + 0/2 +
+ + +
+ 4.54% + Lines + 2/44 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +681x +  +  +  +  +  +  +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  + 
import { Card } from "@/components/ui/card";
+import { QuestionSummary } from "@/services/api/programmingForumSchemas";
+ 
+import { ArrowRight, MessageSquare, Star, StarsIcon } from "lucide-react";
+import React from "react";
+import { Link } from "react-router-dom";
+import placeholderProfile from "@/assets/placeholder_profile.png";
+ 
+function capitalizeString(difficulty: string): React.ReactNode {
+  return difficulty.charAt(0).toUpperCase() + difficulty.slice(1);
+}
+export const HighlightedQuestionCard: React.FC<Partial<QuestionSummary>> = ({
+  id,
+  title,
+  content,
+  upvoteCount,
+  difficulty,
+  answerCount,
+  author,
+}) => {
+  return (
+    <Card className="border-none bg-blue-100 px-6 py-8 shadow-sm">
+      <div className="flex flex-col gap-6">
+        <h3 className="line-clamp-2 text-xl font-semibold text-gray-800">
+          {title}
+        </h3>
+        <p className="line-clamp-3 text-sm font-light text-gray-800">
+          {content}
+        </p>
+        <div className="flex flex-col gap-3 text-xs text-gray-700">
+          <div className="flex items-center gap-1">
+            <Star className="h-4 w-4" />
+            <span>{upvoteCount} votes</span>
+          </div>
+          <div className="flex items-center gap-1">
+            <MessageSquare className="h-4 w-4" />
+            <span>{answerCount} answers</span>
+          </div>
+          {difficulty && (
+            <div className="flex items-center gap-1">
+              <StarsIcon className="h-4 w-4" />
+              <span>{capitalizeString(difficulty)}</span>
+            </div>
+          )}
+        </div>
+        <div className="flex items-center justify-between">
+          {author && (
+            <Link to={`/users/${author.id}`} className="h-10 w-10">
+              <img
+                src={author?.profilePicture || placeholderProfile}
+                alt={author.name}
+                className="h-full w-full rounded-full object-cover"
+              />
+            </Link>
+          )}
+          <Link
+            to={`/question/${id}`}
+            className="flex items-center text-sm font-medium text-gray-800 hover:underline p-2"
+          >
+            Go to question
+            <ArrowRight className="ml-1 h-4 w-4" />
+          </Link>
+        </div>
+      </div>
+    </Card>
+  );
+};
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/AnswerCard.test.tsx b/frontend/src/components/AnswerCard.test.tsx new file mode 100644 index 00000000..7192084f --- /dev/null +++ b/frontend/src/components/AnswerCard.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { AnswerCard } from "./AnswerCard"; + +describe("AnswerCard", () => { + const mockProps = { + id: 1, + title: "Test Answer", + content: "This is a test answer content", + votes: 5, + questionId: 123, + author: { + id: 456, + name: "Test Author", + profilePicture: "test-profile.jpg", + }, + }; + + const renderWithRouter = (ui: React.ReactElement) => { + return render({ui}); + }; + + it("renders answer card with correct content", () => { + renderWithRouter(); + + expect(screen.getByText(mockProps.title)).toBeInTheDocument(); + expect(screen.getByText(mockProps.content)).toBeInTheDocument(); + expect(screen.getByText(`${mockProps.votes} votes`)).toBeInTheDocument(); + }); + + it("renders author profile picture", () => { + renderWithRouter(); + + const profilePicture = screen.getByAltText( + "Profile picture", + ) as HTMLImageElement; + expect(profilePicture).toBeInTheDocument(); + expect(profilePicture.src).toContain(mockProps.author.profilePicture); + }); + + it("renders default profile picture when author picture is not provided", () => { + const propsWithoutPicture = { + ...mockProps, + author: { ...mockProps.author, profilePicture: "" }, + }; + renderWithRouter(); + + const profilePicture = screen.getByAltText( + "Profile picture", + ) as HTMLImageElement; + expect(profilePicture).toBeInTheDocument(); + expect(profilePicture.src).toContain("placeholder_profile"); + }); + + it("contains correct navigation links", () => { + renderWithRouter(); + + const authorLink = screen.getByRole("link", { name: /profile picture/i }); + const answerLink = screen.getByRole("link", { name: /go to answer/i }); + + expect(authorLink).toHaveAttribute("href", `/users/${mockProps.author.id}`); + expect(answerLink).toHaveAttribute( + "href", + `/question/${mockProps.questionId}`, + ); + }); +}); diff --git a/frontend/src/components/CodeSnippet.test.tsx b/frontend/src/components/CodeSnippet.test.tsx new file mode 100644 index 00000000..bbf930ce --- /dev/null +++ b/frontend/src/components/CodeSnippet.test.tsx @@ -0,0 +1,134 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useExecuteCode } from "@/services/api/programmingForumComponents"; +import { UseMutationResult } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CodeSnippet } from "./CodeSnippet"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useExecuteCode: vi.fn(), +})); + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +describe("CodeSnippet", () => { + const mockProps = { + code: 'console.log("Hello, World!");', + language: "javascript", + }; + + beforeEach(() => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + data: undefined, + error: undefined, + } as unknown as UseMutationResult); + }); + + it("renders code snippet with correct language", () => { + render(); + + expect(screen.getByText("JavaScript Code Snippet")).toBeInTheDocument(); + screen.getAllByRole("code").forEach((code) => { + expect(code).toHaveTextContent(mockProps.code); + }); + }); + + it("handles code execution", () => { + const mockMutate = vi.fn(); + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: mockMutate, + isPending: false, + isSuccess: false, + isError: false, + } as unknown as UseMutationResult); + + render(); + + const executeButton = screen.getByRole("button", { name: /execute/i }); + fireEvent.click(executeButton); + + expect(mockMutate).toHaveBeenCalledWith({ + body: { + code: mockProps.code, + language: mockProps.language, + }, + }); + }); + + it("shows loading state during execution", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: true, + isSuccess: false, + isError: false, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText("Executing...")).toBeInTheDocument(); + }); + + it("shows success output after execution", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: true, + isError: false, + data: { + data: { + output: "Hello, World!", + executionTime: 0.5, + }, + }, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText(/output \(in 0\.5s\):/i)).toBeInTheDocument(); + expect(screen.getByText("Hello, World!")).toBeInTheDocument(); + }); + + it("shows error message on execution failure", () => { + vi.mocked(useExecuteCode).mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isSuccess: false, + isError: true, + error: { + payload: { + error: { + errorMessage: "Execution failed", + }, + }, + }, + } as unknown as UseMutationResult); + + render(); + + expect(screen.getByText("Error:")).toBeInTheDocument(); + expect(screen.getByText("Execution failed")).toBeInTheDocument(); + }); + + it("copies code to clipboard when copy button is clicked", async () => { + render(); + + const copyButton = screen.getByRole("button", { name: /copy link/i }); + fireEvent.click(copyButton); + + await waitFor(() => { + // Since the code is broken up into multiple elements for syntax highlighting, + // we should verify the button click rather than the exact clipboard content + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/CustomAnchor.test.tsx b/frontend/src/components/CustomAnchor.test.tsx new file mode 100644 index 00000000..c8f39fab --- /dev/null +++ b/frontend/src/components/CustomAnchor.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useNavigate } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CustomAnchor from "./CustomAnchor"; + +// Mock useNavigate +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: vi.fn(), + }; +}); + +describe("CustomAnchor", () => { + const mockNavigate = vi.fn(); + + beforeEach(() => { + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + }); + + it("renders a span when no href is provided", () => { + render( + + Test Link + , + ); + + expect(screen.getByText("Test Link").tagName).toBe("SPAN"); + }); + + it("renders an anchor with correct href when provided", () => { + render( + + Test Link + , + ); + + const link = screen.getByText("Test Link"); + expect(link.tagName).toBe("A"); + expect(link).toHaveAttribute("href", "#tag-123"); + }); + + it("navigates to tag page when tag link is clicked", () => { + render( + + Tag Link + , + ); + + fireEvent.click(screen.getByText("Tag Link")); + expect(mockNavigate).toHaveBeenCalledWith("/tag/123"); + }); + + it("navigates to question page when question link is clicked", () => { + render( + + Question Link + , + ); + + fireEvent.click(screen.getByText("Question Link")); + expect(mockNavigate).toHaveBeenCalledWith("/question/456"); + }); + + it("sets correct title for tag links", () => { + render( + + Tag Link + , + ); + + expect(screen.getByText("Tag Link")).toHaveAttribute("title", "Tag: 123"); + }); + + it("sets correct title for question links", () => { + render( + + Question Link + , + ); + + expect(screen.getByText("Question Link")).toHaveAttribute( + "title", + "Question: 456", + ); + }); + + it("sets loading title for invalid href patterns", () => { + render( + + Invalid Link + , + ); + + expect(screen.getByText("Invalid Link")).toHaveAttribute( + "title", + "Loading...", + ); + }); +}); diff --git a/frontend/src/components/ExerciseCard.test.tsx b/frontend/src/components/ExerciseCard.test.tsx new file mode 100644 index 00000000..a82c7873 --- /dev/null +++ b/frontend/src/components/ExerciseCard.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { ExerciseCard } from "./ExerciseCard"; + +describe("ExerciseCard", () => { + const mockProps = { + id: 1, + title: "Test Exercise", + description: "This is a test exercise description", + difficulty: "Medium", + tags: ["javascript", "algorithms", "arrays"], + link: "tracks/javascript/exercises/test-exercise", + }; + + const renderWithRouter = (ui: React.ReactElement) => { + return render({ui}); + }; + + it("renders exercise card with correct content", () => { + renderWithRouter(); + + expect(screen.getByText(mockProps.title)).toBeInTheDocument(); + expect(screen.getByText(mockProps.description)).toBeInTheDocument(); + expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument(); + }); + + it("renders all tags correctly", () => { + renderWithRouter(); + + mockProps.tags.forEach((tag) => { + expect(screen.getByText(tag)).toBeInTheDocument(); + }); + }); + + it("contains correct exercism link", () => { + renderWithRouter(); + + const exerciseLink = screen.getByRole("link", { name: /go to exercise/i }); + expect(exerciseLink).toHaveAttribute( + "href", + `https://exercism.org/${mockProps.link}`, + ); + expect(exerciseLink).toHaveAttribute("target", "_blank"); + }); + + it("displays difficulty label correctly", () => { + renderWithRouter(); + + const difficultyLabel = screen.getByText(/difficulty:/i); + expect(difficultyLabel).toBeInTheDocument(); + expect(screen.getByText(mockProps.difficulty)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/SearchQuestionsList.test.tsx b/frontend/src/components/SearchQuestionsList.test.tsx new file mode 100644 index 00000000..7149d5ec --- /dev/null +++ b/frontend/src/components/SearchQuestionsList.test.tsx @@ -0,0 +1,167 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useSearchQuestions } from "@/services/api/programmingForumComponents"; +import { DifficultyLevel } from "@/services/api/programmingForumSchemas"; +import { UseQueryResult } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchQuestionsList } from "./SearchQuestionsList"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useSearchQuestions: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +describe("SearchQuestionsList", () => { + const mockQuestions = [ + { + id: 1, + title: "Test Question 1", + content: "Content 1", + difficulty: "EASY" as DifficultyLevel, + upvoteCount: 5, + downvoteCount: 1, + answerCount: 2, + }, + { + id: 2, + title: "Test Question 2", + content: "Content 2", + difficulty: "MEDIUM" as DifficultyLevel, + upvoteCount: 3, + downvoteCount: 0, + answerCount: 1, + }, + ]; + + const mockSearchParams = new URLSearchParams(); + mockSearchParams.set("q", "test"); + mockSearchParams.set("sortBy", "recommended"); + + beforeEach(() => { + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + vi.mocked(useSearchQuestions).mockReturnValue({ + data: { + data: { + items: mockQuestions, + totalItems: mockQuestions.length, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + }); + + it("renders search results correctly", () => { + render( + + + , + ); + + expect( + screen.getByText(`Found ${mockQuestions.length} results`), + ).toBeInTheDocument(); + mockQuestions.forEach((question) => { + expect(screen.getByText(question.title)).toBeInTheDocument(); + expect(screen.getByText(question.content)).toBeInTheDocument(); + }); + }); + + it("displays loading state", () => { + vi.mocked(useSearchQuestions).mockReturnValue({ + data: null, + isLoading: true, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("displays error state", () => { + const errorMessage = "Failed to fetch questions"; + + vi.mocked(useSearchQuestions).mockReturnValue({ + data: null, + isLoading: false, + error: { + status: 500, + payload: { + status: 500, + error: { + errorMessage, + stackTrace: "Stack trace", + }, + }, + }, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("displays empty state message when no results", () => { + vi.mocked(useSearchQuestions).mockReturnValue({ + data: { + data: { + items: [], + totalItems: 0, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("No questions found")).toBeInTheDocument(); + expect( + screen.getByText("Try searching for specific topics or keywords."), + ).toBeInTheDocument(); + }); + + it("handles difficulty filter change", () => { + render( + + + , + ); + + const difficultyFilter = screen.getByRole("combobox"); + fireEvent.click(difficultyFilter); + const mediumOption = screen.getAllByText("Medium"); + fireEvent.click(mediumOption[1]); + + expect(useSearchQuestions).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + difficulty: "MEDIUM", + }), + }), + ); + }); +}); diff --git a/frontend/src/components/Tag.test.tsx b/frontend/src/components/Tag.test.tsx new file mode 100644 index 00000000..bc47aa8c --- /dev/null +++ b/frontend/src/components/Tag.test.tsx @@ -0,0 +1,102 @@ +import { TagDetails } from "@/services/api/programmingForumSchemas"; +import useAuthStore from "@/services/auth"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import { Tag } from "./Tag"; + +// Mock the auth store +vi.mock("@/services/auth", () => ({ + default: vi.fn(), +})); + +describe("Tag", () => { + const mockTag = { + tagId: "123", + name: "JavaScript", + logoImage: "javascript-logo.png", + description: "A programming language", + }; + + it("renders tag information correctly", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + expect(screen.getByText(mockTag.name!)).toBeInTheDocument(); + expect(screen.getByText(mockTag.description!)).toBeInTheDocument(); + expect( + screen.getByAltText(`The logo image of ${mockTag.name}`), + ).toHaveAttribute("src", mockTag.logoImage); + }); + + it("shows 'See all questions' link", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const link = screen.getByText(/see all questions/i); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", `/tag/${mockTag.tagId}`); + }); + + it("shows create question link when authenticated", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: "mock-token" }); + + render( + + + , + ); + + const createLink = screen.getByRole("link", { name: /create question/i }); + expect(createLink).toBeInTheDocument(); + expect(createLink).toHaveAttribute( + "href", + `/questions/new?tagIds=${encodeURIComponent(mockTag.tagId)}`, + ); + }); + + it("does not show create question link when not authenticated", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const createLink = screen.queryByRole("link", { name: /create question/i }); + expect(createLink).not.toBeInTheDocument(); + }); + + it("renders image with correct alt and title attributes", () => { + vi.mocked(useAuthStore).mockReturnValue({ token: null }); + + render( + + + , + ); + + const image = screen.getByAltText(`The logo image of ${mockTag.name}`); + expect(image).toHaveAttribute( + "title", + `alt:The logo image of ${mockTag.name}`, + ); + expect(image).toHaveClass( + "h-full", + "w-full", + "rounded-2xl", + "object-cover", + ); + }); +}); diff --git a/frontend/src/components/Tag.tsx b/frontend/src/components/Tag.tsx index d12ea132..3530c8f1 100644 --- a/frontend/src/components/Tag.tsx +++ b/frontend/src/components/Tag.tsx @@ -39,6 +39,7 @@ export const Tag = ({ {!!token && ( diff --git a/frontend/src/components/Tags.test.tsx b/frontend/src/components/Tags.test.tsx new file mode 100644 index 00000000..7c1be1d8 --- /dev/null +++ b/frontend/src/components/Tags.test.tsx @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useSearchTags } from "@/services/api/programmingForumComponents"; +import { TagDetails } from "@/services/api/programmingForumSchemas"; +import { UseQueryResult } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import TagsPage from "./Tags"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useSearchTags: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useSearchParams: vi.fn(), + }; +}); + +describe("TagsPage", () => { + const mockTags: Partial[] = [ + { + tagId: "1", + name: "JavaScript", + description: "A programming language", + logoImage: "js-logo.png", + }, + { + tagId: "2", + name: "Python", + description: "Another programming language", + logoImage: "python-logo.png", + }, + ]; + + const mockSearchParams = new URLSearchParams(); + mockSearchParams.set("q", ""); + + beforeEach(() => { + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: mockTags, + totalItems: mockTags.length, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + }); + + it("renders tags list correctly", () => { + render( + + + , + ); + + expect(screen.getByText("Tags")).toBeInTheDocument(); + mockTags.forEach((tag) => { + expect(screen.getByText(tag.name!)).toBeInTheDocument(); + expect(screen.getByText(tag.description!)).toBeInTheDocument(); + expect( + screen.getByAltText(`The logo image of ${tag.name}`), + ).toHaveAttribute("src", tag.logoImage!); + }); + }); + + it("displays loading state", () => { + vi.mocked(useSearchTags).mockReturnValue({ + data: null, + isLoading: true, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + }); + + it("displays error state", () => { + const errorMessage = "Failed to fetch tags"; + vi.mocked(useSearchTags).mockReturnValue({ + data: null, + isLoading: false, + error: { + status: 500, + payload: { + status: 500, + error: { + errorMessage, + stackTrace: "Stack trace", + }, + }, + }, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + + it("shows create tag button", () => { + render( + + + , + ); + + const createButton = screen.getByRole("link", { name: /create tag/i }); + expect(createButton).toBeInTheDocument(); + expect(createButton).toHaveAttribute("href", "/tags/new"); + }); + + it("loads more tags when scrolling", () => { + const { container } = render( + + + , + ); + + const infiniteScroll = container.querySelector("div"); + fireEvent.scroll(infiniteScroll as Element); + + expect(useSearchTags).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + pageSize: 20, + }), + }), + expect.any(Object), + ); + }); + + it("handles empty tags list", () => { + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: [], + totalItems: 0, + }, + }, + isLoading: false, + error: null, + } as unknown as UseQueryResult); + + render( + + + , + ); + + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("updates search results when query changes", () => { + const searchParams = new URLSearchParams(); + searchParams.set("q", "javascript"); + vi.mocked(useSearchParams).mockReturnValue([searchParams, vi.fn()]); + + render( + + + , + ); + + expect(useSearchTags).toHaveBeenCalledWith( + expect.objectContaining({ + queryParams: expect.objectContaining({ + q: "javascript", + }), + }), + expect.any(Object), + ); + }); +}); diff --git a/frontend/src/components/Tags.tsx b/frontend/src/components/Tags.tsx index a1de0918..b12f9966 100644 --- a/frontend/src/components/Tags.tsx +++ b/frontend/src/components/Tags.tsx @@ -65,7 +65,7 @@ export default function TagsPage() { size="icon" className="rounded-full bg-red-500 text-white" > - + @@ -87,9 +87,7 @@ export default function TagsPage() { {isLoading && (
- +
Loading...
diff --git a/frontend/src/routes/create-question.test.tsx b/frontend/src/routes/create-question.test.tsx new file mode 100644 index 00000000..d4d02431 --- /dev/null +++ b/frontend/src/routes/create-question.test.tsx @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + useCreateQuestion, + useSearchTags, +} from "@/services/api/programmingForumComponents"; +import { + UseMutationResult, + UseQueryResult, + useQueryClient, +} from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useNavigate, useSearchParams } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import QuestionCreationPage from "./create-question"; + +// Mock the API hooks +vi.mock("@/services/api/programmingForumComponents", () => ({ + useCreateQuestion: vi.fn(), + useSearchTags: vi.fn(), +})); + +// Mock react-router-dom +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + useNavigate: vi.fn(), + useSearchParams: vi.fn(), + }; +}); + +// Mock react-query +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQueryClient: vi.fn(), + }; +}); + +describe("QuestionCreationPage", () => { + const mockNavigate = vi.fn(); + const mockQueryClient = { + invalidateQueries: vi.fn(), + }; + const mockCreateQuestion = vi.fn(); + const mockSearchParams = new URLSearchParams(); + + const mockTags = [ + { tagId: "1", name: "javascript" }, + { tagId: "2", name: "python" }, + ]; + + beforeEach(() => { + vi.mocked(useNavigate).mockReturnValue(mockNavigate); + vi.mocked(useQueryClient).mockReturnValue(mockQueryClient as any); + vi.mocked(useSearchParams).mockReturnValue([mockSearchParams, vi.fn()]); + + vi.mocked(useCreateQuestion).mockReturnValue({ + mutateAsync: mockCreateQuestion, + isLoading: false, + } as unknown as UseMutationResult); + + vi.mocked(useSearchTags).mockReturnValue({ + data: { + data: { + items: mockTags, + }, + }, + isLoading: false, + } as unknown as UseQueryResult); + }); + + it("renders the form correctly", () => { + render( + + + , + ); + + expect(screen.getByText("Create a new question")).toBeInTheDocument(); + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/content/i)).toBeInTheDocument(); + expect( + screen.getByRole("combobox", { name: /difficulty/i }), + ).toBeInTheDocument(); + }); + + it("shows validation errors for empty fields", async () => { + render( + + + , + ); + + // Submit without filling the form + fireEvent.click(screen.getByRole("button", { name: /submit/i })); + + await waitFor(() => { + expect(screen.getByText("Title is required")).toBeInTheDocument(); + expect(screen.getByText("Content is required")).toBeInTheDocument(); + expect( + screen.getByText("At least one tag is required"), + ).toBeInTheDocument(); + }); + }); + + it("loads tags from URL parameters", () => { + const mockSearchParamsWithTags = new URLSearchParams(); + mockSearchParamsWithTags.set("tagIds", "1,2"); + vi.mocked(useSearchParams).mockReturnValue([ + mockSearchParamsWithTags, + vi.fn(), + ]); + + render( + + + , + ); + + expect(screen.getByText("javascript")).toBeInTheDocument(); + expect(screen.getByText("python")).toBeInTheDocument(); + }); + + it("toggles preview mode", () => { + render( + + + , + ); + + const previewButton = screen.getByRole("button", { name: /preview/i }); + fireEvent.click(previewButton); + + expect(screen.getByText("Preview")).toBeInTheDocument(); + + fireEvent.click(previewButton); + expect(screen.queryByText("Preview Mode")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/create-question.tsx b/frontend/src/routes/create-question.tsx index 9b509f46..a6e6f9b6 100644 --- a/frontend/src/routes/create-question.tsx +++ b/frontend/src/routes/create-question.tsx @@ -1,3 +1,4 @@ +import { ContentWithSnippets } from "@/components/ContentWithSnippets"; import { MultiSelect } from "@/components/multi-select"; import { Button } from "@/components/ui/button"; import { @@ -8,23 +9,26 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { useCreateQuestion, useSearchTags, } from "@/services/api/programmingForumComponents"; -import { Info } from "lucide-react"; import { queryKeyFn } from "@/services/api/programmingForumContext"; import { TagDetails } from "@/services/api/programmingForumSchemas"; import { zodResolver } from "@hookform/resolvers/zod"; import { InvalidateQueryFilters, useQueryClient } from "@tanstack/react-query"; +import { Info } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { z } from "zod"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import SyntaxHighlighter from "react-syntax-highlighter"; -import { ContentWithSnippets } from "@/components/ContentWithSnippets"; +import { z } from "zod"; // Schema validation for the form const newQuestionSchema = z.object({ @@ -119,8 +123,8 @@ export default function QuestionCreationPage() { return (
-

Create a new question

- +

Create a new question

+
-
{ @@ -186,7 +189,10 @@ export default function QuestionCreationPage() { render={({ field }) => ( - + @@ -217,16 +223,19 @@ export default function QuestionCreationPage() { render={({ field }) => ( - {isPreviewMode ? ( -
- -
- ) : ( -