Skip to content

Commit ae1f72a

Browse files
authored
modernise form submit typing and polish listing edit (#61)
* modernise form submit typing * groom form flow rendering * address form polish review * use react submit event type * document submit event typing
1 parent 2ea3046 commit ae1f72a

13 files changed

Lines changed: 162 additions & 80 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ These instructions apply to the whole repository.
1111
- Prefer Server Components by default. Only add `"use client"` when the component needs browser-only APIs, state/effects, event handlers, or client-only libraries.
1212
- When touching JS/JSX components, convert them to TS/TSX when it is reasonable and scoped to the change.
1313
- Keep shared presentational components server-safe where possible. For translated labels or suffixes, prefer passing translated text from the caller instead of adding `useTranslations` to a shared component.
14+
- For React form submit handlers, use the shared `FormSubmitEvent` / `FormSubmitHandler` types from `src/types/events.ts`, which wrap React 19's `SubmitEvent`. Avoid deprecated `FormEvent`, `FormEventHandler`, and `React.FormEvent`; keep `ChangeEvent` for input/select/textarea change handlers.
1415
- In MDX prose, if an inline component inside a Markdown list item is formatted onto multiple lines and changes rendered spacing, use a targeted `{/* prettier-ignore */}` before that list rather than disabling formatting for the whole file.
1516

1617
## Testing

e2e/listings.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { expect, test } from "@playwright/test";
22
import {
3+
DONOR_EMAIL,
34
HOST_EMAIL,
45
PROFILE_RENDER_TIMEOUT_MS,
56
delayServerActionRequests,
67
signIn,
78
} from "./helpers";
89

910
const BUSINESS_LISTING_EDIT_PATH = "/profile/listings/demo-inner-west-cafe";
11+
const RESIDENTIAL_LISTING_EDIT_PATH =
12+
"/profile/listings/demo-newtown-worm-farm";
1013
const ALTERNATE_BUSINESS_DESCRIPTION =
1114
"A demo business listing with a slightly different description for e2e coverage.";
1215

@@ -91,6 +94,8 @@ test("listing edit saves and restores seeded business fields", async ({
9194
await delayServerActionRequests(page);
9295

9396
const submitButton = page.getByTestId("listing-write-submit");
97+
await expect(submitButton).toHaveAttribute("data-button-width", "full");
98+
9499
const updateNavigation = page.waitForURL(
95100
/\/listings\/demo-inner-west-cafe\?status=updated$/
96101
);
@@ -115,3 +120,18 @@ test("listing edit saves and restores seeded business fields", async ({
115120
await expect(page.locator("#description")).toHaveValue(originalDescription);
116121
await expect(page.locator("#visibility")).toHaveValue(originalVisibility);
117122
});
123+
124+
test("residential listing edit leaves avatar management on the profile page", async ({
125+
page,
126+
}) => {
127+
await signIn(page, {
128+
email: DONOR_EMAIL,
129+
redirectTo: RESIDENTIAL_LISTING_EDIT_PATH,
130+
});
131+
132+
await expect(page.getByTestId("listing-write-form")).toBeVisible();
133+
await expect(page.getByTestId("avatar-upload-avatars")).toHaveCount(0);
134+
await expect(page.getByTestId("avatar-upload-listing_avatars")).toHaveCount(
135+
0
136+
);
137+
});

e2e/profile.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ test("profile account actions show pending feedback and update the read view", a
88
await delayProfileActionRequests(page);
99

1010
const profileAccountSettings = page.getByTestId("profile-account-settings");
11+
await expect(page.getByTestId("avatar-upload-avatars")).toBeVisible();
1112
await expect(profileAccountSettings).toBeVisible();
1213
await expect(profileAccountSettings).toHaveAttribute("data-hydrated", "true");
1314

src/app/(forms)/profile/listings/[slug]/page.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export const metadata: Metadata = {
1818
export default async function EditListingPage({
1919
params,
2020
}: EditListingPageProps) {
21-
const t = await getTranslations();
21+
const supabasePromise = createClient();
2222
const { slug } = await params;
23-
const supabase = await createClient();
23+
const supabase = await supabasePromise;
2424
const {
2525
data: { user },
2626
} = await supabase.auth.getUser();
@@ -29,18 +29,55 @@ export default async function EditListingPage({
2929
redirect(`/sign-in?redirect_to=/profile/listings/${slug}`);
3030
}
3131

32-
const { data: profile } = await supabase
33-
.from("profiles")
34-
.select()
35-
.eq("id", user.id)
36-
.single();
37-
38-
const { data: listing } = await supabase
39-
.from("listings_private_data")
40-
.select()
41-
.eq("owner_id", user.id)
42-
.match({ slug })
43-
.single();
32+
const [
33+
t,
34+
{ data: profile, error: profileError },
35+
{ data: listing, error: listingError },
36+
] = await Promise.all([
37+
getTranslations(),
38+
supabase
39+
.from("profiles")
40+
.select("first_name, avatar, is_admin")
41+
.eq("id", user.id)
42+
.single(),
43+
supabase
44+
.from("listings_private_data")
45+
.select(
46+
`
47+
id,
48+
avatar,
49+
name,
50+
description,
51+
coordinates,
52+
country_code,
53+
area_name,
54+
accepted_items,
55+
rejected_items,
56+
photos,
57+
links,
58+
type,
59+
slug,
60+
is_stub,
61+
visibility,
62+
owner_has_multiple_non_residential_listings
63+
`
64+
)
65+
.eq("owner_id", user.id)
66+
.match({ slug })
67+
.maybeSingle(),
68+
]);
69+
70+
if (profileError) {
71+
throw new Error(
72+
`Failed to fetch profile for user "${user.id}": ${profileError.message}`
73+
);
74+
}
75+
76+
if (listingError) {
77+
throw new Error(
78+
`Failed to fetch listing for slug "${slug}" and user "${user.id}": ${listingError.message}`
79+
);
80+
}
4481

4582
if (!listing) {
4683
return <div>{t("Listings.edit.notFound")}</div>;

src/components/AvatarUploadView/AvatarUploadView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ function AvatarUploadView({
112112
};
113113

114114
return (
115-
<Fieldset>
115+
<Fieldset data-testid={`avatar-upload-${bucket}`}>
116116
<StyledField>
117117
{/* Hidden file input */}
118118
<input

src/components/Button/Button.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
420420
tabIndex={isDisabled ? -1 : tabIndex}
421421
aria-disabled={isDisabled || undefined}
422422
aria-busy={isLoading || undefined}
423+
data-button-width={allProps.width ?? "contained"}
423424
onClick={handleLinkClick}
424425
{...linkProps}
425426
rel={rel}
@@ -445,6 +446,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
445446
tabIndex={isDisabled ? -1 : tabIndex}
446447
aria-disabled={isDisabled || undefined}
447448
aria-busy={isLoading || undefined}
449+
data-button-width={allProps.width ?? "contained"}
448450
onClick={onClick}
449451
{...buttonProps}
450452
>

src/components/ButtonToDialog/ButtonToDialog.tsx

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import Button from "@/components/Button";
66
import SubmitButton from "@/components/SubmitButton";
77

88
import { styled } from "next-yak";
9-
import { useState, type ReactNode } from "react";
9+
import { useState, type FormHTMLAttributes, type ReactNode } from "react";
1010
import { useTranslations } from "next-intl";
11+
import type { FormSubmitHandler } from "@/types/events";
1112

1213
const DialogContent = styled(Dialog.Content)`
1314
background: ${theme.colors.background.top};
@@ -68,9 +69,9 @@ type ButtonToDialogProps = {
6869
confirmButtonText?: ReactNode;
6970
confirmLoadingText?: string;
7071
cancelButtonText?: ReactNode;
71-
action?: React.FormHTMLAttributes<HTMLFormElement>["action"];
72+
action?: FormHTMLAttributes<HTMLFormElement>["action"];
7273
disabled?: boolean;
73-
onSubmit?: React.FormEventHandler<HTMLFormElement>;
74+
onSubmit?: FormSubmitHandler;
7475
pending?: boolean;
7576
};
7677

@@ -97,22 +98,21 @@ function ButtonToDialog({
9798
const resolvedCancelButtonText = cancelButtonText || t("Actions.noCancel");
9899
const isPending = pending || isSubmitting;
99100

100-
const handleSubmit: React.FormEventHandler<HTMLFormElement> | undefined =
101-
onSubmit
102-
? async (event) => {
103-
if (isPending) {
104-
event.preventDefault();
105-
return;
106-
}
101+
const handleSubmit: FormSubmitHandler | undefined = onSubmit
102+
? async (event) => {
103+
if (isPending) {
104+
event.preventDefault();
105+
return;
106+
}
107107

108-
setIsSubmitting(true);
109-
try {
110-
await onSubmit(event);
111-
} finally {
112-
setIsSubmitting(false);
113-
}
108+
setIsSubmitting(true);
109+
try {
110+
await onSubmit(event);
111+
} finally {
112+
setIsSubmitting(false);
114113
}
115-
: undefined;
114+
}
115+
: undefined;
116116

117117
return (
118118
<Dialog.Root>

src/components/ChatComposer/ChatComposer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import IconButton from "@/components/IconButton";
66
import { styled } from "next-yak";
77
import FormMessage from "@/components/FormMessage";
88
import { useTranslations } from "next-intl";
9+
import type { FormSubmitHandler } from "@/types/events";
910

1011
const ChatComposerForm = styled.div`
1112
display: flex;
@@ -33,7 +34,7 @@ const StyledIconButton = styled(IconButton)`
3334
const TextareaComponent = Textarea as React.ComponentType<any>;
3435

3536
type ChatComposerProps = {
36-
onSubmit: React.FormEventHandler<HTMLFormElement>;
37+
onSubmit: FormSubmitHandler;
3738
message: string;
3839
handleMessageChange: React.ChangeEventHandler<HTMLTextAreaElement>;
3940
error?: string | null;

src/components/ChatWindow/ChatWindow.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
ChatThreadView,
2929
} from "@/types/chat";
3030
import type { DemoListing } from "@/types/listing";
31+
import type { FormSubmitEvent } from "@/types/events";
3132

3233
type ChatWindowProps = {
3334
isDrawer?: boolean;
@@ -37,6 +38,8 @@ type ChatWindowProps = {
3738
isDemo?: boolean;
3839
};
3940

41+
const DIRECTIONS_FOR_DEMO = ["sent", "received"] as const;
42+
4043
const StyledChatWindow = styled.div`
4144
height: 100%;
4245
flex: 1;
@@ -198,9 +201,10 @@ const ChatWindow = memo(function ChatWindow({
198201

199202
const { readAt, readMessageIds } = result.data;
200203

204+
const readMessageIdsSet = new Set(readMessageIds);
201205
setMessages((previousMessages) =>
202206
previousMessages.map((chatMessage) =>
203-
readMessageIds.includes(chatMessage.id)
207+
readMessageIdsSet.has(chatMessage.id)
204208
? { ...chatMessage, read_at: readAt }
205209
: chatMessage
206210
)
@@ -224,7 +228,7 @@ const ChatWindow = memo(function ChatWindow({
224228
user?.id,
225229
]);
226230

227-
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
231+
async function handleSubmit(event: FormSubmitEvent) {
228232
event.preventDefault();
229233

230234
const messageToSend = message.trim();
@@ -299,7 +303,6 @@ const ChatWindow = memo(function ChatWindow({
299303
setMessage(event.target.value);
300304
};
301305

302-
const directionsForDemo = ["sent", "received"] as const;
303306
const role = isDemo
304307
? "initiator"
305308
: existingThread
@@ -364,7 +367,7 @@ const ChatWindow = memo(function ChatWindow({
364367
<ChatMessage
365368
direction={
366369
isDemo
367-
? directionsForDemo[index % 2]
370+
? DIRECTIONS_FOR_DEMO[index % 2]
368371
: chatMessage.sender_id === user?.id
369372
? "sent"
370373
: "received"

src/components/ListingTypeChooser.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import SubmitButton from "@/components/SubmitButton";
66
import Form from "@/components/Form";
77
import RadioGroup from "@/components/RadioGroup";
88
import Radio from "@/components/Radio";
9+
import type { FormSubmitEvent } from "@/types/events";
910

1011
type ListingTypeOption = {
1112
key: string;
@@ -49,7 +50,7 @@ export default function ListingTypeChooser({
4950
return "/profile/listings/new?type=host";
5051
}, [mode, selectedOption]);
5152

52-
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
53+
function handleSubmit(event: FormSubmitEvent) {
5354
event.preventDefault();
5455

5556
if (!nextHref) return;

0 commit comments

Comments
 (0)