diff --git a/bun.lockb b/bun.lockb index a5a25084..f98cd33c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a736408b..0ab6515a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@trpc/react-query": "next", "@trpc/server": "^11.0.0-rc.566", "@vercel/postgres": "^0.7.2", + "date-fns": "^4.1.0", "drizzle-orm": "^0.28.5", "next": "^14.1.3", "nextjs-cors": "^2.2.0", diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 00000000..de4526c6 --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,47 @@ +import { twMerge } from "tailwind-merge"; +import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers"; + +const Avatar = ({ + size = "md", + name, + email, + icon, + isLoading, +}: { + size?: "sm" | "md" | "lg"; + name: string; + email: string; + icon: React.ReactNode; + isLoading: boolean; +}) => { + const initials = name + ? getInitialsFromName(name) + : inferInitialsFromEmail(email ?? ""); + + return ( + + {icon ? ( + {icon} + ) : ( + + {initials} + + )} + + ); +}; + +export default Avatar; diff --git a/src/server/db/repository/card.repo.ts b/src/server/db/repository/card.repo.ts index 017d2507..415e1eb7 100644 --- a/src/server/db/repository/card.repo.ts +++ b/src/server/db/repository/card.repo.ts @@ -263,12 +263,14 @@ export const getWithListAndMembersByPublicId = async ( publicId, user!workspace_members_userId_user_id_fk ( id, - name + name, + email ) ), user!card_activity_createdBy_user_id_fk ( id, - name + name, + email ) ) `, diff --git a/src/types/router.types.ts b/src/types/router.types.ts index 9762d694..e6e23bbc 100644 --- a/src/types/router.types.ts +++ b/src/types/router.types.ts @@ -1,6 +1,7 @@ import { type RouterInputs, type RouterOutputs } from "~/utils/api"; export type GetBoardByIdOutput = RouterOutputs["board"]["byId"]; +export type GetCardByIdOutput = RouterOutputs["card"]["byId"]; export type ReorderListInput = RouterInputs["list"]["reorder"]; export type ReorderCardInput = RouterInputs["card"]["reorder"]; export type UpdateBoardInput = RouterInputs["board"]["update"]; diff --git a/src/views/card/components/ActivityList.tsx b/src/views/card/components/ActivityList.tsx new file mode 100644 index 00000000..74341506 --- /dev/null +++ b/src/views/card/components/ActivityList.tsx @@ -0,0 +1,185 @@ +import { formatDistanceToNow } from "date-fns"; +import { + HiOutlinePencil, + HiOutlineTag, + HiOutlineUserPlus, + HiOutlineUserMinus, + HiOutlineArrowRight, + HiOutlinePlus, +} from "react-icons/hi2"; + +import Avatar from "~/components/Avatar"; + +import { type GetCardByIdOutput } from "~/types/router.types"; + +type ActivityType = + NonNullable["activities"][number]["type"]; + +const ACTIVITY_TYPE_MAP = { + "card.created": "created the card", + "card.updated.title": "updated the title", + "card.updated.description": "updated the description", + "card.updated.list": "moved the card to another list", + "card.updated.label.added": "added a label to the card", + "card.updated.label.removed": "removed a label from the card", + "card.updated.member.added": "added a member to the card", + "card.updated.member.removed": "removed a member from the card", +} as const; + +const getActivityText = ({ + type, + toTitle, + fromList, + toList, + memberName, + isSelf, + label, +}: { + type: ActivityType; + toTitle: string | null; + fromList: string | null; + toList: string | null; + memberName: string | null; + isSelf: boolean; + label: string | null; +}) => { + if (!(type in ACTIVITY_TYPE_MAP)) return null; + const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP]; + + const TextHighlight = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + if (type === "card.updated.title" && toTitle) { + return ( + <> + updated the title to {toTitle} + + ); + } + + if (type === "card.updated.list" && fromList && toList) { + return ( + <> + moved the card from {fromList} to + {toList} + + ); + } + + if (type === "card.updated.member.added" && memberName) { + if (isSelf) return <>self-assigned the card; + + return ( + <> + assigned {memberName} to the card + + ); + } + + if (type === "card.updated.member.removed" && memberName) { + if (isSelf) return <>unassigned themselves from the card; + + return ( + <> + unassigned {memberName} from the card + + ); + } + + if (type === "card.updated.label.added" && label) { + return ( + <> + added label {label} + + ); + } + + if (type === "card.updated.label.removed" && label) { + return ( + <> + removed label {label} + + ); + } + + return baseText; +}; + +const ACTIVITY_ICON_MAP: Partial> = + { + "card.created": , + "card.updated.title": , + "card.updated.description": , + "card.updated.list": , + "card.updated.label.added": , + "card.updated.label.removed": , + "card.updated.member.added": , + "card.updated.member.removed": , + } as const; + +const getActivityIcon = (type: ActivityType): React.ReactNode | null => { + return ACTIVITY_ICON_MAP[type] ?? null; +}; + +const ActivityList = ({ + activities, + isLoading, +}: { + activities: NonNullable["activities"]; + isLoading: boolean; +}) => { + return ( +
+ {activities?.map((activity, index) => { + const activityText = getActivityText({ + type: activity.type, + toTitle: activity.toTitle, + fromList: activity.fromList?.name ?? null, + toList: activity.toList?.name ?? null, + memberName: activity.member?.user?.name ?? null, + isSelf: activity.member?.user?.id === activity.user?.id, + label: activity.label?.name ?? null, + }); + + if (!activityText) return null; + + return ( +
+
+ + {index !== activities.length - 1 && ( +
+ )} +
+

+ {`${activity.user?.name} `} + + {activityText} + + ยท + + {formatDistanceToNow(new Date(activity.createdAt), { + addSuffix: true, + })} + +

+
+ ); + })} +
+ ); +}; + +export default ActivityList; diff --git a/src/views/card/index.tsx b/src/views/card/index.tsx index 3de0495e..e396e3a9 100644 --- a/src/views/card/index.tsx +++ b/src/views/card/index.tsx @@ -4,6 +4,7 @@ import { useForm } from "react-hook-form"; import ContentEditable from "react-contenteditable"; import { IoChevronForwardSharp } from "react-icons/io5"; +import ActivityList from "./components/ActivityList"; import Dropdown from "./components/Dropdown"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation"; @@ -15,6 +16,7 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import Modal from "~/components/modal"; import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -26,7 +28,9 @@ interface FormValues { export default function CardPage() { const params = useParams(); + const utils = api.useUtils(); const { modalContentType, entityId } = useModal(); + const { showPopup } = usePopup(); const cardId = Array.isArray(params?.cardId) ? params.cardId[0] @@ -39,6 +43,7 @@ export default function CardPage() { const board = data?.list?.board; const boardId = board?.publicId; const labels = board?.labels; + const activities = data?.activities; const workspaceMembers = board?.workspace?.members; const selectedLabels = data?.labels; const selectedMembers = data?.members; @@ -75,7 +80,17 @@ export default function CardPage() { }; }) ?? []; - const updateCard = api.card.update.useMutation(); + const updateCard = api.card.update.useMutation({ + onSuccess: async () => { + await utils.card.byId.refetch(); + }, + onError: () => { + showPopup({ + header: "Unable to update card", + message: "Please try again later, or contact customer support.", + }); + }, + }); const { register, handleSubmit, setValue, watch } = useForm({ values: { @@ -97,58 +112,74 @@ export default function CardPage() { return (
-
-
- {isLoading ? ( -
-
-
-
- ) : ( - <> - - {board?.name} - - -
-
- -
-
-
- +
+
+
+ {isLoading ? ( +
+
+
- - )} -
-
-
-
- setValue("description", e.target.value)} - onBlur={handleSubmit(onSubmit)} - className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" + ) : ( + <> + + {board?.name} + + + +
+ +
+ +
+ +
+ + )} +
+
+
+
+ setValue("description", e.target.value)} + onBlur={handleSubmit(onSubmit)} + className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6" + /> +
+
+
+
+

+ Activity +

+
+
- +