✨(frontend) update sender of draft message

Previously it wasn't possible to change the sender of a draft message.
Now we allow that and in this case we remove the current draft and create
a new one in the new mailbox. After that we redirect the user to the view of
the new draft to continue it's edit!
This commit is contained in:
jbpenrath
2025-05-19 11:16:03 +02:00
committed by Jean-Baptiste PENRATH
parent 37220e615e
commit 0d214634ab
6 changed files with 76 additions and 28 deletions
@@ -6,7 +6,7 @@ import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen";
import { DraftMessageRequestRequest, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen";
import MessageEditor from "@/features/forms/components/message-editor";
import { useMailboxContext } from "@/features/providers/mailbox";
import MailHelper from "@/features/utils/mail-helper";
@@ -14,6 +14,7 @@ import { RhfInput, RhfSelect } from "../react-hook-form";
import { addToast, ToasterItem } from "@/features/ui/components/toaster";
import { toast } from "react-toastify";
import { useSentBox } from "@/features/providers/sent-box";
import { useRouter } from "next/router";
interface MessageFormProps {
// For reply mode
@@ -67,13 +68,18 @@ export const MessageForm = ({
onSuccess
}: MessageFormProps) => {
const { t } = useTranslation();
const router = useRouter();
const [draft, setDraft] = useState<Message | undefined>(draftMessage);
const [showCCField, setShowCCField] = useState((draftMessage?.cc?.length ?? 0) > 0);
const [showBCCField, setShowBCCField] = useState((draftMessage?.bcc?.length ?? 0) > 0);
const [pendingSubmit, setPendingSubmit] = useState(false);
const { selectedMailbox, mailboxes, invalidateThreadMessages, invalidateThreadsStats, unselectThread } = useMailboxContext();
const hideSubjectField = Boolean(parentMessage);
const hideFromField = (mailboxes?.length ?? 0) === 0 || draft;
const defaultSenderId = mailboxes?.find((mailbox) => {
if (draft?.sender) return draft.sender.email === mailbox.email;
return selectedMailbox?.id === mailbox.id;
})?.id ?? mailboxes?.[0]?.id;
const hideFromField = defaultSenderId && (mailboxes?.length ?? 0) === 1;
const { addQueuedMessage } = useSentBox();
const getMailboxOptions = () => {
@@ -114,7 +120,7 @@ export const MessageForm = ({
}, [parentMessage, replyAll, selectedMailbox]);
const formDefaultValues = useMemo(() => ({
from: selectedMailbox?.id || mailboxes?.[0]?.id || '',
from: defaultSenderId ?? '',
to: (draft?.to?.map(contact => contact.email) ?? recipients).join(', '),
cc: (draft?.cc?.map(contact => contact.email) ?? []).join(', '),
bcc: (draft?.bcc?.map(contact => contact.email) ?? []).join(', '),
@@ -171,32 +177,58 @@ export const MessageForm = ({
mutation: { onSuccess: handleDraftMutationSuccess }
});
const deleteMessageMutation = useMessagesDestroy({
mutation: {
onSuccess: () => {
setDraft(undefined);
invalidateThreadMessages();
invalidateThreadsStats();
unselectThread();
addToast(
<ToasterItem type="info">
<span>{t("message_form.success.draft_deleted")}</span>
</ToasterItem>
);
onClose?.();
},
}
});
const deleteMessageMutation = useMessagesDestroy();
const handleDeleteMessage = (messageId: string) => {
if(window.confirm(t("message_form.confirm.delete"))) {
deleteMessageMutation.mutate({
id: messageId
}, {
onSuccess: () => {
setDraft(undefined);
invalidateThreadMessages();
invalidateThreadsStats();
unselectThread();
addToast(
<ToasterItem type="info">
<span>{t("message_form.success.draft_deleted")}</span>
</ToasterItem>
);
onClose?.();
},
});
}
}
/**
* If the user changes the message sender, we need to delete the draft,
* then recreate a new one. Once the new draft is created, we need to
* redirect the user to the new draft view.
*/
const handleChangeSender = async (data: DraftMessageRequestRequest) => {
if (draft && form.formState.dirtyFields.from) {
await deleteMessageMutation.mutateAsync({ id: draft.id });
const response = await draftCreateMutation.mutateAsync({ data }, {
onSuccess: () => {addToast(
<ToasterItem type="info">
<span>{t("message_form.success.draft_transferred")}</span>
</ToasterItem>,
);
}
});
if(router.asPath.includes("new")) {
setDraft(response.data as Message);
return;
}
const mailboxId = data.senderId;
const threadId = response.data.thread_id
// @TODO: Make something less hardcoded to improve the maintainability of the code
router.replace(`/mailbox/${mailboxId}/thread/${threadId}?has_draft=1`);
}
}
/**
* Update or create a draft message if any field to change.
*/
@@ -212,15 +244,19 @@ export const MessageForm = ({
cc: data.cc || [],
bcc: data.bcc || [],
subject: subject,
senderId: data.from!,
senderId: data.from,
parentId: parentMessage?.id,
draftBody: data.messageEditorDraft,
}
let response;
if (!draft) {
response = await draftCreateMutation.mutateAsync({
data: payload,
});
} else if (form.formState.dirtyFields.from) {
handleChangeSender(payload);
return;
} else {
response = await draftUpdateMutation.mutateAsync({
messageId: draft.id,
@@ -269,7 +305,7 @@ export const MessageForm = ({
if (draftMessage) form.setFocus("subject");
else form.setFocus("to")
}, []);
useEffect(() => {
if (draft) {
form.reset(undefined, { keepSubmitCount: true, keepDirty: false, keepValues: true, keepDefaultValues: false });
@@ -17,10 +17,10 @@ export const RhfSelect = (props: SelectProps & { name: string }) => {
render={({ field, fieldState }) => {
return (
<Select
{...field}
{...props}
aria-invalid={!!fieldState.error}
state={fieldState.error ? "error" : "default"}
onBlur={field.onBlur}
onChange={(e) => setValue(field.name, e.target.value, { shouldDirty: true })}
value={field.value}
/>
@@ -68,7 +68,8 @@
},
"success": {
"saved": "Draft saved",
"draft_deleted": "Draft deleted"
"draft_deleted": "Draft deleted",
"draft_transferred": "Draft transferred to another mailbox"
},
"confirm": {
"delete": "Are you sure you want to delete this draft? This action cannot be undone."
@@ -178,7 +179,8 @@
},
"success": {
"saved": "Brouillon sauvegardé.",
"draft_deleted": "Brouillon supprimé."
"draft_deleted": "Brouillon supprimé.",
"draft_transferred": "Brouillon transféré vers une autre boîte aux lettres."
},
"confirm": {
"delete": "Êtes-vous sûr de vouloir supprimer ce brouillon ? Cette action est irréversible."
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from "react"
import { useParams, useSearchParams } from "next/navigation"
import { useSearchParams } from "next/navigation"
import { ActionBar } from "./components/thread-action-bar"
import { ThreadMessage } from "./components/thread-message"
import { useMailboxContext } from "@/features/providers/mailbox"
@@ -13,7 +13,6 @@ type MessageWithDraftChild = Message & {
}
export const ThreadView = () => {
const params = useParams<{ mailboxId: string, threadId: string }>()
const searchParams = useSearchParams();
const toMarkAsReadQueue = useRef<string[]>([]);
const debouncedMarkAsRead = useDebounceCallback(() => {
@@ -1,4 +1,4 @@
import { createContext, PropsWithChildren, useContext, useEffect, useMemo, useState } from "react";
import { createContext, PropsWithChildren, useContext, useEffect, useMemo } from "react";
import { Mailbox, PaginatedMessageList, PaginatedThreadList, Thread, useMailboxesList, useMessagesList, useThreadsListInfinite } from "../api/gen";
import { FetchStatus, QueryStatus, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/router";
@@ -2,10 +2,13 @@ import { MainLayout } from "@/features/layouts/components/main";
import { MessageForm } from "@/features/forms/components/message-form";
import { useRouter } from "next/router";
import { useTranslation } from "react-i18next";
import { useMailboxContext } from "@/features/providers/mailbox";
import { Spinner } from "@gouvfr-lasuite/ui-kit";
const NewMessageFormPage = () => {
const { t } = useTranslation();
const router = useRouter();
const { queryStates } = useMailboxContext();
/**
* Go back to the previous page or to
@@ -19,6 +22,14 @@ const NewMessageFormPage = () => {
}
}
if (queryStates.mailboxes.isLoading) {
return (
<div className="thread-view thread-view--loading">
<Spinner />
</div>
)
}
return (
<div className="new-message-form-container">
<h1>{t("new_message_form.title")}</h1>