diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Constants/EndpointConstants.java b/backend/src/main/java/com/group1/programminglanguagesforum/Constants/EndpointConstants.java index 6d9be26e..dda5644d 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Constants/EndpointConstants.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Constants/EndpointConstants.java @@ -49,6 +49,7 @@ public static class SparqlEndpoints { public static class TagEndpoints { public static final String BASE_PATH = "/tags"; public static final String TAG_ID = BASE_PATH + "/{id}"; + public static final String TAG_FOLLOW = BASE_PATH + "/{id}/follow"; public static final String SEARCH = "/search" + BASE_PATH; } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java index 5a76ecde..aa0d72a7 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Controllers/TagController.java @@ -5,9 +5,16 @@ import com.group1.programminglanguagesforum.DTOs.Responses.ErrorResponse; import com.group1.programminglanguagesforum.DTOs.Responses.GenericApiResponse; import com.group1.programminglanguagesforum.DTOs.Responses.GetTagDetailsResponseDto; +import com.group1.programminglanguagesforum.DTOs.Responses.TagDto; import com.group1.programminglanguagesforum.DTOs.Responses.TagSearchResponseDto; +import com.group1.programminglanguagesforum.Entities.User; +import com.group1.programminglanguagesforum.Exceptions.ExceptionResponseHandler; +import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Services.TagService; +import com.group1.programminglanguagesforum.Services.UserContextService; import com.group1.programminglanguagesforum.Util.ApiResponseBuilder; + +import jakarta.persistence.EntityExistsException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -20,11 +27,15 @@ import java.util.Arrays; import java.util.NoSuchElementException; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1") public class TagController extends BaseController { + private final TagService tagService; + private final UserContextService userContextService; + @GetMapping(value = EndpointConstants.TagEndpoints.SEARCH) public ResponseEntity> tagSearch( @RequestParam String q, @@ -107,4 +118,37 @@ public ResponseEntity> createTag(@R } } + + @PostMapping(value = EndpointConstants.TagEndpoints.TAG_FOLLOW) + public ResponseEntity> postMethodName(@PathVariable(value = "id") Long tagId) { + try { + User user = userContextService.getCurrentUser(); + TagDto tagDto = tagService.followTag(user, tagId); + GenericApiResponse response = ApiResponseBuilder.buildSuccessResponse(TagDto.class, "Tag followed successfully", 200, tagDto); + return buildResponse(response, HttpStatus.OK); + + } catch (UnauthorizedAccessException e) { + return ExceptionResponseHandler.UnauthorizedAccessException(e); + } catch (NoSuchElementException e) { + return ExceptionResponseHandler.NoSuchElementException(e); + } catch (EntityExistsException e) { + return ExceptionResponseHandler.EntityExistsException(e); + } + } + + @DeleteMapping(value = EndpointConstants.TagEndpoints.TAG_FOLLOW) + public ResponseEntity> deleteMethodName(@PathVariable(value = "id") Long tagId) { + try { + User user = userContextService.getCurrentUser(); + TagDto tagDto = tagService.unfollowTag(user, tagId); + GenericApiResponse response = ApiResponseBuilder.buildSuccessResponse(TagDto.class, "Tag unfollowed successfully", 200, tagDto); + return buildResponse(response, HttpStatus.OK); + + } catch (UnauthorizedAccessException e) { + return ExceptionResponseHandler.UnauthorizedAccessException(e); + } catch (NoSuchElementException e) { + return ExceptionResponseHandler.NoSuchElementException(e); + } + } + } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetAnswersResponseDto.java b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetAnswersResponseDto.java index d0bfff08..703a70fa 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetAnswersResponseDto.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetAnswersResponseDto.java @@ -26,6 +26,7 @@ public static class AnswerResponseDto { private Long upvoteCount; private Long downvoteCount; private boolean selfAnswer; + private boolean selfVoted; } } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetQuestionDetailsResponseDto.java b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetQuestionDetailsResponseDto.java index c4cdc78b..8b3314f6 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetQuestionDetailsResponseDto.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/DTOs/Responses/GetQuestionDetailsResponseDto.java @@ -18,6 +18,7 @@ public class GetQuestionDetailsResponseDto { private Long dislikeCount; private Long commentCount; private Boolean selfQuestion; + private Boolean selfVoted; private String createdAt; @Builder.Default private List tags= new ArrayList<>(); diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Entities/Tag.java b/backend/src/main/java/com/group1/programminglanguagesforum/Entities/Tag.java index 67be4adf..68fc36f7 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Entities/Tag.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Entities/Tag.java @@ -1,5 +1,8 @@ package com.group1.programminglanguagesforum.Entities; +import java.util.HashSet; +import java.util.Set; + import jakarta.persistence.*; import lombok.*; @@ -18,6 +21,14 @@ public class Tag { private String wikidataId; private String tagName; private String tagDescription; + + @ManyToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL) + @JoinTable(name = "USER_TAGS", + joinColumns = @JoinColumn(name = "tag_id"), + inverseJoinColumns = @JoinColumn(name = "user_id") + ) + private Set followers = new HashSet<>(); + public Tag(String wikidataId, String tagName, String tagDescription) { this.wikidataId = wikidataId; this.tagName = tagName; diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Entities/User.java b/backend/src/main/java/com/group1/programminglanguagesforum/Entities/User.java index 83549e25..418b8fe6 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Entities/User.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Entities/User.java @@ -57,6 +57,16 @@ public class User implements UserDetails { private int followingCount = 0; @Builder.Default private Long reputationPoints = 0L; + + @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) + @JoinTable( + name = "USER_TAGS", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + @Builder.Default + private Set followedTags = new HashSet<>(); + @OneToMany(mappedBy = "askedBy", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List questions = new ArrayList<>(); diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Exceptions/ExceptionResponseHandler.java b/backend/src/main/java/com/group1/programminglanguagesforum/Exceptions/ExceptionResponseHandler.java index 09f06933..bf941861 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Exceptions/ExceptionResponseHandler.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Exceptions/ExceptionResponseHandler.java @@ -4,6 +4,9 @@ import com.group1.programminglanguagesforum.DTOs.Responses.GenericApiResponse; import com.group1.programminglanguagesforum.DTOs.Responses.UserProfileResponseDto; import com.group1.programminglanguagesforum.Util.ApiResponseBuilder; + +import jakarta.persistence.EntityExistsException; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -88,4 +91,43 @@ public static ResponseEntity> NoSuchElementException(N ); } + + public static ResponseEntity> IllegalArgumentException(IllegalArgumentException e) { + ErrorResponse errorResponse = ErrorResponse.builder() + .errorMessage(e.getMessage()) + .stackTrace(Arrays.toString(e.getStackTrace())) + .build(); + + GenericApiResponse response = ApiResponseBuilder.buildErrorResponse( + UserProfileResponseDto.class, + e.getMessage(), + HttpStatus.BAD_REQUEST.value(), + errorResponse + ); + + return new ResponseEntity<>( + response, + HttpStatus.valueOf(response.getStatus()) + ); + } + + public static ResponseEntity> EntityExistsException(EntityExistsException e) { + ErrorResponse errorResponse = ErrorResponse.builder() + .errorMessage(e.getMessage()) + .stackTrace(Arrays.toString(e.getStackTrace())) + .build(); + + GenericApiResponse response = ApiResponseBuilder.buildErrorResponse( + UserProfileResponseDto.class, + e.getMessage(), + HttpStatus.CONFLICT.value(), + errorResponse + ); + + return new ResponseEntity<>( + response, + HttpStatus.valueOf(response.getStatus()) + ); + } + } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/VoteRepository.java b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/VoteRepository.java index c6bbe7ba..8ee33690 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/VoteRepository.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Repositories/VoteRepository.java @@ -13,6 +13,6 @@ public interface VoteRepository extends JpaRepository { Optional findByUserAndQuestionAndIsUpvote(User user, Question question, boolean isUpvote); Optional findByUserAndAnswer(User user, Answer answer); - + Optional findByUserAndQuestion(User user, Question question); Optional findByUserAndAnswerAndIsUpvote(User user, Answer answer, boolean b); } diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Services/AnswerService.java b/backend/src/main/java/com/group1/programminglanguagesforum/Services/AnswerService.java index 31ddae1c..a2695071 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Services/AnswerService.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Services/AnswerService.java @@ -9,6 +9,8 @@ import com.group1.programminglanguagesforum.Entities.User; import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Repositories.AnswerRepository; +import com.group1.programminglanguagesforum.Repositories.VoteRepository; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -19,6 +21,7 @@ public class AnswerService { private final AnswerRepository answerRepository; private final UserContextService userContextService; private final QuestionService questionService; + private final VoteRepository voteRepository; public CreateAnswerResponseDto createAnswer(Long questionId, CreateAnswerRequestDto createAnswerRequestDto) throws UnauthorizedAccessException { User currentUser = userContextService.getCurrentUser(); @@ -92,6 +95,7 @@ public GetAnswersResponseDto getAnswersForQuestion(Long questionId) { .upvoteCount(answer.getUpvoteCount()) .downvoteCount(answer.getDownvoteCount()) .selfAnswer(currentUser != null && currentUser.getId().equals(answer.getAnsweredBy().getId())) + .selfVoted(currentUser != null && voteRepository.findByUserAndAnswer(currentUser, answer).isPresent()) .build()).toList()) .totalItems(question.getAnswers().size()) .build(); diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java b/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java index e978b703..3c892a7d 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Services/QuestionService.java @@ -14,6 +14,8 @@ import com.group1.programminglanguagesforum.Exceptions.UnauthorizedAccessException; import com.group1.programminglanguagesforum.Repositories.BookmarkRepository; import com.group1.programminglanguagesforum.Repositories.QuestionRepository; +import com.group1.programminglanguagesforum.Repositories.VoteRepository; + import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -31,6 +33,7 @@ public class QuestionService { private final UserContextService userContextService; private final TagService tagService; private final BookmarkRepository bookmarkRepository; + private final VoteRepository voteRepository; public Optional findById(Long id) { return questionRepository.findById(id); @@ -81,26 +84,23 @@ public CreateQuestionResponseDto createQuestion(CreateQuestionRequestDto dto) } public GetQuestionDetailsResponseDto getQuestion(Long id) throws NoSuchElementException { - Optional questionOptional = questionRepository.findById(id); - if (questionOptional.isEmpty()) { - throw new NoSuchElementException("Question not found"); - } - Question question = questionOptional.get(); - boolean selfQuestion; + User currentUser; try { - User currentUser = userContextService.getCurrentUser(); - selfQuestion = currentUser.getId().equals(question.getAskedBy().getId()); + currentUser = userContextService.getCurrentUser(); } catch (UnauthorizedAccessException e) { - selfQuestion = false; + currentUser = null; } - boolean isBookmarked; - try { - isBookmarked = isBookmarked(id); - } catch (UnauthorizedAccessException e) { - isBookmarked = false; + Optional questionOptional = questionRepository.findById(id); + if (questionOptional.isEmpty()) { + throw new NoSuchElementException("Question not found"); } + Question question = questionOptional.get(); + boolean selfQuestion = (currentUser != null && currentUser.getId().equals(question.getAskedBy().getId())); + boolean isBookmarked = (currentUser != null && bookmarkRepository.existsByUserAndQuestion(currentUser, question)); + boolean selfVoted = (currentUser != null && voteRepository.findByUserAndQuestion(currentUser, question).isPresent()); + return GetQuestionDetailsResponseDto.builder() .id(question.getId()) .title(question.getTitle()) @@ -109,6 +109,7 @@ public GetQuestionDetailsResponseDto getQuestion(Long id) throws NoSuchElementEx .dislikeCount(question.getDownvoteCount()) .commentCount(question.getCommentCount()) .selfQuestion(selfQuestion) + .selfVoted(selfVoted) .createdAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getCreatedAt())) .updatedAt(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(question.getUpdatedAt())) .author(AuthorDto.builder() @@ -128,12 +129,6 @@ public GetQuestionDetailsResponseDto getQuestion(Long id) throws NoSuchElementEx } - private boolean isBookmarked(Long questionId) throws UnauthorizedAccessException { - User user = userContextService.getCurrentUser(); - Question question = findById(questionId).orElseThrow(); - return bookmarkRepository.existsByUserAndQuestion(user, question); - } - public String deleteQuestion(Long id) { Optional questionOptional = questionRepository.findById(id); if (questionOptional.isEmpty()) { diff --git a/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java b/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java index 65a71823..45857cfc 100644 --- a/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java +++ b/backend/src/main/java/com/group1/programminglanguagesforum/Services/TagService.java @@ -5,6 +5,9 @@ import com.group1.programminglanguagesforum.Entities.*; import com.group1.programminglanguagesforum.Repositories.QuestionRepository; import com.group1.programminglanguagesforum.Repositories.TagRepository; +import com.group1.programminglanguagesforum.Repositories.UserRepository; + +import jakarta.persistence.EntityExistsException; import lombok.RequiredArgsConstructor; import org.modelmapper.ModelMapper; import org.springframework.data.domain.Page; @@ -21,6 +24,7 @@ public class TagService { private final TagRepository tagRepository; private final ModelMapper modelMapper; private final QuestionRepository questionRepository; + private final UserRepository userRepository; public List findAllByIdIn(List tagIds) { return tagRepository.findAllByIdIn(tagIds); @@ -103,4 +107,49 @@ public Page searchTags(String q, Pageable pageable) { .tagType(getTagType(tag).toString()) .build()); } + + public TagDto followTag(User user, Long tagId) { + + Optional tag = tagRepository.findById(tagId); + if (tag.isEmpty()) { + throw new NoSuchElementException("Tag not found"); + } + + Tag tagEntity = tag.get(); + + if (user.getFollowedTags().stream().anyMatch(t -> t.getId().equals(tagId))) { + throw new EntityExistsException("User already follows this tag"); + } + + user.getFollowedTags().add(tagEntity); + userRepository.save(user); + + return TagDto.builder() + .id(tagEntity.getId()) + .name(tagEntity.getTagName()) + .build(); + } + + public TagDto unfollowTag(User user, Long tagId) { + + Optional tag = tagRepository.findById(tagId); + if (tag.isEmpty()) { + throw new NoSuchElementException("Tag not found"); + } + + Tag tagEntity = tag.get(); + + if (!user.getFollowedTags().stream().anyMatch(t -> t.getId().equals(tagId))) { + throw new NoSuchElementException("User does not follow this tag"); + } + + user.getFollowedTags().removeIf(t -> t.getId().equals(tagId)); + userRepository.save(user); + + return TagDto.builder() + .id(tagEntity.getId()) + .name(tagEntity.getTagName()) + .build(); + } + } diff --git a/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java b/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java index afe16bfc..d1bef799 100644 --- a/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java +++ b/backend/src/test/java/com/group1/programminglanguagesforum/Services/TagServiceTests.java @@ -67,7 +67,7 @@ void testCreateTag() { .build(); // Mock the Tag returned by the repository save method - Tag savedTag = new Tag(1L, null, "New Tag", "Tag description"); + Tag savedTag = new Tag(1L, null, "New Tag", "Tag description", null); // Mock tagRepository behavior when(tagRepository.save(any(Tag.class))).thenReturn(savedTag); @@ -90,7 +90,7 @@ void testCreateTag() { void testGetTagDetails_Success() { Long tagId = 1L; - Tag mockTag = new Tag(1L, null, "Tag1", "Description1"); + Tag mockTag = new Tag(1L, null, "Tag1", "Description1", null); List mockQuestions = Arrays.asList( new Question(1L, "Question1", "Body1", DifficultyLevel.EASY, 0L, 0L, null, null, null, null, null,null), new Question(2L, "Question2", "Body2", DifficultyLevel.MEDIUM, 0L, 0L, null, null, null, null, null,null)); diff --git a/dev.yml b/dev.yml index 8ceb51ee..08ced51e 100644 --- a/dev.yml +++ b/dev.yml @@ -44,14 +44,10 @@ services: - PROXY_API=http://backend:8080 ports: - "5173:5173" + volumes: + - ./frontend/src:/app/src develop: watch: - - action: sync - x-initialSync: true - path: ./frontend - target: /app - ignore: - - ./frontend/node_modules/ - action: rebuild path: ./frontend/package.json volumes: diff --git a/frontend/package.json b/frontend/package.json index 783edf87..5b24eb45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,12 +24,13 @@ "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.1.3", @@ -38,6 +39,7 @@ "@types/react-syntax-highlighter": "^15.5.13", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "1.0.0", "country-emoji": "^1.5.6", "date-fns": "^3.6.0", "lucide-react": "^0.376.0", diff --git a/frontend/src/components/ContentWithSnippets.tsx b/frontend/src/components/ContentWithSnippets.tsx index 2f4f5003..88d1bd70 100644 --- a/frontend/src/components/ContentWithSnippets.tsx +++ b/frontend/src/components/ContentWithSnippets.tsx @@ -55,9 +55,5 @@ export const ContentWithSnippets: React.FC = ({ }); }, [content]); - return ( -
- {renderedContent} -
- ); + return
{renderedContent}
; }; diff --git a/frontend/src/components/CreateTagForm.test.tsx b/frontend/src/components/CreateTagForm.test.tsx new file mode 100644 index 00000000..3d3d8e0c --- /dev/null +++ b/frontend/src/components/CreateTagForm.test.tsx @@ -0,0 +1,133 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateTagForm } from "./CreateTagForm"; // Import your component +import { useCreateTag } from "@/services/api/programmingForumComponents"; // Import the hook + +// Mock the useCreateTag hook +vi.mock("@/services/api/programmingForumComponents", () => ({ + useCreateTag: vi.fn(), +})); + +describe("CreateTagForm", () => { + const mockCreateTag = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCreateTag).mockReturnValue({ + mutateAsync: mockCreateTag, + isPending: false, + } as unknown as ReturnType); + }); + + it("renders form elements correctly", () => { + render(); + + // Check for presence of form fields + expect(screen.getByPlaceholderText("Tag name")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Tag description")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create Tag" }), + ).toBeInTheDocument(); + }); + + it("submits form with valid data when 'Create Tag' is clicked", async () => { + render(); + + const nameInput = screen.getByPlaceholderText("Tag name"); + const descriptionInput = screen.getByPlaceholderText("Tag description"); + const submitButton = screen.getByRole("button", { name: "Create Tag" }); + + // Simulate user input + fireEvent.change(nameInput, { target: { value: "NewTag" } }); + fireEvent.change(descriptionInput, { + target: { value: "A description for NewTag" }, + }); + + fireEvent.click(submitButton); + + await waitFor(() => { + // Check if the createTag function was called with the correct arguments + expect(mockCreateTag).toHaveBeenCalledWith({ + body: { + name: "NewTag", + description: "A description for NewTag", + }, + }); + }); + }); + + it("disables submit button when form is empty", () => { + render(); + + const submitButton = screen.getByRole("button", { name: "Create Tag" }); + + // Initially, the submit button should be disabled + expect(submitButton).toBeDisabled(); + + // Simulate filling the form + fireEvent.change(screen.getByPlaceholderText("Tag name"), { + target: { value: "NewTag" }, + }); + fireEvent.change(screen.getByPlaceholderText("Tag description"), { + target: { value: "Description" }, + }); + + // The submit button should be enabled now + expect(submitButton).not.toBeDisabled(); + }); + + it("shows loading state while creating the tag", async () => { + vi.mocked(useCreateTag).mockReturnValue({ + mutateAsync: mockCreateTag, + isPending: true, // Simulate loading state + } as unknown as ReturnType); + + render(); + + // Wait for the button text to change to "Creating..." + const submitButton = await screen.findByRole("button", { + name: /Create Tag|Creating.../, + }); + + // The button should display "Creating..." and be disabled + expect(submitButton).toHaveTextContent("Creating..."); + expect(submitButton).toBeDisabled(); + }); + + it("clears form inputs after successful submission", async () => { + render(); + + const nameInput = screen.getByPlaceholderText("Tag name"); + const descriptionInput = screen.getByPlaceholderText("Tag description"); + const submitButton = screen.getByRole("button", { name: "Create Tag" }); + + fireEvent.change(nameInput, { target: { value: "NewTag" } }); + fireEvent.change(descriptionInput, { target: { value: "Description" } }); + + fireEvent.click(submitButton); + + await waitFor(() => { + // After form submission, inputs should be cleared + expect(nameInput).toHaveValue(""); + expect(descriptionInput).toHaveValue(""); + }); + }); + + it("does not submit if fields are empty", async () => { + render(); + + const nameInput = screen.getByPlaceholderText("Tag name"); + const descriptionInput = screen.getByPlaceholderText("Tag description"); + const submitButton = screen.getByRole("button", { name: "Create Tag" }); + + fireEvent.change(nameInput, { target: { value: "" } }); + fireEvent.change(descriptionInput, { target: { value: "" } }); + + fireEvent.click(submitButton); + + await waitFor(() => { + // The submit function should not be called because inputs are empty + expect(mockCreateTag).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/components/CreateTagForm.tsx b/frontend/src/components/CreateTagForm.tsx new file mode 100644 index 00000000..e605a141 --- /dev/null +++ b/frontend/src/components/CreateTagForm.tsx @@ -0,0 +1,62 @@ +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import { useCreateTag } from "@/services/api/programmingForumComponents"; +import { useState } from "react"; + +interface CreateTagFormProps { + onCreateSuccess?: () => void; // Optional callback for successful tag creation +} + +export function CreateTagForm({ onCreateSuccess }: CreateTagFormProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const { mutateAsync: createTag, isPending } = useCreateTag(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !description.trim()) return; + + try { + await createTag({ + body: { name, description }, + }); + setName(""); + setDescription(""); + if (onCreateSuccess) onCreateSuccess(); + // Optionally refresh the tag list or show a success message + } catch (error) { + console.error("Failed to create tag:", error); + } + }; + + return ( +
+ setName(e.target.value)} + placeholder="Tag name" + className="min-w-[200px]" + required + /> + + + {contentLength} / 1000 + + + ); + +} \ No newline at end of file diff --git a/mobile/components/ContentWithSnippets.tsx b/mobile/components/ContentWithSnippets.tsx index 552703e1..938de201 100644 --- a/mobile/components/ContentWithSnippets.tsx +++ b/mobile/components/ContentWithSnippets.tsx @@ -2,6 +2,7 @@ import React from "react"; import { View } from "react-native"; import { CodeSnippet } from "./CodeSnippet"; import { Text } from "./ui"; + interface ContentWithSnippetsProps { content: string; } @@ -51,12 +52,14 @@ export const ContentWithSnippets: React.FC = ({ ); } - return ( - - {part.code} - - ); - return {part.content}; + if (part.type === "text") { + return ( + + {part.content} + + ); + } + return null; }); }, [content]);