Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions apps/web/src/views/board/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const Card = ({
comments,
attachments,
dueDate,
parent,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
Expand All @@ -45,6 +46,7 @@ const Card = ({
comments: { publicId: string }[];
attachments?: { publicId: string }[];
dueDate?: Date | null;
parent?: { publicId: string; title: string } | null;
}) => {
const { dateLocale } = useLocalisation();
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
Expand All @@ -67,6 +69,11 @@ const Card = ({

return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
{parent && (
<span className="mb-1 truncate text-[10px] font-medium text-blue-600 dark:text-blue-400">
{parent.title}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||
Expand Down
81 changes: 81 additions & 0 deletions apps/web/src/views/board/components/EpicsBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { t } from "@lingui/core/macro";
import { useState } from "react";
import { HiChevronDown, HiChevronUp } from "react-icons/hi2";

import { api } from "~/utils/api";

interface EpicsBarProps {
boardPublicId: string;
}

const EpicsBar = ({ boardPublicId }: EpicsBarProps) => {
const [isCollapsed, setIsCollapsed] = useState(false);

const { data: epics } = api.card.getEpics.useQuery(
{ boardPublicId },
{ enabled: !!boardPublicId },
);

if (!epics || epics.length === 0) return null;

return (
<div className="z-10 mx-6 mb-2 md:mx-8">
<div className="rounded-md border border-light-200 bg-light-50 dark:border-dark-200 dark:bg-dark-100">
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="flex w-full items-center justify-between px-3 py-2 text-xs font-medium text-light-900 hover:text-light-1000 dark:text-dark-900 dark:hover:text-dark-1000"
>
<span>{t`Epics`} ({epics.length})</span>
{isCollapsed ? (
<HiChevronDown className="h-3.5 w-3.5" />
) : (
<HiChevronUp className="h-3.5 w-3.5" />
)}
</button>
{!isCollapsed && (
<div className="flex gap-3 overflow-x-auto px-3 pb-3">
{epics.map((epic) => {
const progress =
epic.totalChildren > 0
? Math.round(
(epic.doneChildren / epic.totalChildren) * 100,
)
: 0;

return (
<div
key={epic.publicId}
className="flex min-w-[180px] flex-col gap-1.5 rounded-md border border-light-200 bg-white px-3 py-2 dark:border-dark-300 dark:bg-dark-200"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-neutral-900 dark:text-dark-1000">
{epic.title}
</span>
<span className="flex-shrink-0 rounded-full bg-light-200 px-1.5 py-0.5 text-[10px] font-medium text-light-900 dark:bg-dark-400 dark:text-dark-950">
{epic.doneChildren}/{epic.totalChildren}
</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-light-200 dark:bg-dark-400">
<div
className={`h-full rounded-full transition-all duration-300 ${
progress === 100
? "bg-green-500"
: "bg-blue-500"
}`}
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-[10px] text-light-700 dark:text-dark-800">
{epic.listName}
</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
};

export default EpicsBar;
1 change: 1 addition & 0 deletions apps/web/src/views/board/components/NewCardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function NewCardForm({
comments: [],
checklists: [],
attachments: [],
parent: null,
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/views/board/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { formatToArray } from "~/utils/helpers";
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
import BoardDropdown from "./components/BoardDropdown";
import Card from "./components/Card";
import EpicsBar from "./components/EpicsBar";
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
import { CardContextLabelsModal } from "./components/CardContextLabelsModal";
Expand Down Expand Up @@ -631,6 +632,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
</div>
</div>

{boardData && boardId && (
<EpicsBar boardPublicId={boardId} />
)}

<div
ref={scrollRef}
onMouseDown={onMouseDown}
Expand Down Expand Up @@ -766,6 +771,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
parent={card.parent ?? null}
/>
</Link>
)}
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/views/card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,17 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {

return (
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
{card?.parent && (
<div className="mb-4 flex w-full flex-row pt-[18px]">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Epic`}</p>
<Link
href={`/cards/${card.parent.publicId}`}
className="my-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{card.parent.title}
</Link>
</div>
)}
<div className="mb-4 flex w-full flex-row pt-[18px]">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector
Expand Down Expand Up @@ -367,6 +378,17 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
<div className="p-6 md:p-8">
{card?.parent && (
<div className="mb-2 md:mt-4">
<Link
href={`/cards/${card.parent.publicId}`}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
<IoChevronForwardSharp className="h-2.5 w-2.5 rotate-180" />
{t`Epic`}: {card.parent.title}
</Link>
</div>
)}
<div className="mb-8 md:mt-4">
{!card && isLoading && (
<div className="flex space-x-2">
Expand Down Expand Up @@ -432,6 +454,29 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
setActiveChecklistForm={setActiveChecklistForm}
viewOnly={!canEdit}
/>
{card.children && card.children.length > 0 && (
<div className="mt-6">
<h2 className="text-md pb-3 font-medium text-light-1000 dark:text-dark-1000">
{t`Sub-tasks`}
</h2>
<div className="flex flex-col gap-1">
{card.children.map((child) => (
<Link
key={child.publicId}
href={`/cards/${child.publicId}`}
className="flex items-center justify-between rounded-md border border-light-200 px-3 py-2 text-sm hover:bg-light-100 dark:border-dark-300 dark:hover:bg-dark-200"
>
<span className="text-neutral-900 dark:text-dark-1000">
{child.title}
</span>
<span className="text-xs text-light-700 dark:text-dark-800">
{child.list.name}
</span>
</Link>
))}
</div>
</div>
)}
{!isTemplate && (
<>
{card?.attachments.length > 0 && (
Expand Down
136 changes: 136 additions & 0 deletions packages/api/src/routers/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,4 +1357,140 @@ export const cardRouter = createTRPCRouter({

return { publicId: newCard.publicId };
}),
setParent: protectedProcedure
.meta({
openapi: {
summary: "Set or remove a card's parent (epic)",
method: "PUT",
path: "/cards/{cardPublicId}/parent",
description: "Sets a parent card (epic) for a card, or removes it",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
parentPublicId: z.string().min(12).nullable(),
}),
)
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});

const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
ctx.db,
input.cardPublicId,
);

if (!card)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});

await assertPermission(ctx.db, userId, card.workspaceId, "card:edit");

let parentId: number | null = null;

if (input.parentPublicId) {
const parentCard = await cardRepo.getByPublicId(
ctx.db,
input.parentPublicId,
);

if (!parentCard)
throw new TRPCError({
message: `Parent card with public ID ${input.parentPublicId} not found`,
code: "NOT_FOUND",
});

// Prevent self-reference
if (parentCard.id === card.id)
throw new TRPCError({
message: "A card cannot be its own parent",
code: "BAD_REQUEST",
});

parentId = parentCard.id;
}

await cardRepo.setParent(ctx.db, {
cardId: card.id,
parentId,
});

return { success: true };
}),
getEpics: protectedProcedure
.meta({
openapi: {
summary: "Get epics for a board",
method: "GET",
path: "/boards/{boardPublicId}/epics",
description: "Returns all cards that have children (epics) for a board, with progress",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
boardPublicId: z.string().min(12),
}),
)
.output(
z.array(
z.object({
publicId: z.string(),
title: z.string(),
dueDate: z.date().nullable(),
listName: z.string(),
totalChildren: z.number(),
doneChildren: z.number(),
}),
),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;

if (!userId)
throw new TRPCError({
message: "User not authenticated",
code: "UNAUTHORIZED",
});

const board = await ctx.db.query.boards.findFirst({
columns: { id: true, workspaceId: true },
where: (boards, { eq, isNull, and }) =>
and(
eq(boards.publicId, input.boardPublicId),
isNull(boards.deletedAt),
),
});

if (!board)
throw new TRPCError({
message: `Board with public ID ${input.boardPublicId} not found`,
code: "NOT_FOUND",
});

await assertPermission(ctx.db, userId, board.workspaceId, "card:view");

const epics = await cardRepo.getEpicsByBoard(ctx.db, board.id);

return epics.map((epic: any) => ({
publicId: epic.publicId,
title: epic.title,
dueDate: epic.dueDate ?? null,
listName: epic.list_name,
totalChildren: Number(epic.total_children),
doneChildren: Number(epic.done_children),
}));
}),
});
6 changes: 6 additions & 0 deletions packages/api/src/schemas/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ const boardDetailCardSchema = z.object({
description: z.string().nullable(),
index: z.number(),
dueDate: z.date().nullable(),
parent: z
.object({
publicId: z.string(),
title: z.string(),
})
.nullable(),
labels: z.array(labelSchema),
members: z.array(boardCardMemberSchema),
attachments: z.array(z.object({ publicId: z.string() })),
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/schemas/card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ export const cardDetailSchema = z.object({
description: z.string().nullable(),
dueDate: z.date().nullable(),
createdBy: z.string().nullable(),
parent: z
.object({
publicId: z.string(),
title: z.string(),
})
.nullable(),
children: z.array(
z.object({
publicId: z.string(),
title: z.string(),
index: z.number(),
list: z.object({
publicId: z.string(),
name: z.string(),
}),
}),
),
labels: z.array(labelSchema),
attachments: z.array(
z.object({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ALTER TABLE "card" ADD COLUMN "parentId" bigint;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_parentId_card_id_fk" FOREIGN KEY ("parentId") REFERENCES "public"."card"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
Loading