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 React, { createContext, useContext, useState } from "react";
import type {
GetBoardByIdOutput,
NewCardInput,
NewListInput,
ReorderCardInput,
ReorderListInput,
} from "@kan/api/types";
import type { GetBoardByIdOutput, NewListInput } from "@kan/api/types";
import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup";
@@ -16,9 +10,6 @@ import { api } from "~/utils/api";
interface BoardContextProps {
boardData: GetBoardByIdOutput;
setBoardData: React.Dispatch<React.SetStateAction<GetBoardByIdOutput>>;
updateList: (params: ReorderListInput) => void;
updateCard: (params: ReorderCardInput) => void;
addCard: (params: NewCardInput) => void;
addList: (params: NewListInput) => void;
removeCard: (params: { cardPublicId: string }) => 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) => {
if (!boardData) return;
@@ -162,38 +81,11 @@ export const BoardProvider: React.FC<{ children: ReactNode }> = ({
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 (
<BoardContext.Provider
value={{
boardData,
setBoardData,
updateList,
updateCard,
addCard,
addList,
removeCard,
refetchBoard,

View File

@@ -7,6 +7,7 @@ import {
} from "react-icons/hi2";
import type { NewCardInput } from "@kan/api/types";
import { generateUID } from "@kan/shared/utils";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
@@ -14,7 +15,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
import Toggle from "~/components/Toggle";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -25,15 +25,28 @@ type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
};
interface NewCardFormProps {
listPublicId: string;
interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
}
export function NewCardForm({ listPublicId }: NewCardFormProps) {
const { boardData, addCard, refetchBoard } = useBoard();
interface NewCardFormProps {
boardPublicId: string;
listPublicId: string;
queryParams: QueryParams;
}
export function NewCardForm({
boardPublicId,
listPublicId,
queryParams,
}: NewCardFormProps) {
const { showPopup } = usePopup();
const { closeModal } = useModal();
const utils = api.useUtils();
const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({
defaultValues: {
@@ -52,19 +65,63 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position");
const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId,
});
const createCard = api.card.create.useMutation({
onSuccess: async () => {
await refetchBoard();
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 = 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 () => {
closeModal();
await refetchBoard();
onError: (_error, _newList, context) => {
utils.board.byId.setData(queryParams, context?.previousState);
showPopup({
header: "Unable to create card",
message: "Please try again later, or contact customer support.",
icon: "error",
});
},
onSettled: async () => {
await utils.board.byId.invalidate(queryParams);
},
});
useEffect(() => {
@@ -108,7 +165,6 @@ export function NewCardForm({ listPublicId }: NewCardFormProps) {
})) ?? [];
const onSubmit = (data: NewCardInput) => {
addCard(data);
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal();
reset({

View File

@@ -15,8 +15,8 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { useBoard } from "~/providers/board";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { formatToArray } from "~/utils/helpers";
@@ -36,7 +36,8 @@ type PublicListId = string;
export default function BoardPage() {
const params = useParams() as { boardId: string[] } | null;
const router = useRouter();
const { boardData, setBoardData, updateCard, updateList } = useBoard();
const utils = api.useUtils();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const { openModal, modalContentType } = useModal();
const [selectedPublicListId, setSelectedPublicListId] =
@@ -60,23 +61,110 @@ export default function BoardPage() {
});
};
const { data, isSuccess, isLoading } = api.board.byId.useQuery(
{
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
const queryParams = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
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 };
},
{
enabled: !!boardId,
onError: (_error, _newList, context) => {
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(() => {
if (isSuccess && data) {
setBoardData(data);
setValue("name", data.name || "");
if (isSuccess && boardData) {
setValue("name", boardData.name || "");
}
}, [isSuccess, data, setBoardData, setValue]);
}, [isSuccess, boardData, setValue]);
if (!boardId || !boardData) return <></>;
@@ -96,16 +184,7 @@ export default function BoardPage() {
}
if (type === "LIST") {
const updatedLists = Array.from(boardData.lists);
const removedList = updatedLists.splice(source.index, 1)[0];
if (removedList) {
updatedLists.splice(destination.index, 0, removedList);
setBoardData({ ...boardData, lists: updatedLists });
}
updateList({
updateListMutation.mutate({
listPublicId: draggableId,
currentIndex: source.index,
newIndex: destination.index,
@@ -113,24 +192,11 @@ export default function BoardPage() {
}
if (type === "CARD") {
const updatedLists = Array.from(boardData.lists);
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({
updateCardMutation.mutate({
cardPublicId: draggableId,
currentListPublicId: source.droppableId,
newListPublicId: destination.droppableId,
currentIndex: source.index,
newIndex: destination.index,
});
}
@@ -277,7 +343,11 @@ export default function BoardPage() {
<DeleteListConfirmation listPublicId={selectedPublicListId} />
)}
{modalContentType === "NEW_CARD" && (
<NewCardForm listPublicId={selectedPublicListId} />
<NewCardForm
boardPublicId={boardId}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_LIST" && (
<NewListForm boardPublicId={boardId} />

View File

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