diff --git a/front/src/app/__tests__/page.test.tsx b/front/src/app/__tests__/page.test.tsx index 87d8a164..a119dc2f 100644 --- a/front/src/app/__tests__/page.test.tsx +++ b/front/src/app/__tests__/page.test.tsx @@ -1,6 +1,6 @@ import "@testing-library/jest-dom"; -jest.mock("../../services/LogsData.ts", () => ({ +jest.mock("../../services/prisma/LogsData.ts", () => ({ getLogs: jest.fn(() => Promise.resolve([])), })); diff --git a/front/src/app/api/logs/[id]/connectTag/route.ts b/front/src/app/api/logs/[id]/connectTag/route.ts index 960490ad..2ead6ab3 100644 --- a/front/src/app/api/logs/[id]/connectTag/route.ts +++ b/front/src/app/api/logs/[id]/connectTag/route.ts @@ -1,5 +1,5 @@ import { prismaErrorHandler } from "@/features/tags/utils"; -import { LogsData } from "@/services/LogsData"; +import { LogsData } from "@/services/prisma/LogsData"; import { Prisma } from "@prisma/client"; import { NextResponse } from "next/server"; diff --git a/front/src/app/logs/[id]/page.tsx b/front/src/app/logs/[id]/page.tsx index c7451624..ad013a1e 100644 --- a/front/src/app/logs/[id]/page.tsx +++ b/front/src/app/logs/[id]/page.tsx @@ -1,5 +1,5 @@ -import { LogDetailsTable } from "@/features/tables/LogDetailsTable"; -import { LogsData } from "@/services/LogsData"; +import { LogDetailsTable } from "@/features/tables/views/LogDetailsTable"; +import { LogsData } from "@/services/prisma/LogsData"; export default async function LogDetails({ params, diff --git a/front/src/app/page.tsx b/front/src/app/page.tsx index 299cffc8..7ecbbf0a 100644 --- a/front/src/app/page.tsx +++ b/front/src/app/page.tsx @@ -1,6 +1,6 @@ import { FilterHeader } from "@/features/filter/FilterHeader"; -import { TableBody } from "@/features/tables/TableBody"; -import { LogsData } from "@/services/LogsData"; +import { TableBody } from "@/features/tables/views/TableBody"; +import { LogsData } from "@/services/prisma/LogsData"; export const revalidate = 0; diff --git a/front/src/components/atoms/Button.tsx b/front/src/components/atoms/Button.tsx index 1004279a..eb9c14db 100644 --- a/front/src/components/atoms/Button.tsx +++ b/front/src/components/atoms/Button.tsx @@ -2,14 +2,17 @@ type ButtonProps = { children: React.ReactNode; onClick: () => void; className?: string; - colour?: "blue" | "grey" | "green"; + colour?: "blue" | "grey" | "green" | "red"; }; -export const Button = ({ children, onClick, className, colour = "blue", ...props }: ButtonProps) => { - const blueStyle = "bg-theodo-turquoise hover:bg-omnilog-clear-blue"; - const greyStyle = "bg-theodo-grey-regular hover:bg-theodo-turquoise"; - const greenStyle = "bg-theodo-green-dark hover:bg-theodo-green-regular"; - const bgColour = colour === "blue" ? blueStyle : colour === "grey" ? greyStyle : greenStyle; +export const Button = ({ + children, + onClick, + className, + colour = "blue", + ...props +}: ButtonProps) => { + const bgColour = buttonColourPicker(colour); return ( + ); +}; diff --git a/front/src/components/atoms/Popup.tsx b/front/src/components/atoms/Popup.tsx new file mode 100644 index 00000000..e1270d2d --- /dev/null +++ b/front/src/components/atoms/Popup.tsx @@ -0,0 +1,56 @@ +import { faTimes } from "@fortawesome/free-solid-svg-icons"; +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment, ReactNode } from "react"; +import { ButtonIcon } from "./ButtonIcon"; +import { CardAtom } from "./CardAtom"; + +type PopupProps = { + children: ReactNode; + isOpen: boolean; + close: () => void; +}; + +export const Popup = ({ children, isOpen, close }: PopupProps) => { + return ( + + {}}> + +
+ +
+
+ + + +
+ +
+
+ {children} +
+
+
+
+
+
+
+
+ ); +}; + +const CustomTransition = ({ children }: { children: ReactNode }) => ( + + {children} + +); diff --git a/front/src/components/atoms/TagLabel.tsx b/front/src/components/atoms/TagLabel.tsx index 8ed17a50..8115120d 100644 --- a/front/src/components/atoms/TagLabel.tsx +++ b/front/src/components/atoms/TagLabel.tsx @@ -2,7 +2,7 @@ import { Tag } from "@prisma/client"; export const TagLabel = ({ tag }: { tag: Tag }) => { return ( -
+
{tag.name}
); diff --git a/front/src/features/tables/components/ColumnContentTags.tsx b/front/src/features/tables/components/ColumnContentTags.tsx index 5b89de90..957c5fc8 100644 --- a/front/src/features/tables/components/ColumnContentTags.tsx +++ b/front/src/features/tables/components/ColumnContentTags.tsx @@ -1,12 +1,32 @@ +import { TagPopup } from "@/features/tables/components/TagPopup"; import { Tag } from "@prisma/client"; +import { useState } from "react"; import { TagLabel } from "../../../components/atoms/TagLabel"; +import { concatAndSortTags, isTagInArray } from "../helpers/tagArrayManagement"; -export const ColumnContentTags = ({ tags }: { tags: Tag[] }) => { +export const ColumnContentTags = ({ + tags, + logId, +}: { + tags: Tag[]; + logId: string; +}) => { + const [tagArray, setTagArray] = useState(tags); + const addTag = (tag: Tag) => { + if (isTagInArray(tagArray, tag.id)) return; + setTagArray(concatAndSortTags(tagArray, tag)); + }; return ( - - {tags.map((tag) => ( - - ))} + + {tagArray + .map((tag) => ) + .concat( + , + )} ); }; diff --git a/front/src/features/tables/components/TagPopup.tsx b/front/src/features/tables/components/TagPopup.tsx new file mode 100644 index 00000000..d0610edd --- /dev/null +++ b/front/src/features/tables/components/TagPopup.tsx @@ -0,0 +1,41 @@ +import { ButtonIcon } from "@/components/atoms/ButtonIcon"; +import { Popup } from "@/components/atoms/Popup"; +import { TagLabel } from "@/components/atoms/TagLabel"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { Tag } from "@prisma/client"; +import { useTagPopup } from "../hooks/useTagPopup"; + +type TagPopupProps = { + logId: string; + addTagToDisplay: (tag: Tag) => void; +}; + +export const TagPopup = ({ logId, addTagToDisplay }: TagPopupProps) => { + const { isPopupOpen, openPopup, closePopup, tags, selectTag } = useTagPopup( + logId, + addTagToDisplay, + ); + + return ( + <> + + +
+

+ Pick a tag to add +

+
+ {tags.map((tag) => ( + + ))} +
+
+
+ + ); +}; diff --git a/front/src/features/tables/helpers/__tests__/concatAndSortTags.ts b/front/src/features/tables/helpers/__tests__/concatAndSortTags.ts new file mode 100644 index 00000000..0bf6300a --- /dev/null +++ b/front/src/features/tables/helpers/__tests__/concatAndSortTags.ts @@ -0,0 +1,23 @@ +import "@testing-library/jest-dom"; +import { concatAndSortTags } from "../tagArrayManagement"; + +describe("concatAndSortTags", () => { + const tags = [ + { id: "1", name: "tag a" }, + { id: "2", name: "tag c" }, + ]; + const tag = { id: "3", name: "tag b" }; + it("should concat the array of tags with the new tag", () => { + const result = concatAndSortTags(tags, tag); + const filteredResult = result.filter((tag) => tag.id === "3"); + expect(filteredResult).toEqual([{ id: "3", name: "tag b" }]); + }); + it("should sort the array of tags", () => { + const result = concatAndSortTags(tags, tag); + expect(result).toEqual([ + { id: "1", name: "tag a" }, + { id: "3", name: "tag b" }, + { id: "2", name: "tag c" }, + ]); + }); +}); diff --git a/front/src/features/tables/helpers/__tests__/findTagById.ts b/front/src/features/tables/helpers/__tests__/findTagById.ts new file mode 100644 index 00000000..ab689040 --- /dev/null +++ b/front/src/features/tables/helpers/__tests__/findTagById.ts @@ -0,0 +1,22 @@ +import "@testing-library/jest-dom"; +import { findTagById } from "../tagArrayManagement"; + +describe("findTagById", () => { + const tags = [ + { id: "1", name: "tag1" }, + { id: "2", name: "tag2" }, + ]; + + it("when the id is valid, should return the tag with the given id", () => { + const tagId = "2"; + const tag = findTagById(tags, tagId); + expect(tag).toEqual(tags[1]); + }); + + it("should throw an error if the tag is not found", () => { + const tagId = "3"; + expect(() => findTagById(tags, tagId)).toThrow( + "Failed to find tag of id 3 among the given tags.", + ); + }); +}); diff --git a/front/src/features/tables/helpers/tagArrayManagement.ts b/front/src/features/tables/helpers/tagArrayManagement.ts new file mode 100644 index 00000000..9c687958 --- /dev/null +++ b/front/src/features/tables/helpers/tagArrayManagement.ts @@ -0,0 +1,22 @@ +import { Tag } from "@prisma/client"; + +export const isTagInArray = (tags: Tag[], tagId: string): boolean => { + return tags.some((tag) => tag.id === tagId); +}; + +export const findTagById = (tags: Tag[], tagId: string): Tag => { + const tag = tags.find((tag) => tag.id === tagId); + if (tag === undefined) { + throw new Error( + `Failed to find tag of id ${tagId} among the given tags.`, + ); + } + return tag; +}; + +export const concatAndSortTags = (tags: Tag[], newTag: Tag): Tag[] => { + const newTagArray = [...tags, newTag]; + return newTagArray.sort((tagA, tagB) => { + return tagA.name.localeCompare(tagB.name); + }); +}; diff --git a/front/src/features/tables/hooks/useTagPopup.ts b/front/src/features/tables/hooks/useTagPopup.ts new file mode 100644 index 00000000..ad076dd5 --- /dev/null +++ b/front/src/features/tables/hooks/useTagPopup.ts @@ -0,0 +1,38 @@ +import { LogsAPI } from "@/services/nextAPI/LogsAPI"; +import { TagsAPI } from "@/services/nextAPI/TagsAPI"; +import { Tag } from "@prisma/client"; +import { MouseEventHandler, useEffect, useState } from "react"; +import { findTagById } from "../helpers/tagArrayManagement"; + +export const useTagPopup = ( + logId: string, + addTagToDisplay: (tag: Tag) => void, +) => { + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [tags, setTags] = useState([]); + + useEffect(() => { + TagsAPI.getTags().then((tags) => setTags(tags)); + }, []); + + const selectTag = async (tagId: string) => { + const tag = findTagById(tags, tagId); + const success = await LogsAPI.connectTagToLog(logId, tagId); + if (success) { + addTagToDisplay(tag); + setIsPopupOpen(false); + } else { + alert("Failed to connect tag to log."); + } + }; + + const openPopup: MouseEventHandler = (e) => { + e.stopPropagation(); // Prevents the click event from bubbling up to the table row + setIsPopupOpen(true); + }; + + const closePopup = () => { + setIsPopupOpen(false); + }; + return { isPopupOpen, openPopup, closePopup, tags, selectTag }; +}; diff --git a/front/src/features/tables/EmptyLogs.tsx b/front/src/features/tables/views/EmptyLogs.tsx similarity index 100% rename from front/src/features/tables/EmptyLogs.tsx rename to front/src/features/tables/views/EmptyLogs.tsx diff --git a/front/src/features/tables/LogDetailsTable.tsx b/front/src/features/tables/views/LogDetailsTable.tsx similarity index 91% rename from front/src/features/tables/LogDetailsTable.tsx rename to front/src/features/tables/views/LogDetailsTable.tsx index 38bf2ec4..77c15e64 100644 --- a/front/src/features/tables/LogDetailsTable.tsx +++ b/front/src/features/tables/views/LogDetailsTable.tsx @@ -3,7 +3,7 @@ import { Button } from "@/components/atoms/Button"; import { CardAtom } from "@/components/atoms/CardAtom"; import { useNavigation } from "@/hooks/useNavigation"; import { LogData } from "@/types/logDisplayOptions"; -import { ColumnContentTags } from "./components/ColumnContentTags"; +import { ColumnContentTags } from "../components/ColumnContentTags"; export const LogDetailsTable = ({ logDetails }: { logDetails: LogData }) => { const cost_text: string = @@ -55,7 +55,10 @@ export const LogDetailsTable = ({ logDetails }: { logDetails: LogData }) => { Tags - + diff --git a/front/src/features/tables/LogsTable.tsx b/front/src/features/tables/views/LogsTable.tsx similarity index 91% rename from front/src/features/tables/LogsTable.tsx rename to front/src/features/tables/views/LogsTable.tsx index 077bb0b3..44029cb7 100644 --- a/front/src/features/tables/LogsTable.tsx +++ b/front/src/features/tables/views/LogsTable.tsx @@ -3,9 +3,9 @@ import { CardAtom } from "@/components/atoms/CardAtom"; import { ColumnHeader } from "@/features/tables/components/ColumnHeader"; import { useNavigation } from "@/hooks/useNavigation"; import { LogDataArray } from "@/types/logDisplayOptions"; -import { ColumnContent } from "./components/ColumnContent"; -import { ColumnContentTags } from "./components/ColumnContentTags"; -import { formatCostToString } from "./helpers/formatCost"; +import { ColumnContent } from "../components/ColumnContent"; +import { ColumnContentTags } from "../components/ColumnContentTags"; +import { formatCostToString } from "../helpers/formatCost"; export default function LogsTable({ logs }: { logs: LogDataArray }) { const { router } = useNavigation(); @@ -61,7 +61,7 @@ export default function LogsTable({ logs }: { logs: LogDataArray }) { "undefined: an error occurred" } /> - + ))} diff --git a/front/src/features/tables/TableBody.tsx b/front/src/features/tables/views/TableBody.tsx similarity index 100% rename from front/src/features/tables/TableBody.tsx rename to front/src/features/tables/views/TableBody.tsx diff --git a/front/src/services/nextAPI/LogsAPI.ts b/front/src/services/nextAPI/LogsAPI.ts new file mode 100644 index 00000000..fb0cc133 --- /dev/null +++ b/front/src/services/nextAPI/LogsAPI.ts @@ -0,0 +1,9 @@ +export const LogsAPI = { + connectTagToLog: (logId: string, tagId: string): Promise => { + const success = fetch(`/api/logs/${logId}/connectTag`, { + method: "POST", + body: JSON.stringify({ tagId }), + }).then((res) => res.status === 201); + return success; + }, +}; diff --git a/front/src/services/nextAPI/TagsAPI.ts b/front/src/services/nextAPI/TagsAPI.ts new file mode 100644 index 00000000..b863a21f --- /dev/null +++ b/front/src/services/nextAPI/TagsAPI.ts @@ -0,0 +1,11 @@ +import { safeCastToTagArray } from "@/types/safeCast"; +import { Tag } from "@prisma/client"; + +export const TagsAPI = { + getTags: async (): Promise => { + const response = await fetch("/api/tags", { method: "GET" }).then( + (res) => res.json(), + ); + return safeCastToTagArray(response.tags); + }, +}; diff --git a/front/src/services/LogsData.ts b/front/src/services/prisma/LogsData.ts similarity index 92% rename from front/src/services/LogsData.ts rename to front/src/services/prisma/LogsData.ts index e1089395..a4321658 100644 --- a/front/src/services/LogsData.ts +++ b/front/src/services/prisma/LogsData.ts @@ -1,6 +1,6 @@ import { LogData, LogDataArray } from "@/types/logDisplayOptions"; -import { prisma } from "./PrismaClient"; -import { convertSearchParamToPrismaConditions } from "./helpers/formatSearchParamToPrismaQuery"; +import { prisma } from "../PrismaClient"; +import { convertSearchParamToPrismaConditions } from "../helpers/formatSearchParamToPrismaQuery"; export const LogsData = { getLogs: async (searchParams?: URLSearchParams): Promise => { diff --git a/front/src/types/safeCast.ts b/front/src/types/safeCast.ts index 01adca1f..40747d70 100644 --- a/front/src/types/safeCast.ts +++ b/front/src/types/safeCast.ts @@ -1,4 +1,5 @@ import { timeOptionConstants } from "@/services/helpers/timeConstants"; +import { Tag } from "@prisma/client"; import { Order, SortOptions, TimeOption } from "./logDisplayOptions"; export const safeCastToTimeOption = ( @@ -23,3 +24,17 @@ export const safeCastToOrder = (value: string | null): Order => { if (["asc", "desc"].includes(value ?? "")) return value as Order; return "desc"; }; + +export const safeCastToTagArray = ( + tagArray: { name: string; id: string }[], +): Tag[] => { + for (const tag of tagArray) { + if (typeof tag.id !== "string") { + return []; + } + if (typeof tag.name !== "string") { + return []; + } + } + return tagArray as Tag[]; +};