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
24 changes: 23 additions & 1 deletion apps/web/src/views/card/components/ChecklistItemRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";

import { usePopup } from "~/providers/popup";
Expand All @@ -15,12 +17,16 @@ interface ChecklistItemRowProps {
};
cardPublicId: string;
viewOnly?: boolean;
dragHandleProps?: DraggableProvided["dragHandleProps"];
isDragging?: boolean;
}

export default function ChecklistItemRow({
item,
cardPublicId,
viewOnly = false,
dragHandleProps,
isDragging = false,
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
Expand Down Expand Up @@ -138,7 +144,23 @@ export default function ChecklistItemRow({
};

return (
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<div
className={twMerge(
"group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100",
isDragging && "opacity-80",
)}
>
{!viewOnly && (
<div
{...dragHandleProps}
className="absolute left-0 top-1/2 flex h-[20px] w-[20px] -translate-x-full -translate-y-1/2 cursor-grab items-center justify-center pr-1 opacity-0 transition-opacity group-hover:opacity-75 hover:opacity-100 active:cursor-grabbing"
>
<RiDraggable className="h-4 w-4 text-light-700 dark:text-dark-700" />
</div>
)}

{viewOnly && <div className="w-[20px] flex-shrink-0" />}

<label
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
>
Expand Down
243 changes: 151 additions & 92 deletions apps/web/src/views/card/components/Checklists.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { DropResult } from "react-beautiful-dnd";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { HiPlus, HiXMark } from "react-icons/hi2";

import CircularProgress from "~/components/CircularProgress";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ChecklistItemRow from "./ChecklistItemRow";
import ChecklistNameInput from "./ChecklistNameInput";
import NewChecklistItemForm from "./NewChecklistItemForm";
Expand Down Expand Up @@ -34,109 +38,164 @@ export default function Checklists({
viewOnly = false,
}: ChecklistsProps) {
const { openModal } = useModal();
const utils = api.useUtils();

const reorderItemMutation = api.checklist.reorderItem.useMutation({
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});

const onDragEnd = (result: DropResult) => {
if (!result.destination) return;

const { source, destination, draggableId } = result;

if (source.droppableId !== destination.droppableId) return;

if (source.index === destination.index) return;

reorderItemMutation.mutate({
checklistItemPublicId: draggableId,
index: destination.index,
});
};

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

return (
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
<DragDropContext onDragEnd={onDragEnd}>
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;

return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
</div>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
)}
</div>

<Droppable
droppableId={checklist.publicId}
type="CHECKLIST_ITEM"
isDropDisabled={viewOnly}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="ml-1"
>
{checklist.items.map((item, index) => (
<Draggable
key={item.publicId}
draggableId={item.publicId}
index={index}
isDragDisabled={viewOnly}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1,
}}
>
<ChecklistItemRow
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
dragHandleProps={provided.dragHandleProps}
isDragging={snapshot.isDragging}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>

<div className="ml-1">
{checklist.items.map((item) => (
<ChecklistItemRow
key={item.publicId}
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
))}
</div>

{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
);
})}
</div>
</div>
</div>
</DragDropContext>
);
}
63 changes: 62 additions & 1 deletion packages/api/src/routers/checklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import * as cardRepo from "@kan/db/repository/card.repo";
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
import * as checklistRepo from "@kan/db/repository/checklist.repo";

import { createTRPCRouter, protectedProcedure } from "../trpc";
import {
createNextApiContext,
createTRPCRouter,
protectedProcedure,
} from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";

const checklistSchema = z.object({
Expand Down Expand Up @@ -349,6 +353,63 @@ export const checklistRouter = createTRPCRouter({

return updated;
}),
reorderItem: protectedProcedure
Copy link
Contributor

Choose a reason for hiding this comment

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

We should make this part of updateItem instead of a standalone route. Ideally updateItem would be a patch request that can just take a publicId and index and do the reordering in a transaction if index is provided (bit like card.update). This keeps the API clean and predictable

.meta({
openapi: {
summary: "Reorder a checklist item",
method: "PUT",
path: "/checklists/items/{checklistItemPublicId}/reorder",
description: "Reorders a checklist item",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
checklistItemPublicId: z.string().length(12),
index: z.number().int().min(0),
}),
)
.output(checklistItemSchema)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;

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

const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
ctx.db,
input.checklistItemPublicId,
);

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

await assertUserInWorkspace(
ctx.db,
userId,
item.checklist.card.list.board.workspace.id,
);

const reordered = await checklistRepo.reorderItem(ctx.db, {
itemId: item.id,
newIndex: input.index,
});

if (!reordered)
throw new TRPCError({
message: `Failed to reorder checklist item`,
code: "INTERNAL_SERVER_ERROR",
});

return reordered;
}),
deleteItem: protectedProcedure
.meta({
openapi: {
Expand Down
Loading
Loading