Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions frontend/app/listings/__tests__/newListingValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
hasFieldErrors,
upsertFieldError,
validateDescription,
validateImages,
validateListingFields,
validatePrice,
validateTitle,
} from '../newListingValidation';

describe('new listing validation', () => {
it('collects required field errors for an empty submission', () => {
expect(
validateListingFields({
title: '',
description: ' ',
price: '',
images: [],
})
).toEqual({
title: 'Title is required.',
description: 'Description is required.',
price: 'Price is required.',
images: 'At least 1 photo is required.',
});
});

it('treats trimmed valid values as valid', () => {
expect(validateTitle(' Desk lamp ')).toBeUndefined();
expect(validateDescription(' Clean, lightly used. ')).toBeUndefined();
expect(validatePrice(' 15.50 ')).toBeUndefined();
expect(validateImages(['image-1'])).toBeUndefined();
});

it('keeps invalid edits invalid until the value is actually corrected', () => {
expect(validateTitle('Hey')).toBe('Title must be at least 5 characters.');
expect(validatePrice('.')).toBe('Enter a valid non-negative price.');
});

it('rejects titles longer than 100 characters', () => {
expect(validateTitle('a'.repeat(101))).toBe('Title must be 100 characters or less.');
});

it('rejects more than 8 photos', () => {
expect(
validateImages([
'image-1',
'image-2',
'image-3',
'image-4',
'image-5',
'image-6',
'image-7',
'image-8',
'image-9',
])
).toBe('Maximum 8 photos allowed.');
});

it('removes cleared field keys so banner state reflects active errors only', () => {
const errors = validateListingFields({
title: 'Hey',
description: '',
price: '',
images: [],
});

const withoutTitle = upsertFieldError(errors, 'title', undefined);

expect(withoutTitle.title).toBeUndefined();
expect(Object.prototype.hasOwnProperty.call(withoutTitle, 'title')).toBe(false);
expect(hasFieldErrors(withoutTitle)).toBe(true);

const noErrors = upsertFieldError(
upsertFieldError(
upsertFieldError(withoutTitle, 'description', undefined),
'price',
undefined
),
'images',
undefined
);

expect(hasFieldErrors(noErrors)).toBe(false);
});
});
167 changes: 127 additions & 40 deletions frontend/app/listings/new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ import { useEntranceAnimation } from '../../hooks/useEntranceAnimation';
import OpenInAppPrompt from '../../components/OpenInAppPrompt';
import { KeyboardAwareScreen, ScreenHeader } from '../../components/ui';
import { colors, typography, borderRadius, spacing } from '../../theme/tokens';
import {
hasFieldErrors,
type FieldErrors,
upsertFieldError,
validateDescription,
validateImages,
validateListingFields,
validatePrice,
validateTitle,
} from './newListingValidation';

const categories = ['textbooks', 'electronics', 'furniture', 'tickets', 'other'] as const;
const conditions = ['new', 'used', 'refurbished'] as const;
Expand Down Expand Up @@ -58,6 +68,19 @@ function getListingActionError(error: unknown, fallbackTitle: string) {
message: rawMessage,
};
}
function RequiredLabel({ text }: { text: string }) {
return (
<Text style={styles.label}>
{text}
<Text style={styles.requiredAsterisk}> *</Text>
</Text>
);
}

function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <Text style={styles.fieldError}>{message}</Text>;
}

