feat: optimisic card updates

This commit is contained in:
Henry
2025-02-02 10:33:32 +00:00
parent 14b4d73fb6
commit eba5d9335c
6 changed files with 122 additions and 19 deletions

View File

@@ -24,7 +24,10 @@ interface CheckboxDropdownProps {
createNewItemLabel?: string; createNewItemLabel?: string;
menuSpacing?: "sm" | "md" | "lg"; menuSpacing?: "sm" | "md" | "lg";
position?: "left" | "right"; position?: "left" | "right";
handleSelect: (groupKey: string | null, item: { key: string }) => void; handleSelect: (
groupKey: string | null,
item: { key: string; value: string },
) => void;
handleEdit?: (key: string) => void; handleEdit?: (key: string) => void;
handleCreate?: () => void; handleCreate?: () => void;
asChild?: boolean; asChild?: boolean;
@@ -58,7 +61,7 @@ export default function CheckboxDropdown({
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300" className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleSelect(groupKey, { key: item.key }); handleSelect(groupKey, { key: item.key, value: item.value });
}} }}
> >
<input <input
@@ -67,7 +70,9 @@ export default function CheckboxDropdown({
type="checkbox" type="checkbox"
className="h-[14px] w-[14px] rounded bg-transparent" className="h-[14px] w-[14px] rounded bg-transparent"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
onChange={() => handleSelect(groupKey, { key: item.key })} onChange={() =>
handleSelect(groupKey, { key: item.key, value: item.value })
}
checked={item.selected} checked={item.selected}
/> />
{item.leftIcon && ( {item.leftIcon && (

View File

@@ -9,7 +9,7 @@ import {
HiOutlineUserPlus, HiOutlineUserPlus,
} from "react-icons/hi2"; } from "react-icons/hi2";
import { type GetCardByIdOutput } from "@kan/api/types"; import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Comment from "./Comment"; import Comment from "./Comment";
@@ -148,7 +148,7 @@ const ActivityList = ({
}) => { }) => {
return ( return (
<div className="flex flex-col space-y-4 pt-4"> <div className="flex flex-col space-y-4 pt-4">
{activities?.map((activity, index) => { {activities.map((activity, index) => {
const activityText = getActivityText({ const activityText = getActivityText({
type: activity.type, type: activity.type,
toTitle: activity.toTitle, toTitle: activity.toTitle,

View File

@@ -14,12 +14,14 @@ interface LabelSelectorProps {
selected: boolean; selected: boolean;
leftIcon: React.ReactNode; leftIcon: React.ReactNode;
}[]; }[];
handleSelectLabel: (labelPublicId: string) => void;
isLoading: boolean; isLoading: boolean;
} }
export default function LabelSelector({ export default function LabelSelector({
cardPublicId, cardPublicId,
labels, labels,
handleSelectLabel,
isLoading, isLoading,
}: LabelSelectorProps) { }: LabelSelectorProps) {
const { openModal } = useModal(); const { openModal } = useModal();
@@ -44,9 +46,10 @@ export default function LabelSelector({
) : ( ) : (
<CheckboxDropdown <CheckboxDropdown
items={labels} items={labels}
handleSelect={(_, label) => handleSelect={(_, label) => {
addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key }) handleSelectLabel(label.key);
} addOrRemoveLabel.mutate({ cardPublicId, labelPublicId: label.key });
}}
handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)} handleEdit={(labelPublicId) => openModal("EDIT_LABEL", labelPublicId)}
handleCreate={() => openModal("NEW_LABEL")} handleCreate={() => openModal("NEW_LABEL")}
createNewItemLabel="Create new label" createNewItemLabel="Create new label"

View File

@@ -10,12 +10,14 @@ interface ListSelectorProps {
value: string; value: string;
selected: boolean; selected: boolean;
}[]; }[];
handleChangeList: (newListPublicId: string, newListName: string) => void;
isLoading: boolean; isLoading: boolean;
} }
export default function ListSelector({ export default function ListSelector({
cardPublicId, cardPublicId,
lists, lists,
handleChangeList,
isLoading, isLoading,
}: ListSelectorProps) { }: ListSelectorProps) {
const utils = api.useUtils(); const utils = api.useUtils();
@@ -39,12 +41,13 @@ export default function ListSelector({
) : ( ) : (
<CheckboxDropdown <CheckboxDropdown
items={lists} items={lists}
handleSelect={(_, member) => handleSelect={(_, member) => {
handleChangeList(member.key, member.value);
updateCardList.mutate({ updateCardList.mutate({
cardPublicId, cardPublicId,
newListPublicId: member.key, newListPublicId: member.key,
}) });
} }}
asChild asChild
> >
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200"> <Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-200 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-300 dark:border-dark-100 dark:text-dark-1000 dark:hover:border-dark-300 dark:hover:bg-dark-200">

View File

@@ -5,6 +5,7 @@ import { HiMiniPlus } from "react-icons/hi2";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import CheckboxDropdown from "~/components/CheckboxDropdown"; import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
interface MemberSelectorProps { interface MemberSelectorProps {
@@ -16,16 +17,19 @@ interface MemberSelectorProps {
leftIcon: React.ReactNode; leftIcon: React.ReactNode;
imageUrl: string | undefined; imageUrl: string | undefined;
}[]; }[];
handleSelectMember: (memberPublicId: string) => void;
isLoading: boolean; isLoading: boolean;
} }
export default function MemberSelector({ export default function MemberSelector({
cardPublicId, cardPublicId,
members, members,
handleSelectMember,
isLoading, isLoading,
}: MemberSelectorProps) { }: MemberSelectorProps) {
const router = useRouter(); const router = useRouter();
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils(); const utils = api.useUtils();
const refetchCard = () => utils.card.byId.refetch({ cardPublicId }); const refetchCard = () => utils.card.byId.refetch({ cardPublicId });
@@ -34,6 +38,14 @@ export default function MemberSelector({
onSuccess: async () => { onSuccess: async () => {
await refetchCard(); await refetchCard();
}, },
onError: async () => {
await refetchCard();
showPopup({
header: "Unable to update members",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
}); });
const selectedMembers = members.filter((member) => member.selected); const selectedMembers = members.filter((member) => member.selected);
@@ -52,12 +64,13 @@ export default function MemberSelector({
) : ( ) : (
<CheckboxDropdown <CheckboxDropdown
items={members} items={members}
handleSelect={(_, member) => handleSelect={(_, member) => {
handleSelectMember(member.key);
addOrRemoveMember.mutate({ addOrRemoveMember.mutate({
cardPublicId, cardPublicId,
workspaceMemberPublicId: member.key, workspaceMemberPublicId: member.key,
}) });
} }}
handleCreate={handleInviteMember} handleCreate={handleInviteMember}
createNewItemLabel="Invite member" createNewItemLabel="Invite member"
asChild asChild

View File

@@ -1,9 +1,12 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable"; import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
@@ -35,6 +38,7 @@ export default function CardPage() {
const utils = api.useUtils(); const utils = api.useUtils();
const { modalContentType, entityId } = useModal(); const { modalContentType, entityId } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const [card, setCard] = useState<GetCardByIdOutput>();
const cardId = Array.isArray(router.query.cardId) const cardId = Array.isArray(router.query.cardId)
? router.query.cardId[0] ? router.query.cardId[0]
@@ -44,13 +48,85 @@ export default function CardPage() {
cardPublicId: cardId ?? "", cardPublicId: cardId ?? "",
}); });
const board = data?.list?.board; useEffect(() => {
setCard(data);
}, [data]);
const board = card?.list?.board;
const boardId = board?.publicId; const boardId = board?.publicId;
const labels = board?.labels; const labels = board?.labels;
const activities = data?.activities; const activities = card?.activities;
const workspaceMembers = board?.workspace?.members; const workspaceMembers = board?.workspace?.members;
const selectedLabels = data?.labels; const selectedLabels = card?.labels;
const selectedMembers = data?.members; const selectedMembers = card?.members;
const handleChangeList = (newListPublicId: string) => {
if (!card) return;
setCard({
...card,
list: {
...card.list,
publicId: newListPublicId,
name: card.list?.name ?? "",
board: card.list?.board ?? null,
},
});
};
const handleSelectLabel = (labelPublicId: string) => {
if (!card) return;
const isSelected = card.labels.some(
(label) => label.publicId === labelPublicId,
);
const updatedLabels = isSelected
? card.labels.filter((label) => label.publicId !== labelPublicId)
: [
...(card.labels ?? []),
labels?.find((label) => label.publicId === labelPublicId),
].filter(Boolean);
setCard({
...card,
labels: updatedLabels as {
publicId: string;
name: string;
colourCode: string | null;
}[],
});
};
const handleSelectMember = (memberPublicId: string) => {
if (!card) return;
const isSelected = card.members.some(
(member) => member.publicId === memberPublicId,
);
const updatedMembers = isSelected
? card.members.filter((member) => member.publicId !== memberPublicId)
: [
...(card.members ?? []),
workspaceMembers?.find(
(member) => member.publicId === memberPublicId,
),
];
setCard({
...card,
members: updatedMembers.filter(Boolean) as {
publicId: string;
user: {
id: string;
name: string | null;
email: string;
image: string | null;
} | null;
}[],
});
};
const formattedLabels = const formattedLabels =
labels?.map((label) => { labels?.map((label) => {
@@ -70,7 +146,7 @@ export default function CardPage() {
board?.lists.map((list) => ({ board?.lists.map((list) => ({
key: list.publicId, key: list.publicId,
value: list.name, value: list.name,
selected: list.publicId === data?.list?.publicId, selected: list.publicId === card?.list?.publicId,
})) ?? []; })) ?? [];
const formattedMembers = const formattedMembers =
@@ -219,6 +295,7 @@ export default function CardPage() {
<ListSelector <ListSelector
cardPublicId={cardId} cardPublicId={cardId}
lists={formattedLists} lists={formattedLists}
handleChangeList={handleChangeList}
isLoading={isLoading} isLoading={isLoading}
/> />
</div> </div>
@@ -227,6 +304,7 @@ export default function CardPage() {
<LabelSelector <LabelSelector
cardPublicId={cardId} cardPublicId={cardId}
labels={formattedLabels} labels={formattedLabels}
handleSelectLabel={handleSelectLabel}
isLoading={isLoading} isLoading={isLoading}
/> />
</div> </div>
@@ -235,6 +313,7 @@ export default function CardPage() {
<MemberSelector <MemberSelector
cardPublicId={cardId} cardPublicId={cardId}
members={formattedMembers} members={formattedMembers}
handleSelectMember={handleSelectMember}
isLoading={isLoading} isLoading={isLoading}
/> />
</div> </div>