Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/add tag binding #90

Merged
merged 24 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b3e7ca6
feat(front): create calls from services to tag api
Lerri-Cofannos Oct 6, 2023
e46f575
feat(front): add red option in button atom
Lerri-Cofannos Oct 6, 2023
29d497b
feat(front): create button atom with icon
Lerri-Cofannos Oct 6, 2023
bb7717c
feat(front): create popup atom
Lerri-Cofannos Oct 6, 2023
1d49193
feat(front): safe cast response from tagsAPI
Lerri-Cofannos Oct 6, 2023
bbdd338
feat(front): create tag popup
Lerri-Cofannos Oct 6, 2023
d09e3a9
refactor(front): move tables to views subdirectory
Lerri-Cofannos Oct 6, 2023
f2a7b30
fix(front): adjust getTags to server response
Lerri-Cofannos Oct 6, 2023
20a1eff
style(front): improve popup UI
Lerri-Cofannos Oct 6, 2023
9bcb131
refactor(front): move LogsData to prisma folder
Lerri-Cofannos Oct 10, 2023
2b55f4a
fix(front): update path to LogsData in page test
Lerri-Cofannos Oct 10, 2023
e19a887
style(front): change ButtonIcon outline colour
Lerri-Cofannos Oct 10, 2023
0b3c3c6
feat(front): improve UX speed when adding tags
Lerri-Cofannos Oct 10, 2023
b9af47d
refactor(front): move the propagation prevention to useTagPopup
Lerri-Cofannos Oct 10, 2023
5e4824f
refactor(front): move connectTagToLog to relevant file
Lerri-Cofannos Oct 10, 2023
72f975e
refactor(front): group tag helper functions
Lerri-Cofannos Oct 10, 2023
c64b9ee
feat(front): handle errors while connecting log and tag
Lerri-Cofannos Oct 10, 2023
39034c6
fix(front): prevent adding existing tag to array
Lerri-Cofannos Oct 10, 2023
4a22a4e
refactor(front): rename colour picker function
Lerri-Cofannos Oct 10, 2023
e800c1b
fix(front): remove unused buttons on popup
Lerri-Cofannos Oct 10, 2023
badbd1d
refactor(front): create closePopup func in tag hook
Lerri-Cofannos Oct 11, 2023
56006ed
refactor(front): use built-in string comparison
Lerri-Cofannos Oct 11, 2023
dec6e0e
refactor(front): clean className string format
Lerri-Cofannos Oct 11, 2023
8de6f1a
refactor(front): remove unused type option for button
Lerri-Cofannos Oct 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion front/src/app/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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([])),
}));

Expand Down
2 changes: 1 addition & 1 deletion front/src/app/api/logs/[id]/connectTag/route.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
4 changes: 2 additions & 2 deletions front/src/app/logs/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 2 additions & 2 deletions front/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
36 changes: 29 additions & 7 deletions front/src/components/atoms/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<button
className={` ${bgColour} text-white font-bold py-2 px-4 rounded ${className} transition-colors duration-100`}
Expand All @@ -19,4 +22,23 @@ export const Button = ({ children, onClick, className, colour = "blue", ...props
{children}
</button>
);
}
};

const buttonColourPicker = (colour: string): string => {
const blueStyle = "bg-theodo-turquoise hover:bg-omnilog-clear-blue";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this colourPicker specific to the button? If so, maybe include that in the name? Before I understood the function I thought if might be better living outside this folder, but actually it's looks specific to button? Probably good to make this clear in the name

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add it in the name, but this function is not exported so I thought it was clear that the scope was restrained to this file. I will rename it to buttonColourPicker.

const greyStyle = "bg-theodo-grey-regular hover:bg-theodo-turquoise";
const greenStyle = "bg-theodo-green-dark hover:bg-theodo-green-regular";
const redStyle = "bg-red-600 hover:bg-red-500";
switch (colour) {
case "blue":
return blueStyle;
case "grey":
return greyStyle;
case "green":
return greenStyle;
case "red":
return redStyle;
default:
return blueStyle;
}
};
19 changes: 19 additions & 0 deletions front/src/components/atoms/ButtonIcon.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason that this component can't extend your non-icon button?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The style of both components are a bit different: this one has grey scale colours and is a circle.

Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { MouseEventHandler } from "react";

type ButtonIconProps = {
onClick: MouseEventHandler<HTMLButtonElement>;
icon: IconDefinition;
};

