perf: improve board optimistic updates

This commit is contained in:
Henry
2025-02-06 22:45:55 +00:00
parent b1fa8a529d
commit fe45c31795
4 changed files with 181 additions and 161 deletions

View File

@@ -1,13 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React, { createContext, useContext, useState } from "react"; import React, { createContext, useContext, useState } from "react";
import type { import type { GetBoardByIdOutput, NewListInput } from "@kan/api/types";
GetBoardByIdOutput,
NewCardInput,
NewListInput,
ReorderCardInput,
ReorderListInput,
} from "@kan/api/types";
import { generateUID } from "@kan/shared/utils"; import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
@@ -16,9 +10,6 @@ import { api } from "~/utils/api";
interface BoardContextProps { interface BoardContextProps {
boardData: GetBoardByIdOutput; boardData: GetBoardByIdOutput;
setBoardData: React.Dispatch<React.SetStateAction<GetBoardByIdOutput>>; setBoardData: React.Dispatch<React.SetStateAction<GetBoardByIdOutput>>;
updateList: (params: ReorderListInput) => void;
updateCard: (params: ReorderCardInput) => void;
addCard: (params: NewCardInput) => void;
addList: (params: NewListInput) => void; addList: (params: NewListInput) => void;
removeCard: (params: { cardPublicId: string }) => void; removeCard: (params: { cardPublicId: string }) => void;
refetchBoard: () => Promise<void>; refetchBoard: () => Promise<void>;
@@ -60,78 +51,6 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({
} }
}; };
const updateCardMutation = api.card.reorder.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
await refetchBoard();
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const updateListMutation = api.list.reorder.useMutation({
onSuccess: async () => {
await refetchBoard();
},
onError: async () => {
await refetchBoard();
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
});
const addCard = ({
title,
listPublicId,
labelPublicIds,
memberPublicIds,
position,
}: {
title: string;
listPublicId: string;
labelPublicIds: string[];
memberPublicIds: string[];
position: "start" | "end";
}) => {
if (!boardData) return;
const updatedLists = boardData.lists.map((list) => {
if (list.publicId === listPublicId) {
const newCard = {
publicId: `PLACEHOLDER_${generateUID()}`,
title,
listId: 2,
description: "",
labels: boardData.labels.filter((label) =>
labelPublicIds.includes(label.publicId),
),
members:
boardData.workspace?.members.filter((member) =>
memberPublicIds.includes(member.publicId),
) ?? [],
index: position === "start" ? 0 : list.cards.length,
};
const updatedCards =
position === "start"
? [newCard, ...list.cards]
: [...list.cards, newCard];
return { ...list, cards: updatedCards };
}
return list;
});
setBoardData({ ...boardData, lists: updatedLists });
};
const addList = ({ name, boardPublicId }: NewListInput) => { const addList = ({ name, boardPublicId }: NewListInput) => {
if (!boardData) return; if (!boardData) return;
@@ -162,38 +81,11 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({
setBoardData({ ...boardData, lists: updatedLists }); setBoardData({ ...boardData, lists: updatedLists });
}; };
const updateList = ({
listPublicId,
currentIndex,
newIndex,
}: ReorderListInput) => {
updateListMutation.mutate({
listPublicId,
currentIndex,
newIndex,
});
};
const updateCard = ({
cardPublicId,
newListPublicId,
newIndex,
}: ReorderCardInput) => {
updateCardMutation.mutate({
cardPublicId,
newListPublicId,
newIndex,
});
};
return ( return (
<BoardContext.Provider <BoardContext.Provider
value={{ value={{
boardData, boardData,
setBoardData, setBoardData,
updateList,
updateCard,
addCard,
addList, addList,
removeCard, removeCard,
refetchBoard, refetchBoard,

View File

@@ -7,6 +7,7 @@ import {
} from "react-icons/hi2"; } from "react-icons/hi2";
import type { NewCardInput } from "@kan/api/types"; import type { NewCardInput } from "@kan/api/types";
import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
@@ -14,7 +15,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import Input from "~/components/Input"; import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle"; import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -25,15 +25,28 @@ type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean; isCreateAnotherEnabled: boolean;
}; };
interface NewCardFormProps { interface QueryParams {
listPublicId: string; boardPublicId: string;
members: string[];
labels: string[];
} }
export function NewCardForm({ listPublicId }: NewCardFormProps) { interface NewCardFormProps {
const { boardData, addCard, refetchBoard } = useBoard(); boardPublicId: string;
listPublicId: string;
queryParams: QueryParams;
}
export function NewCardForm({
boardPublicId,
listPublicId,
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { closeModal } = useModal(); const { closeModal } = useModal();
const utils = api.useUtils();
const { register, handleSubmit, reset, setValue, watch } = const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({ useForm<NewCardFormInput>({
defaultValues: { defaultValues: {
@@ -52,19 +65,63 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position"); const position = watch("position");
const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId,
});
const createCard = api.card.create.useMutation({ const createCard = api.card.create.useMutation({
onSuccess: async () => { onMutate: async (args) => {
await refetchBoard(); await utils.board.byId.cancel();
const currentState = utils.board.byId.getData(queryParams);
utils.board.byId.setData(queryParams, (oldBoard) => {
if (!oldBoard) return oldBoard;
const updatedLists = oldBoard.lists.map((list) => {
if (list.publicId === listPublicId) {
const newCard = {
publicId: `PLACEHOLDER_${generateUID()}`,
title: args.title,
listId: 2,
description: "",
labels: oldBoard.labels.filter((label) =>
labelPublicIds.includes(label.publicId),
),
members:
oldBoard.workspace?.members.filter((member) =>
memberPublicIds.includes(member.publicId),
) ?? [],
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
index: position === "start" ? 0 : list.cards.length,
};
const updatedCards =
position === "start"
? [newCard, ...list.cards]
: [...list.cards, newCard];
return { ...list, cards: updatedCards };
}
return list;
});
return { ...oldBoard, lists: updatedLists };
});
return { previousState: currentState };
}, },
onError: async () => { onError: (_error, _newList, context) => {
closeModal(); utils.board.byId.setData(queryParams, context?.previousState);
await refetchBoard();
showPopup({ showPopup({
header: "Unable to create card", header: "Unable to create card",
message: "Please try again later, or contact customer support.", message: "Please try again later, or contact customer support.",
icon: "error", icon: "error",
}); });
}, },
onSettled: async () => {
await utils.board.byId.invalidate(queryParams);
},
}); });
useEffect(() => { useEffect(() => {
@@ -108,7 +165,6 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
})) ?? []; })) ?? [];
const onSubmit = (data: NewCardInput) => { const onSubmit = (data: NewCardInput) => {
addCard(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal(); if (!isCreateAnotherEnabled) closeModal();
reset({ reset({

View File

@@ -15,8 +15,8 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground"; import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable"; import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers"; import { formatToArray } from "~/utils/helpers";
@@ -36,7 +36,8 @@ type PublicListId = string;
export default function BoardPage() { export default function BoardPage() {
const params = useParams() as { boardId: string[] } | null; const params = useParams() as { boardId: string[] } | null;
const router = useRouter(); const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard(); const utils = api.useUtils();
const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { openModal, modalContentType } = useModal(); const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] = const [selectedPublicListId, setSelectedPublicListId] =
@@ -60,23 +61,110 @@ export default function BoardPage() {
}); });
}; };
const { data, isSuccess, isLoading } = api.board.byId.useQuery( const queryParams = {
{ boardPublicId: boardId ?? "",
boardPublicId: boardId ?? "", members: formatToArray(router.query.members),
members: formatToArray(router.query.members), labels: formatToArray(router.query.labels),
labels: formatToArray(router.query.labels), };
const {
data: boardData,
isSuccess,
isLoading,
} = api.board.byId.useQuery(queryParams, {
enabled: !!boardId,
});
const updateListMutation = api.list.reorder.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();
const currentState = utils.board.byId.getData(queryParams);
utils.board.byId.setData(queryParams, (oldBoard) => {
if (!oldBoard) return oldBoard;
const updatedLists = Array.from(oldBoard.lists);
const removedList = updatedLists.splice(args.currentIndex, 1)[0];
if (removedList) {
updatedLists.splice(args.newIndex, 0, removedList);
return {
...oldBoard,
lists: updatedLists,
};
}
});
return { previousState: currentState };
}, },
{ onError: (_error, _newList, context) => {
enabled: !!boardId, utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to update list",
message: "Please try again later, or contact customer support.",
icon: "error",
});
}, },
); onSettled: async () => {
await utils.board.byId.invalidate(queryParams);
},
});
const updateCardMutation = api.card.reorder.useMutation({
onMutate: async (args) => {
await utils.board.byId.cancel();
const currentState = utils.board.byId.getData(queryParams);
utils.board.byId.setData(queryParams, (oldBoard) => {
if (!oldBoard) return oldBoard;
const updatedLists = Array.from(oldBoard.lists);
const sourceList = updatedLists.find(
(list) => list.publicId === args.currentListPublicId,
);
const destinationList = updatedLists.find(
(list) => list.publicId === args.newListPublicId,
);
const removedCard = sourceList?.cards.splice(args.currentIndex, 1)[0];
if (
sourceList &&
destinationList &&
removedCard &&
args.newIndex !== undefined
) {
destinationList.cards.splice(args.newIndex, 0, removedCard);
return {
...oldBoard,
lists: updatedLists,
};
}
});
return { previousState: currentState };
},
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to update card",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
onSettled: async () => {
await utils.board.byId.invalidate(queryParams);
},
});
useEffect(() => { useEffect(() => {
if (isSuccess && data) { if (isSuccess && boardData) {
setBoardData(data); setValue("name", boardData.name || "");
setValue("name", data.name || "");
} }
}, [isSuccess, data, setBoardData, setValue]); }, [isSuccess, boardData, setValue]);
if (!boardId || !boardData) return <></>; if (!boardId || !boardData) return <></>;
@@ -96,16 +184,7 @@ export default function BoardPage() {
} }
if (type === "LIST") { if (type === "LIST") {
const updatedLists = Array.from(boardData.lists); updateListMutation.mutate({
const removedList = updatedLists.splice(source.index, 1)[0];
if (removedList) {
updatedLists.splice(destination.index, 0, removedList);
setBoardData({ ...boardData, lists: updatedLists });
}
updateList({
listPublicId: draggableId, listPublicId: draggableId,
currentIndex: source.index, currentIndex: source.index,
newIndex: destination.index, newIndex: destination.index,
@@ -113,24 +192,11 @@ export default function BoardPage() {
} }
if (type === "CARD") { if (type === "CARD") {
const updatedLists = Array.from(boardData.lists); updateCardMutation.mutate({
const sourceList = updatedLists.find(
(list) => list.publicId === source.droppableId,
);
const destinationList = updatedLists.find(
(list) => list.publicId === destination.droppableId,
);
const removedCard = sourceList?.cards.splice(source.index, 1)[0];
if (sourceList && destinationList && removedCard) {
destinationList.cards.splice(destination.index, 0, removedCard);
setBoardData({ ...boardData, lists: updatedLists });
}
updateCard({
cardPublicId: draggableId, cardPublicId: draggableId,
currentListPublicId: source.droppableId,
newListPublicId: destination.droppableId, newListPublicId: destination.droppableId,
currentIndex: source.index,
newIndex: destination.index, newIndex: destination.index,
}); });
} }
@@ -277,7 +343,11 @@ export default function BoardPage() {
<DeleteListConfirmation listPublicId={selectedPublicListId} /> <DeleteListConfirmation listPublicId={selectedPublicListId} />
)} )}
{modalContentType === "NEW_CARD" && ( {modalContentType === "NEW_CARD" && (
<NewCardForm listPublicId={selectedPublicListId} /> <NewCardForm
boardPublicId={boardId}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)} )}
{modalContentType === "NEW_LIST" && ( {modalContentType === "NEW_LIST" && (
<NewListForm boardPublicId={boardId} /> <NewListForm boardPublicId={boardId} />

View File

@@ -667,7 +667,9 @@ export const cardRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
cardPublicId: z.string().min(12), cardPublicId: z.string().min(12),
currentListPublicId: z.string().min(12),
newListPublicId: z.string().min(12), newListPublicId: z.string().min(12),
currentIndex: z.number(),
newIndex: z.number().optional(), newIndex: z.number().optional(),
}), }),
) )