perf: improve board optimistic updates
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user