export const ButtonIcon = ({ onClick, icon }: ButtonIconProps) => {
return (
<button
className="bg-white hover:bg-theodo-grey-regular outline outline-gray-500 text-gray-600 rounded-3xl h-6 w-6 justify-center align-middle transition-colors duration-100"
onClick={onClick}
>
<FontAwesomeIcon icon={icon} />
</button>
);
};
56 changes: 56 additions & 0 deletions front/src/components/atoms/Popup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={() => {}}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close is a different ticket im assuming?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close is a compulsory prop of the Dialog component. It is called when clicking outside of the modal. I deactivated that behaviour to avoid a bug. The bug was that exiting with such a click navigated the user to the corresponding log. I suspect that the event that I had caught with e.stopPropagate in openPopup is freed and thus a click is made on the log's row, making the user navigate to it. I did not find a quick solution on the documentation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do users close the dialog in this case?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a button in the top right corner (see screenshot)

<CustomTransition>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</CustomTransition>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<CustomTransition>
<Dialog.Panel className="relative transform shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<CardAtom>
<div className="absolute top-3 right-3">
<ButtonIcon
onClick={close}
icon={faTimes}
/>
</div>
<div className="p-2 flex flex-col">
{children}
</div>
</CardAtom>
</Dialog.Panel>
</CustomTransition>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

const CustomTransition = ({ children }: { children: ReactNode }) => (
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{children}
</Transition.Child>
);
2 changes: 1 addition & 1 deletion front/src/components/atoms/TagLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Tag } from "@prisma/client";

export const TagLabel = ({ tag }: { tag: Tag }) => {
return (
<div className="text-sm text-gray-800 rounded-md px-2 py-1 m-1 outline outline-gray-400 truncate">
<div className="text-sm text-gray-800 rounded-md px-2 py-1 m-1 outline outline-gray-500 bg-white truncate">
{tag.name}
</div>
);
Expand Down
30 changes: 25 additions & 5 deletions front/src/features/tables/components/ColumnContentTags.tsx
Original file line number Diff line number Diff line change
@@ -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<Tag[]>(tags);
const addTag = (tag: Tag) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this is just setting it locally for now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the idea is to update the database and the UI at the same time so that the user sees the new tag on his log without having to refresh the whole page. To do that I had to store the tags in this state.

if (isTagInArray(tagArray, tag.id)) return;
setTagArray(concatAndSortTags(tagArray, tag));
};
return (
<td className="px-4 py-2 flex flex-wrap">
{tags.map((tag) => (
<TagLabel key={tag.id} tag={tag} />
))}
<td className="px-4 py-2 flex flex-wrap items-center gap-1">
{tagArray
.map((tag) => <TagLabel key={tag.id} tag={tag} />)
.concat(
<TagPopup
key="plus-button"
logId={logId}
addTagToDisplay={addTag}
/>,
)}
</td>
);
};
41 changes: 41 additions & 0 deletions front/src/features/tables/components/TagPopup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ButtonIcon onClick={openPopup} icon={faPlus} />
<Popup isOpen={isPopupOpen} close={closePopup}>
<div className="bg-white items-stretch sm:items-start text-center">
<h2 className=" text-lg font-bold leading-6 text-gray-900 my-4">
Pick a tag to add
</h2>
<div className=" w-full my-6 p-2 outline outline-gray-500 bg-gray-50 rounded-md px-4 py-2 flex flex-wrap items-center gap-1">
{tags.map((tag) => (
<button
key={tag.id}
onClick={() => selectTag(tag.id)}
>
<TagLabel tag={tag} />
</button>
))}
</div>
</div>
</Popup>
</>
);
};
23 changes: 23 additions & 0 deletions front/src/features/tables/helpers/__tests__/concatAndSortTags.ts
Original file line number Diff line number Diff line change
@@ -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" },
]);
});
});
22 changes: 22 additions & 0 deletions front/src/features/tables/helpers/__tests__/findTagById.ts
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
22 changes: 22 additions & 0 deletions front/src/features/tables/helpers/tagArrayManagement.ts
Original file line number Diff line number Diff line change
@@ -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);
});
};
38 changes: 38 additions & 0 deletions front/src/features/tables/hooks/useTagPopup.ts
Original file line number Diff line number Diff line change
@@ -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<Tag[]>([]);

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<HTMLButtonElement> = (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 };
};
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious why some of your imports are relative and others are absolute?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took the habit of writing the imports that are linked to the same feature as relative, and the ones which are not as absolute. Is this bad practice ?


export const LogDetailsTable = ({ logDetails }: { logDetails: LogData }) => {
const cost_text: string =
Expand Down Expand Up @@ -55,7 +55,10 @@ export const LogDetailsTable = ({ logDetails }: { logDetails: LogData }) => {
</tr>
<tr>
<td className="font-bold px-4 py-2">Tags</td>
<ColumnContentTags tags={logDetails.tags} />
<ColumnContentTags
tags={logDetails.tags}
logId={logDetails.id}
/>
</tr>
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -61,7 +61,7 @@ export default function LogsTable({ logs }: { logs: LogDataArray }) {
"undefined: an error occurred"
}
/>
<ColumnContentTags tags={log.tags} />
<ColumnContentTags tags={log.tags} logId={log.id} />
</tr>
))}
</tbody>
Expand Down
Loading