Skip to content

Commit 74bf2aa

Browse files
authored
Improve unsaved draft handling (#72)
* add unsaved draft warnings * improve unsaved draft handling * fix smooth scroll route warning * address draft warning review feedback * address dialog and location review feedback * harden draft persistence and dirty tracking * fix new chat thread draft sync * preserve new chat draft on send failure
1 parent 86081ba commit 74bf2aa

14 files changed

Lines changed: 677 additions & 32 deletions

File tree

e2e/chat.spec.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ test("chat send disables the composer while pending and appends the new message"
125125
await expect(composerInput).toBeDisabled();
126126
await expect(messageVisible).toBeVisible();
127127
await expect(composerInput).toHaveValue("");
128+
await expect
129+
.poll(async () =>
130+
page.evaluate((threadId) => {
131+
const draftEntry = Object.entries(sessionStorage).find(([key]) =>
132+
key.endsWith(`:${threadId}`)
133+
);
134+
135+
return draftEntry?.[1] ?? null;
136+
}, SEEDED_THREAD_ID)
137+
)
138+
.toBeNull();
128139
});
129140

130141
test("chat send failures preserve the draft and show inline feedback", async ({
@@ -148,6 +159,103 @@ test("chat send failures preserve the draft and show inline feedback", async ({
148159
await expect(page.getByText("Synthetic chat failure")).toBeVisible();
149160
});
150161

162+
test("chat preserves unsent drafts when switching threads", async ({
163+
page,
164+
}) => {
165+
await signIn(page, {
166+
email: DONOR_EMAIL,
167+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
168+
});
169+
170+
const composerInput = page.getByTestId("chat-composer-input");
171+
const draftMessage = `Thread switch draft ${Date.now()}`;
172+
173+
await composerInput.click();
174+
await composerInput.pressSequentially(draftMessage);
175+
await expect(composerInput).toHaveValue(draftMessage);
176+
177+
await page.getByTestId(`thread-preview-${SECOND_SEEDED_THREAD_ID}`).click();
178+
await expect(page).toHaveURL(
179+
new RegExp(`/chats/${SECOND_SEEDED_THREAD_ID}$`)
180+
);
181+
await expect(page.getByTestId("chat-composer-input")).toHaveValue("");
182+
183+
await page.getByTestId(`thread-preview-${SEEDED_THREAD_ID}`).click();
184+
await expect(page).toHaveURL(new RegExp(`/chats/${SEEDED_THREAD_ID}$`));
185+
await expect(page.getByTestId("chat-composer-input")).toHaveValue(
186+
draftMessage
187+
);
188+
});
189+
190+
test("chat restores unsent drafts after leaving and returning in the same tab", async ({
191+
page,
192+
}) => {
193+
await signIn(page, {
194+
email: DONOR_EMAIL,
195+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
196+
});
197+
198+
const draftMessage = `Route return draft ${Date.now()}`;
199+
200+
await page.getByTestId("chat-composer-input").pressSequentially(draftMessage);
201+
await expect(page.getByTestId("chat-composer-input")).toHaveValue(
202+
draftMessage
203+
);
204+
205+
page.once("dialog", async (dialog) => {
206+
expect(dialog.type()).toBe("beforeunload");
207+
await dialog.accept();
208+
});
209+
await page.goto("/chats");
210+
await expect(page.getByTestId("thread-list")).toBeVisible();
211+
212+
await page.goto(`/chats/${SEEDED_THREAD_ID}`);
213+
await expect(
214+
page.getByRole("textbox", { name: "Send a message to Avery..." })
215+
).toHaveValue(draftMessage);
216+
});
217+
218+
test("unsent chat drafts warn before closing or reloading the page", async ({
219+
page,
220+
}) => {
221+
await signIn(page, {
222+
email: DONOR_EMAIL,
223+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
224+
});
225+
226+
const composerInput = page.getByTestId("chat-composer-input");
227+
await composerInput.click();
228+
await composerInput.pressSequentially(`Unsent chat draft ${Date.now()}`);
229+
230+
const dialogPromise = page.waitForEvent("dialog");
231+
const reloadPromise = page.reload({ waitUntil: "domcontentloaded" });
232+
const dialog = await dialogPromise;
233+
expect(dialog.type()).toBe("beforeunload");
234+
await dialog.accept();
235+
await reloadPromise;
236+
});
237+
238+
test("empty chat composers reload without an unsaved draft warning", async ({
239+
page,
240+
}) => {
241+
await signIn(page, {
242+
email: DONOR_EMAIL,
243+
redirectTo: `/chats/${SEEDED_THREAD_ID}`,
244+
});
245+
246+
await expect(page.getByTestId("chat-composer-input")).toHaveValue("");
247+
248+
const dialogMessages: string[] = [];
249+
page.once("dialog", async (dialog) => {
250+
dialogMessages.push(dialog.message());
251+
await dialog.dismiss();
252+
});
253+
254+
await page.reload({ waitUntil: "domcontentloaded" });
255+
256+
expect(dialogMessages).toEqual([]);
257+
});
258+
151259
test("invalid chat thread ids redirect back to the chat index", async ({
152260
page,
153261
}) => {

e2e/listings.spec.ts

Lines changed: 96 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@ const RESIDENTIAL_LISTING_EDIT_PATH =
1515
const ALTERNATE_BUSINESS_DESCRIPTION =
1616
"A demo business listing with a slightly different description for e2e coverage.";
1717

18+
function requireHref(href: string | null) {
19+
expect(
20+
href,
21+
"Expected listing photo thumbnail to have an href"
22+
).not.toBeNull();
23+
24+
if (!href) {
25+
throw new Error("Expected listing photo thumbnail to have an href");
26+
}
27+
28+
return href;
29+
}
30+
1831
test("profile loads the seeded host account and listings", async ({ page }) => {
1932
await signIn(page, { email: HOST_EMAIL, redirectTo: "/profile" });
2033

@@ -83,7 +96,7 @@ test("listing edit saves and restores seeded business fields", async ({
8396
const listingWriteForm = page.getByTestId("listing-write-form");
8497
await expect(listingWriteForm).toBeVisible();
8598
const descriptionInput = listingWriteForm.locator("#description").first();
86-
const visibilityInput = listingWriteForm.locator("#visibility");
99+
const visibilityInput = listingWriteForm.locator("#visibility").first();
87100
const originalDescription = await descriptionInput.inputValue();
88101
const originalVisibility = await visibilityInput.inputValue();
89102
const updatedDescription =
@@ -111,7 +124,7 @@ test("listing edit saves and restores seeded business fields", async ({
111124
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
112125
updatedDescription
113126
);
114-
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
127+
await expect(listingWriteForm.locator("#visibility").first()).toHaveValue(
115128
updatedVisibility
116129
);
117130

@@ -132,11 +145,87 @@ test("listing edit saves and restores seeded business fields", async ({
132145
await expect(listingWriteForm.locator("#description").first()).toHaveValue(
133146
originalDescription
134147
);
135-
await expect(listingWriteForm.locator("#visibility")).toHaveValue(
148+
await expect(listingWriteForm.locator("#visibility").first()).toHaveValue(
136149
originalVisibility
137150
);
138151
});
139152

153+
test("dirty listing edit asks before viewing the saved listing", async ({
154+
page,
155+
}) => {
156+
await signIn(page, {
157+
email: HOST_EMAIL,
158+
redirectTo: BUSINESS_LISTING_EDIT_PATH,
159+
});
160+
161+
const listingWriteForm = page.getByTestId("listing-write-form");
162+
await expect(listingWriteForm).toBeVisible();
163+
const descriptionInput = listingWriteForm.locator("#description").first();
164+
const draftDescription = `Unsaved listing preview draft ${Date.now()}`;
165+
const viewListingButton = page.getByRole("button", { name: "View listing" });
166+
167+
await descriptionInput.fill(draftDescription);
168+
169+
await viewListingButton.click();
170+
await expect(
171+
page.getByRole("heading", { name: "Discard changes" })
172+
).toBeVisible();
173+
await expect(
174+
page.getByText(
175+
"You have unsaved changes. Are you sure you want to discard them and leave?"
176+
)
177+
).toBeVisible();
178+
await page.getByRole("button", { name: "No, go back" }).click();
179+
180+
await expect(page).toHaveURL(/\/profile\/listings\/demo-inner-west-cafe$/);
181+
await expect(descriptionInput).toHaveValue(draftDescription);
182+
183+
await viewListingButton.click();
184+
await page.getByRole("button", { name: "Yes, discard" }).click();
185+
186+
await expect(page).toHaveURL(/\/listings\/demo-inner-west-cafe$/);
187+
});
188+
189+
test("dirty new listing forms warn before closing or reloading the page", async ({
190+
page,
191+
}) => {
192+
await signIn(page, {
193+
email: HOST_EMAIL,
194+
redirectTo: "/profile/listings/new/business",
195+
});
196+
197+
await expect(page.getByTestId("listing-write-form")).toBeVisible();
198+
await page.locator("#name").fill(`Unsaved new listing ${Date.now()}`);
199+
200+
const dialogPromise = page.waitForEvent("dialog");
201+
const reloadPromise = page.reload({ waitUntil: "domcontentloaded" });
202+
const dialog = await dialogPromise;
203+
expect(dialog.type()).toBe("beforeunload");
204+
await dialog.accept();
205+
await reloadPromise;
206+
});
207+
208+
test("clean listing edit views the saved listing without asking", async ({
209+
page,
210+
}) => {
211+
await signIn(page, {
212+
email: HOST_EMAIL,
213+
redirectTo: BUSINESS_LISTING_EDIT_PATH,
214+
});
215+
216+
await expect(page.getByTestId("listing-write-form")).toBeVisible();
217+
const dialogMessages: string[] = [];
218+
page.on("dialog", async (dialog) => {
219+
dialogMessages.push(dialog.message());
220+
await dialog.dismiss();
221+
});
222+
223+
await page.getByRole("link", { name: "View listing" }).click();
224+
225+
await expect(page).toHaveURL(/\/listings\/demo-inner-west-cafe$/);
226+
expect(dialogMessages).toEqual([]);
227+
});
228+
140229
test("residential listing edit leaves avatar management on the profile page", async ({
141230
page,
142231
}) => {
@@ -159,13 +248,13 @@ test("listing photos open in a dedicated photo tab", async ({ page }) => {
159248
await expect(firstThumbnail).toBeVisible();
160249
await expect(firstThumbnail).toHaveAttribute("target", "_blank");
161250

162-
const href = await firstThumbnail.getAttribute("href");
251+
const href = requireHref(await firstThumbnail.getAttribute("href"));
163252
expect(href).toBe(
164253
"/listings/demo-marrickville-compost/photos/demo/garden.jpg"
165254
);
166255

167256
const photoPage = await page.context().newPage();
168-
await photoPage.goto(`http://127.0.0.1:3000${href}`);
257+
await photoPage.goto(new URL(href, page.url()).toString());
169258
await expect(photoPage.getByTestId("listing-photo-tab-viewer")).toBeVisible();
170259
await expect(photoPage.getByRole("navigation")).toHaveCount(0);
171260
await expect(page).toHaveURL(PUBLIC_MULTI_PHOTO_LISTING_PATH);
@@ -192,13 +281,13 @@ test("map listing photos open in a dedicated photo tab without disturbing the dr
192281
await expect(firstThumbnail).toBeVisible();
193282
await expect(firstThumbnail).toHaveAttribute("target", "_blank");
194283

195-
const href = await firstThumbnail.getAttribute("href");
284+
const href = requireHref(await firstThumbnail.getAttribute("href"));
196285
expect(href).toBe(
197286
"/listings/demo-marrickville-compost/photos/demo/garden.jpg"
198287
);
199288

200289
const photoPage = await page.context().newPage();
201-
await photoPage.goto(`http://127.0.0.1:3000${href}`);
290+
await photoPage.goto(new URL(href, page.url()).toString());
202291
await expect(photoPage.getByTestId("listing-photo-tab-viewer")).toBeVisible();
203292
await expect(page).toHaveURL(MAP_MULTI_PHOTO_LISTING_PATH);
204293
await expect(firstThumbnail).toBeVisible();

messages/de.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@
350350
},
351351
"edit": {
352352
"title": "Eintrag bearbeiten",
353-
"notFound": "Eintrag nicht gefunden"
353+
"notFound": "Eintrag nicht gefunden",
354+
"discardChangesTitle": "Änderungen verwerfen",
355+
"discardChangesBody": "Du hast ungespeicherte Änderungen. Möchtest du sie wirklich verwerfen und die Seite verlassen?",
356+
"discardChangesCancel": "Nein, zurück",
357+
"discardChangesConfirm": "Ja, verwerfen"
354358
},
355359
"form": {
356360
"basics": "Grundlagen",

messages/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@
350350
},
351351
"edit": {
352352
"title": "Edit listing",
353-
"notFound": "Listing not found"
353+
"notFound": "Listing not found",
354+
"discardChangesTitle": "Discard changes",
355+
"discardChangesBody": "You have unsaved changes. Are you sure you want to discard them and leave?",
356+
"discardChangesCancel": "No, go back",
357+
"discardChangesConfirm": "Yes, discard"
354358
},
355359
"form": {
356360
"basics": "Basics",

messages/es.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@
350350
},
351351
"edit": {
352352
"title": "Editar anuncio",
353-
"notFound": "Anuncio no encontrado"
353+
"notFound": "Anuncio no encontrado",
354+
"discardChangesTitle": "Descartar cambios",
355+
"discardChangesBody": "Tienes cambios sin guardar. ¿Seguro que quieres descartarlos y salir?",
356+
"discardChangesCancel": "No, volver",
357+
"discardChangesConfirm": "Sí, descartar"
354358
},
355359
"form": {
356360
"basics": "Datos básicos",

messages/fr.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@
350350
},
351351
"edit": {
352352
"title": "Modifier l’annonce",
353-
"notFound": "Annonce introuvable"
353+
"notFound": "Annonce introuvable",
354+
"discardChangesTitle": "Ignorer les modifications",
355+
"discardChangesBody": "Vous avez des modifications non enregistrées. Voulez-vous vraiment les ignorer et quitter la page ?",
356+
"discardChangesCancel": "Non, revenir",
357+
"discardChangesConfirm": "Oui, ignorer"
354358
},
355359
"form": {
356360
"basics": "Informations de base",

messages/pt-BR.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,11 @@
350350
},
351351
"edit": {
352352
"title": "Editar anúncio",
353-
"notFound": "Anúncio não encontrado"
353+
"notFound": "Anúncio não encontrado",
354+
"discardChangesTitle": "Descartar alterações",
355+
"discardChangesBody": "Você tem alterações não salvas. Tem certeza de que quer descartá-las e sair?",
356+
"discardChangesCancel": "Não, voltar",
357+
"discardChangesConfirm": "Sim, descartar"
354358
},
355359
"form": {
356360
"basics": "Informações básicas",

src/app/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export default async function RootLayout({
7979
const inlineFontFaces = hostedFontFaces.trim();
8080

8181
return (
82-
<html lang={locale}>
82+
<html lang={locale} data-scroll-behavior="smooth">
8383
<body>
8484
{inlineFontFaces ? <style>{inlineFontFaces}</style> : null}
8585
<AuthHashCompletion />

src/components/ButtonToDialog/ButtonToDialog.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "react";
1616
import { useTranslations } from "next-intl";
1717
import type { FormSubmitHandler } from "@/types/events";
18+
import type { ButtonVariant } from "@/components/Button";
1819

1920
const DialogContent = styled(Dialog.Content)`
2021
background: ${theme.colors.background.top};
@@ -66,8 +67,11 @@ const DialogOverlay = styled(Dialog.Overlay)`
6667
z-index: 3;
6768
`;
6869

70+
type DialogVariant = Extract<ButtonVariant, "primary" | "secondary" | "danger">;
71+
6972
type ButtonToDialogProps = {
70-
variant?: "primary" | "secondary" | "danger";
73+
variant?: DialogVariant;
74+
triggerVariant?: ButtonVariant;
7175
size?: "massive" | "large" | "normal" | "small";
7276
initialButtonText: ReactNode;
7377
dialogTitle: ReactNode;
@@ -83,6 +87,7 @@ type ButtonToDialogProps = {
8387

8488
function ButtonToDialog({
8589
variant = "danger",
90+
triggerVariant,
8691
size,
8792
initialButtonText,
8893
dialogTitle,
@@ -138,7 +143,7 @@ function ButtonToDialog({
138143
<Dialog.Trigger asChild>
139144
<Button
140145
width="contained"
141-
variant={variant}
146+
variant={triggerVariant ?? variant}
142147
size={size}
143148
disabled={disabled || isPending}
144149
>

0 commit comments

Comments
 (0)