Skip to content

Commit eb88932

Browse files
committed
feat(mobile/submissions): red inline errors and gold warning toast
Field errors below "Mosque name" and "Location" now render in the danger red (the CVA tone merge was previously winning with the ink base class, so they showed black). The invalid-submit toast is reclassified from the dark error red to the gold warning tone — still clearly signals "heads up" but matches the warning-style intent of a form correction rather than a hard failure. Submit uses TanStack Form's revalidateLogic so the button stays enabled, validation fires on first submit, then re-validates on every change so fixes clear immediately.
1 parent f17660a commit eb88932

5 files changed

Lines changed: 132 additions & 39 deletions

File tree

apps/mobile/features/submissions/components/submission-edit-screen.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useForm, useStore } from "@tanstack/react-form";
1+
import { revalidateLogic, useForm, useStore } from "@tanstack/react-form";
22
import { router, useLocalSearchParams } from "expo-router";
33
import { StatusBar } from "expo-status-bar";
44
import { useEffect, useState } from "react";
@@ -8,11 +8,13 @@ import { Button } from "@/components/ui/button";
88
import { useAppDialog } from "@/components/ui/dialog";
99
import { IconButton } from "@/components/ui/icon-button";
1010
import { Text } from "@/components/ui/text";
11+
import { useAppToast } from "@/components/ui/toast";
1112
import {
1213
useDeleteMySubmission,
1314
useMySubmission,
1415
useUpdateMySubmission,
1516
} from "../hooks/use-submissions";
17+
import { collectInvalidLabels } from "../lib/collect-invalid-labels";
1618
import {
1719
EMPTY_SUBMISSION,
1820
type MosqueSubmissionInput,
@@ -27,10 +29,26 @@ export function SubmissionEditScreen() {
2729
const del = useDeleteMySubmission();
2830
const [errorMessage, setErrorMessage] = useState<string | null>(null);
2931
const dialog = useAppDialog();
32+
const toast = useAppToast();
3033

3134
const form = useForm({
3235
defaultValues: EMPTY_SUBMISSION,
33-
validators: { onChange: mosqueSubmissionSchema },
36+
validationLogic: revalidateLogic({
37+
mode: "submit",
38+
modeAfterSubmission: "change",
39+
}),
40+
validators: { onDynamic: mosqueSubmissionSchema },
41+
onSubmitInvalid: ({ formApi }) => {
42+
const missing = collectInvalidLabels(formApi.state.fieldMeta);
43+
toast.show({
44+
title: "Missing info",
45+
body:
46+
missing.length > 0
47+
? `Please fix: ${missing.join(", ")}`
48+
: "Please fix the highlighted fields.",
49+
tone: "warning",
50+
});
51+
},
3452
onSubmit: async ({ value }) => {
3553
if (!id) return;
3654
try {
@@ -73,7 +91,6 @@ export function SubmissionEditScreen() {
7391
}, [submission]);
7492

7593
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
76-
const canSubmit = useStore(form.store, (s) => s.canSubmit);
7794

7895
const readOnly = submission && submission.status !== "pending";
7996

@@ -167,7 +184,7 @@ export function SubmissionEditScreen() {
167184
<Button
168185
label={isSubmitting ? "Saving…" : "Save changes"}
169186
onPress={() => form.handleSubmit()}
170-
disabled={!canSubmit || isSubmitting}
187+
disabled={isSubmitting}
171188
/>
172189
<Button
173190
label="Withdraw submission"
@@ -181,6 +198,7 @@ export function SubmissionEditScreen() {
181198
)}
182199

183200
{dialog.element}
201+
{toast.element}
184202
</View>
185203
);
186204
}

apps/mobile/features/submissions/components/submission-form-fields.tsx

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function SubmissionFormFields({ form, isSubmitting }: Props) {
5757
editable={!isSubmitting}
5858
/>
5959
{fieldError(field.state.meta.errors) ? (
60-
<Text variant="caption" className="text-[#b04a3a]">
60+
<Text variant="caption" tone="danger">
6161
{fieldError(field.state.meta.errors)}
6262
</Text>
6363
) : null}
@@ -80,20 +80,31 @@ export function SubmissionFormFields({ form, isSubmitting }: Props) {
8080
)}
8181
</form.Field>
8282

83-
<View className="gap-s-2">
84-
<form.Subscribe selector={(s) => [s.values.lat, s.values.lng] as const}>
85-
{([lat, lng]) => (
86-
<LocationPicker
87-
lat={lat}
88-
lng={lng}
89-
onChange={(newLat, newLng) => {
90-
form.setFieldValue("lat", newLat);
91-
form.setFieldValue("lng", newLng);
92-
}}
93-
/>
94-
)}
95-
</form.Subscribe>
96-
</View>
83+
<form.Field name="lat">
84+
{(latField) => (
85+
<View className="gap-s-2">
86+
<form.Subscribe
87+
selector={(s) => [s.values.lat, s.values.lng] as const}
88+
>
89+
{([lat, lng]) => (
90+
<LocationPicker
91+
lat={lat}
92+
lng={lng}
93+
onChange={(newLat, newLng) => {
94+
form.setFieldValue("lat", newLat);
95+
form.setFieldValue("lng", newLng);
96+
}}
97+
/>
98+
)}
99+
</form.Subscribe>
100+
{fieldError(latField.state.meta.errors) ? (
101+
<Text variant="caption" tone="danger">
102+
{fieldError(latField.state.meta.errors)}
103+
</Text>
104+
) : null}
105+
</View>
106+
)}
107+
</form.Field>
97108

98109
<form.Field name="area">
99110
{(field) => (

apps/mobile/features/submissions/components/submit-mosque-screen.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useForm, useStore } from "@tanstack/react-form";
1+
import { revalidateLogic, useForm, useStore } from "@tanstack/react-form";
22
import { router } from "expo-router";
33
import { StatusBar } from "expo-status-bar";
44
import { useState } from "react";
@@ -8,7 +8,9 @@ import { Button } from "@/components/ui/button";
88
import { useAppDialog } from "@/components/ui/dialog";
99
import { IconButton } from "@/components/ui/icon-button";
1010
import { Text } from "@/components/ui/text";
11+
import { useAppToast } from "@/components/ui/toast";
1112
import { useSubmitMosque } from "../hooks/use-submissions";
13+
import { collectInvalidLabels } from "../lib/collect-invalid-labels";
1214
import {
1315
EMPTY_SUBMISSION,
1416
type MosqueSubmissionInput,
@@ -20,10 +22,28 @@ export function SubmitMosqueScreen() {
2022
const submit = useSubmitMosque();
2123
const [errorMessage, setErrorMessage] = useState<string | null>(null);
2224
const dialog = useAppDialog();
25+
const toast = useAppToast();
2326

2427
const form = useForm({
2528
defaultValues: EMPTY_SUBMISSION,
26-
validators: { onChange: mosqueSubmissionSchema },
29+
// Don't run validation until the user taps Submit; once they have,
30+
// validate on every change so errors clear as they fix each field.
31+
validationLogic: revalidateLogic({
32+
mode: "submit",
33+
modeAfterSubmission: "change",
34+
}),
35+
validators: { onDynamic: mosqueSubmissionSchema },
36+
onSubmitInvalid: ({ formApi }) => {
37+
const missing = collectInvalidLabels(formApi.state.fieldMeta);
38+
toast.show({
39+
title: "Missing info",
40+
body:
41+
missing.length > 0
42+
? `Please fix: ${missing.join(", ")}`
43+
: "Please fix the highlighted fields.",
44+
tone: "warning",
45+
});
46+
},
2747
onSubmit: async ({ value }) => {
2848
try {
2949
await submit.mutateAsync(value as MosqueSubmissionInput);
@@ -46,7 +66,6 @@ export function SubmitMosqueScreen() {
4666
});
4767

4868
const isSubmitting = useStore(form.store, (s) => s.isSubmitting);
49-
const canSubmit = useStore(form.store, (s) => s.canSubmit);
5069

5170
return (
5271
<View className="flex-1 bg-cream">
@@ -76,7 +95,8 @@ export function SubmitMosqueScreen() {
7695
>
7796
<Text variant="body" tone="muted" className="mb-s-4">
7897
Fill in what you know — the admin team reviews every submission before
79-
it goes live on the map. You can edit anything until it's approved.
98+
it goes live on the map. You can edit anything until it&apos;s
99+
approved.
80100
</Text>
81101

82102
{errorMessage ? (
@@ -93,12 +113,13 @@ export function SubmitMosqueScreen() {
93113
<Button
94114
label={isSubmitting ? "Submitting…" : "Submit for review"}
95115
onPress={() => form.handleSubmit()}
96-
disabled={!canSubmit || isSubmitting}
116+
disabled={isSubmitting}
97117
/>
98118
</View>
99119
</ScrollView>
100120

101121
{dialog.element}
122+
{toast.element}
102123
</View>
103124
);
104125
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Map raw field names to the user-facing labels shown in the validation toast.
3+
* Only required fields belong here — optional fields should never produce
4+
* errors that need explaining to the user.
5+
*/
6+
const FIELD_LABELS: Record<string, string> = {
7+
name: "Mosque name",
8+
lat: "Location",
9+
lng: "Location",
10+
city: "City",
11+
};
12+
13+
type FieldMetaLike = { errors?: readonly unknown[] } | undefined;
14+
15+
/**
16+
* Walks a TanStack Form fieldMeta record and returns the user-facing labels
17+
* of fields that currently have validation errors. Duplicates are de-duped
18+
* (e.g. lat + lng both erroring maps to one "Location" entry).
19+
*
20+
* TanStack Form's fieldMeta values are typed as `AnyFieldMeta | undefined`
21+
* because a field may not yet have been registered; we treat those as no
22+
* errors via optional chaining.
23+
*/
24+
export function collectInvalidLabels(
25+
fieldMeta: Record<string, FieldMetaLike> | undefined,
26+
): string[] {
27+
if (!fieldMeta) return [];
28+
const labels = new Set<string>();
29+
for (const [field, meta] of Object.entries(fieldMeta)) {
30+
const errorCount = meta?.errors?.length ?? 0;
31+
if (errorCount === 0) continue;
32+
const label = FIELD_LABELS[field];
33+
if (label) labels.add(label);
34+
}
35+
return Array.from(labels);
36+
}

apps/mobile/features/submissions/lib/schemas.ts

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,28 @@ export const TAG_OPTIONS = [
1919
"Madrasa",
2020
] as const;
2121

22-
export const mosqueSubmissionSchema = z.object({
23-
name: z.string().min(1, "Name is required").max(200),
24-
subtitle: z.string().max(200).nullable(),
25-
about: z.string().max(2000).nullable(),
26-
address: z.string().max(300).nullable(),
27-
street: z.string().max(200).nullable(),
28-
area: z.string().max(100).nullable(),
29-
city: z.string().min(1).max(100),
30-
lat: z.number().min(-90).max(90),
31-
lng: z.number().min(-180).max(180),
32-
open: z.boolean(),
33-
tags: z.array(z.string()),
34-
facilities: z.array(z.string()),
35-
photos: z.array(z.string().url()),
36-
});
22+
export const mosqueSubmissionSchema = z
23+
.object({
24+
name: z.string().min(1, "Name is required").max(200),
25+
subtitle: z.string().max(200).nullable(),
26+
about: z.string().max(2000).nullable(),
27+
address: z.string().max(300).nullable(),
28+
street: z.string().max(200).nullable(),
29+
area: z.string().max(100).nullable(),
30+
city: z.string().min(1).max(100),
31+
lat: z.number().min(-90).max(90),
32+
lng: z.number().min(-180).max(180),
33+
open: z.boolean(),
34+
tags: z.array(z.string()),
35+
facilities: z.array(z.string()),
36+
photos: z.array(z.string().url()),
37+
})
38+
// The empty form starts at (0,0) — a valid coordinate in the ocean but
39+
// never a valid mosque. Require the user to pick a real location.
40+
.refine((v) => !(v.lat === 0 && v.lng === 0), {
41+
message: "Pick the mosque's location on the map",
42+
path: ["lat"],
43+
});
3744

3845
export type MosqueSubmissionInput = z.infer<typeof mosqueSubmissionSchema>;
3946

0 commit comments

Comments
 (0)