export default function NewListingScreen() {
const router = useRouter();
Expand All @@ -76,7 +99,10 @@ export default function NewListingScreen() {
const [images, setImages] = useState<string[]>([]);
const [hasPendingUploads, setHasPendingUploads] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
const submittingRef = useRef(false);
const hasActiveFieldErrors = hasFieldErrors(fieldErrors);

useEffect(() => {
if (!isWeb && !isSessionLoading && !isAuthenticated) {
Expand All @@ -98,6 +124,40 @@ export default function NewListingScreen() {
);
}

// After the first failed submit, keep each field's error state in sync with its current value.
const handleTitleChange = (text: string) => {
setTitle(text);
if (hasAttemptedSubmit) {
setFieldErrors((prev) => upsertFieldError(prev, 'title', validateTitle(text)));
}
};

const handleDescriptionChange = (text: string) => {
setDescription(text);
if (hasAttemptedSubmit) {
setFieldErrors((prev) => upsertFieldError(prev, 'description', validateDescription(text)));
}
};

const handlePriceChange = (text: string) => {
const filtered = text.replace(/[^0-9.]/g, '');
const parts = filtered.split('.');
if (parts.length > 2) return;
if (parts[1]?.length > 2) return;
setPrice(filtered);
if (hasAttemptedSubmit) {
setFieldErrors((prev) => upsertFieldError(prev, 'price', validatePrice(filtered)));
}
};

const handleImagesChange = (newImages: string[] | ((prev: string[]) => string[])) => {
const nextImages = typeof newImages === 'function' ? newImages(images) : newImages;
setImages(nextImages);
if (hasAttemptedSubmit) {
setFieldErrors((prev) => upsertFieldError(prev, 'images', validateImages(nextImages)));
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function onSubmit() {
if (submittingRef.current) {
return;
Expand All @@ -116,35 +176,28 @@ export default function NewListingScreen() {
return;
}

const trimmedTitle = title.trim();
const trimmedDescription = description.trim();

if (!trimmedTitle || trimmedTitle.length < 5) {
showAlert('Missing fields', 'Title must be at least 5 characters.');
if (hasPendingUploads) {
showAlert('Uploads in progress', 'Please wait for image uploads to finish.');
return;
}

if (!trimmedDescription) {
showAlert('Missing fields', 'Description is required.');
return;
}
setHasAttemptedSubmit(true);

const trimmed = price.trim();
const parsedPrice = trimmed === '' ? NaN : Number(trimmed);
if (!Number.isFinite(parsedPrice) || parsedPrice < 0) {
showAlert('Invalid price', 'Please enter a valid non-negative price in dollars.');
return;
}
const errors = validateListingFields({
title,
description,
price,
images,
});
setFieldErrors(errors);

if (hasPendingUploads) {
showAlert('Uploads in progress', 'Please wait for image uploads to finish.');
if (hasFieldErrors(errors)) {
return;
}

if (images.length < 1 || images.length > 8) {
showAlert('Invalid images', 'Please upload between 1 and 8 images.');
return;
}
const trimmedTitle = title.trim();
const trimmedDescription = description.trim();
const parsedPrice = Number(price.trim());

try {
submittingRef.current = true;
Expand Down Expand Up @@ -198,15 +251,16 @@ export default function NewListingScreen() {
/>

<View style={styles.section}>
<Text style={styles.label}>Photos</Text>
<RequiredLabel text="Photos" />
<Text style={styles.labelHint}>
Add 1–8 photos. Listings with clear photos sell faster.
</Text>
<ImageUploader
images={images}
onImagesChange={setImages}
onImagesChange={handleImagesChange}
onPendingChange={setHasPendingUploads}
/>
<FieldError message={fieldErrors.images} />
</View>

{profile === null && (
Expand All @@ -224,57 +278,56 @@ export default function NewListingScreen() {
)}

<View style={styles.section}>
<Text style={styles.label}>Title</Text>
<RequiredLabel text="Title" />
<TextInput
style={styles.input}
style={[styles.input, fieldErrors.title && styles.inputError]}
value={title}
onChangeText={setTitle}
onChangeText={handleTitleChange}
placeholder="Enter listing title"
accessibilityLabel="Listing title"
accessibilityLabel="Listing title (required)"
placeholderTextColor={colors.muted}
selectionColor={colors.primary}
cursorColor={colors.primary}
maxLength={100}
/>
<FieldError message={fieldErrors.title} />
</View>

<View style={styles.section}>
<Text style={styles.label}>Description</Text>
<RequiredLabel text="Description" />
<TextInput
style={[styles.input, styles.textArea]}
style={[styles.input, styles.textArea, fieldErrors.description && styles.inputError]}
value={description}
onChangeText={setDescription}
onChangeText={handleDescriptionChange}
placeholder="Describe your item"
placeholderTextColor={colors.muted}
selectionColor={colors.primary}
cursorColor={colors.primary}
multiline
numberOfLines={4}
accessibilityLabel="Description (required)"
/>
<FieldError message={fieldErrors.description} />
</View>

<View style={styles.section}>
<Text style={styles.label}>Price</Text>
<View style={styles.priceInputWrap}>
<RequiredLabel text="Price" />
<View style={[styles.priceInputWrap, fieldErrors.price && styles.priceInputWrapError]}>
<Text style={styles.pricePrefix}>$</Text>
<TextInput
style={[styles.input, styles.priceInput]}
value={price}
onChangeText={(text) => {
const filtered = text.replace(/[^0-9.]/g, '');
const parts = filtered.split('.');
if (parts.length > 2) return;
if (parts[1]?.length > 2) return;
setPrice(filtered);
}}
onChangeText={handlePriceChange}
placeholder="15"
placeholderTextColor={colors.muted}
selectionColor={colors.primary}
cursorColor={colors.primary}
keyboardType="decimal-pad"
accessibilityLabel="Price (required)"
/>
</View>
<Text style={styles.helperText}>Enter amount in dollars</Text>
<FieldError message={fieldErrors.price} />
{!fieldErrors.price && <Text style={styles.helperText}>Enter amount in dollars</Text>}
</View>

<View style={styles.section}>
Expand Down Expand Up @@ -327,6 +380,12 @@ export default function NewListingScreen() {
</View>
</View>

{hasAttemptedSubmit && hasActiveFieldErrors && (
<View style={styles.formErrorBanner}>
<Text style={styles.formErrorText}>Please fix the errors above before submitting.</Text>
</View>
)}

<View style={styles.buttonContainer}>
<Pressable
style={({ pressed }) => [
Expand Down Expand Up @@ -398,6 +457,10 @@ const styles = StyleSheet.create({
color: colors.textDark,
marginBottom: 4,
},
requiredAsterisk: {
color: colors.errorText,
fontWeight: '700',
},
labelHint: {
...typography.footnote,
color: colors.muted,
Expand All @@ -412,6 +475,9 @@ const styles = StyleSheet.create({
color: colors.textDark,
backgroundColor: colors.white,
},
inputError: {
borderColor: colors.errorText,
},
textArea: {
minHeight: 112,
textAlignVertical: 'top',
Expand All @@ -424,6 +490,9 @@ const styles = StyleSheet.create({
borderRadius: borderRadius.md,
backgroundColor: colors.white,
},
priceInputWrapError: {
borderColor: colors.errorText,
},
pricePrefix: {
...typography.body,
color: colors.text,
Expand All @@ -439,6 +508,24 @@ const styles = StyleSheet.create({
color: colors.muted,
marginTop: spacing.xs,
},
fieldError: {
...typography.footnote,
color: colors.errorText,
marginTop: spacing.xs,
},
formErrorBanner: {
backgroundColor: colors.errorBg,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.errorBorder,
padding: spacing.md,
marginBottom: spacing.md,
},
formErrorText: {
...typography.footnote,
color: colors.errorText,
textAlign: 'center',
},
optionsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
Expand Down
Loading
Loading