fix: creating a new label when adding a new card resets all fields (#119)

* fix: creating a new label when adding a new card resets all fields

* fix: persist form data and modal stacking without breaking the animations
This commit is contained in:
Ridham Khandar
2025-07-29 02:19:13 +05:30
committed by GitHub
parent babaa9f1b1
commit f8827da73a
8 changed files with 282 additions and 105 deletions

View File

@@ -86,8 +86,10 @@ export default function CheckboxDropdown({
</label> </label>
{handleEdit && ( {handleEdit && (
<button <button
type="button"
className="invisible ml-auto group-hover:visible" className="invisible ml-auto group-hover:visible"
onClick={(event) => { onClick={(event) => {
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
handleEdit(item.key); handleEdit(item.key);
}} }}
@@ -100,8 +102,12 @@ export default function CheckboxDropdown({
))} ))}
{handleCreate && ( {handleCreate && (
<button <button
type="button"
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300" className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
onClick={() => handleCreate()} onClick={(e) => {
e.preventDefault();
handleCreate();
}}
> >
<HiMiniPlus size={20} className="pr-1.5" /> <HiMiniPlus size={20} className="pr-1.5" />
{createNewItemLabel} {createNewItemLabel}

View File

@@ -15,6 +15,7 @@ import {
ReactRenderer, ReactRenderer,
useEditor, useEditor,
} from "@tiptap/react"; } from "@tiptap/react";
import { useEffect } from "react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion"; import Suggestion from "@tiptap/suggestion";
import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import { forwardRef, useImperativeHandle, useRef, useState } from "react";
@@ -343,9 +344,19 @@ export default function Editor({
editable: !readOnly, editable: !readOnly,
injectCSS: false, injectCSS: false,
}, },
[content], [], // creating the editor only once
); );
// this will sync external content changes without re-creating the editor instance
useEffect(() => {
if (!editor) return;
const currentHTML = editor.getHTML();
const safeContent = content ?? "";
if (safeContent !== currentHTML) {
editor.commands.setContent(safeContent, false);
}
}, [content, editor]);
return ( return (
<div ref={containerRef}> <div ref={containerRef}>
<style jsx global>{` <style jsx global>{`

View File

@@ -63,12 +63,16 @@ export function LabelForm({
); );
try { try {
refetch(); refetch();
if (!isCreateAnotherEnabled) closeModal(); if (!isCreateAnotherEnabled) {
reset({ closeModal();
name: "", } else {
colour: colours[(currentColourIndex + 1) % colours.length], const newFormState = {
isCreateAnotherEnabled, name: "",
}); colour: colours[(currentColourIndex + 1) % colours.length],
isCreateAnotherEnabled,
};
reset(newFormState);
}
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
@@ -79,10 +83,6 @@ export function LabelForm({
onSuccess: () => { onSuccess: () => {
refetch(); refetch();
closeModal(); closeModal();
reset({
name: "",
colour: colours[0],
});
}, },
}); });

View File

@@ -7,15 +7,19 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
modalSize?: "sm" | "md" | "lg"; modalSize?: "sm" | "md" | "lg";
positionFromTop?: "sm" | "md" | "lg"; positionFromTop?: "sm" | "md" | "lg";
isVisible?: boolean;
} }
const Modal: React.FC<Props> = ({ const Modal: React.FC<Props> = ({
children, children,
modalSize = "sm", modalSize = "sm",
positionFromTop = "md", positionFromTop = "md",
isVisible,
}) => { }) => {
const { isOpen, closeModal } = useModal(); const { isOpen, closeModal } = useModal();
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
const modalSizeMap = { const modalSizeMap = {
sm: "max-w-[400px]", sm: "max-w-[400px]",
md: "max-w-[550px]", md: "max-w-[550px]",
@@ -29,7 +33,7 @@ const Modal: React.FC<Props> = ({
}; };
return ( return (
<Transition.Root show={isOpen} as={Fragment}> <Transition.Root show={shouldShow} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}> <Dialog as="div" className="relative z-10" onClose={closeModal}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}

View File

@@ -0,0 +1,48 @@
import { useEffect } from "react";
import { useModal } from "~/providers/modal";
interface UseModalFormStateOptions<T> {
modalType: string;
initialValues: T;
resetOnClose?: boolean;
}
export function useModalFormState<T extends Record<string, any>>({
modalType,
initialValues,
resetOnClose = false,
}: UseModalFormStateOptions<T>) {
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
const isCurrentModal = modalContentType === modalType;
const savedState = getModalState(modalType) as T | undefined;
// get current form state (using the saved values if available, otherwise the initial values)
const formState = savedState || initialValues;
const saveFormState = (state: Partial<T>) => {
if (!isCurrentModal) return;
const currentState = getModalState(modalType) || initialValues;
const newState = { ...currentState, ...state };
setModalState(modalType, newState);
};
const clearFormState = () => {
clearModalState(modalType);
};
useEffect(() => {
if (resetOnClose && !isOpen && savedState) {
clearModalState(modalType);
}
}, [isOpen, resetOnClose, savedState, modalType, clearModalState]);
return {
formState,
saveFormState,
clearFormState,
isCurrentModal,
hasSavedState: !!savedState,
};
}

View File

@@ -1,5 +1,15 @@
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react";
interface ModalState {
contentType: string;
entityId?: string;
entityLabel?: string;
}
interface Props {
children: React.ReactNode;
}
type ModalContextType = { type ModalContextType = {
isOpen: boolean; isOpen: boolean;
openModal: ( openModal: (
@@ -11,33 +21,64 @@ type ModalContextType = {
modalContentType: string; modalContentType: string;
entityId: string; entityId: string;
entityLabel: string; entityLabel: string;
modalStates: Record<string, any>;
setModalState: (modalType: string, state: any) => void;
getModalState: (modalType: string) => any;
clearModalState: (modalType: string) => void;
clearAllModalStates: () => void;
}; };
interface Props {
children: React.ReactNode;
}
const ModalContext = createContext<ModalContextType | undefined>(undefined); const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const ModalProvider: React.FC<Props> = ({ children }) => { export const ModalProvider: React.FC<Props> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false); const [modalStack, setModalStack] = useState<ModalState[]>([]);
const [entityId, setEntityId] = useState(""); const [modalStates, setModalStates] = useState<Record<string, any>>({});
const [entityLabel, setEntityLabel] = useState("");
const [modalContentType, setModalContentType] = useState(""); const isOpen = modalStack.length > 0;
const currentModal = modalStack[modalStack.length - 1];
const modalContentType = currentModal?.contentType || "";
const entityId = currentModal?.entityId || "";
const entityLabel = currentModal?.entityLabel || "";
const openModal = ( const openModal = (
contentType: string, contentType: string,
entityId?: string, entityId?: string,
entityLabel?: string, entityLabel?: string,
) => { ) => {
setIsOpen(true); const newModal: ModalState = { contentType, entityId, entityLabel };
setModalContentType(contentType); setModalStack(prev => [...prev, newModal]);
if (entityId) setEntityId(entityId);
if (entityLabel) setEntityLabel(entityLabel);
}; };
const closeModal = () => { const closeModal = () => {
setIsOpen(false); setModalStack(prev => {
if (prev.length <= 1) {
return [];
}
return prev.slice(0, -1);
});
};
const setModalState = (modalType: string, state: any) => {
setModalStates(prev => ({
...prev,
[modalType]: state
}));
};
const getModalState = (modalType: string) => {
return modalStates[modalType];
};
const clearModalState = (modalType: string) => {
setModalStates(prev => {
const newStates = { ...prev };
delete newStates[modalType];
return newStates;
});
};
const clearAllModalStates = () => {
setModalStates({});
}; };
return ( return (
@@ -49,6 +90,11 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
modalContentType, modalContentType,
entityId, entityId,
entityLabel, entityLabel,
modalStates,
setModalState,
getModalState,
clearModalState,
clearAllModalStates,
}} }}
> >
{children} {children}

View File

@@ -18,6 +18,7 @@ import Editor from "~/components/Editor";
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 { useModalFormState } from "~/hooks/useModalFormState";
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";
@@ -49,17 +50,24 @@ export function NewCardForm({
const utils = api.useUtils(); const utils = api.useUtils();
// persists the form values
const { formState, saveFormState } = useModalFormState<NewCardFormInput>({
modalType: "NEW_CARD",
initialValues: {
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
resetOnClose: true,
});
const { register, handleSubmit, reset, setValue, watch } = const { register, handleSubmit, reset, setValue, watch } =
useForm<NewCardFormInput>({ useForm<NewCardFormInput>({
defaultValues: { values: formState,
title: "",
description: "",
listPublicId,
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
},
}); });
const labelPublicIds = watch("labelPublicIds") || []; const labelPublicIds = watch("labelPublicIds") || [];
@@ -67,6 +75,15 @@ export function NewCardForm({
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
const position = watch("position"); const position = watch("position");
const title = watch("title"); const title = watch("title");
const description = watch("description");
// saving form state whenever form values change
useEffect(() => {
const subscription = watch((data) => {
saveFormState(data as NewCardFormInput);
});
return () => subscription.unsubscribe();
}, [watch, saveFormState]);
const { data: boardData } = api.board.byId.useQuery(queryParams, { const { data: boardData } = api.board.byId.useQuery(queryParams, {
enabled: !!boardPublicId, enabled: !!boardPublicId,
@@ -131,17 +148,24 @@ export function NewCardForm({
}, },
onSuccess: async () => { onSuccess: async () => {
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled"); const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
if (!isCreateAnotherEnabled) closeModal(); if (!isCreateAnotherEnabled) {
// close modal (state will auto-clear due to resetOnClose: true)
closeModal();
} else {
// reset form for creating another card
const newFormState = {
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
};
reset(newFormState);
saveFormState(newFormState);
}
await utils.board.byId.invalidate(queryParams); await utils.board.byId.invalidate(queryParams);
reset({
title: "",
description: "",
listPublicId: watch("listPublicId"),
labelPublicIds: [],
memberPublicIds: [],
isCreateAnotherEnabled,
position,
});
}, },
}); });
@@ -264,8 +288,11 @@ export function NewCardForm({
<div className="mt-2"> <div className="mt-2">
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6"> <div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
<Editor <Editor
content="" content={description}
onChange={(value) => setValue("description", value)} onChange={(value) => {
setValue("description", value);
saveFormState({ ...formState, description: value });
}}
/> />
</div> </div>
</div> </div>

View File

@@ -45,7 +45,7 @@ export default function BoardPage() {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { openModal, modalContentType, entityId } = useModal(); const { openModal, modalContentType, entityId, isOpen } = useModal();
const [selectedPublicListId, setSelectedPublicListId] = const [selectedPublicListId, setSelectedPublicListId] =
useState<PublicListId>(""); useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -235,6 +235,97 @@ export default function BoardPage() {
} }
}; };
const renderModalContent = () => {
return (
<>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LIST"}
>
<DeleteListConfirmation
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LIST"}
>
<NewListForm
boardPublicId={boardId ?? ""}
queryParams={queryParams}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LABEL"}
>
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
>
<LabelForm
boardPublicId={boardId ?? ""}
refetch={refetchBoard}
isEdit
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
>
<DeleteLabelConfirmation
refetch={refetchBoard}
labelPublicId={entityId}
/>
</Modal>
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPDATE_BOARD_SLUG"}
>
<UpdateBoardSlugForm
boardPublicId={boardId ?? ""}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
/>
</Modal>
</>
);
};
return ( return (
<> <>
<PageHead <PageHead
@@ -422,63 +513,7 @@ export default function BoardPage() {
</> </>
) : null} ) : null}
</div> </div>
<Modal {renderModalContent()}
modalSize={
modalContentType === "NEW_CARD" ||
modalContentType === "NEW_FEEDBACK"
? "md"
: "sm"
}
>
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
{modalContentType === "DELETE_BOARD" && (
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
)}
{modalContentType === "DELETE_LIST" && (
<DeleteListConfirmation
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_CARD" && (
<NewCardForm
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_LIST" && (
<NewListForm
boardPublicId={boardId ?? ""}
queryParams={queryParams}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "NEW_LABEL" && (
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
)}
{modalContentType === "EDIT_LABEL" && (
<LabelForm
boardPublicId={boardId ?? ""}
refetch={refetchBoard}
isEdit
/>
)}
{modalContentType === "DELETE_LABEL" && (
<DeleteLabelConfirmation
refetch={refetchBoard}
labelPublicId={entityId}
/>
)}
{modalContentType === "UPDATE_BOARD_SLUG" && (
<UpdateBoardSlugForm
boardPublicId={boardId ?? ""}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
/>
)}
</Modal>
</div> </div>
</> </>
); );