Skip to content

fix(web): unify all forms on login's inline-error validation#35

Merged
jeffgicharu merged 2 commits into
mainfrom
fix/form-validation-consistency
May 19, 2026
Merged

fix(web): unify all forms on login's inline-error validation#35
jeffgicharu merged 2 commits into
mainfrom
fix/form-validation-consistency

Conversation

@jeffgicharu
Copy link
Copy Markdown
Owner

Why

Form validation was inconsistent: the login page shows custom inline error messages under each field, while Add Contractor (and other forms) fell back to the browser's native HTML5 validation bubbles. A visitor who hits an empty Add Contractor submit sees a native browser tooltip — a different, lower-quality UX than the rest of the app.

What

  • Added noValidate to every <form> in apps/web/src (12 forms). This is the definitive guarantee that no native validation bubble can appear anywhere.
  • Every form that previously relied on native required/type/min now validates client-side before the network call and renders inline field errors in the exact login style — Input's error prop, or a matching <p className="mt-1.5 text-[13px] text-error-600"> + border-error-500 for <select>/<textarea>/file inputs.
  • Reuses the shared Zod schemas so messages are consistent app-wide; explicit required checks where no schema maps.
  • The static Contact form was a no-op submit; it's now a controlled form with the same inline UX and a success banner.

Files: contractors/new, contractors/[id]/edit, settings, portal/profile, portal/invoices/new, (static)/contact, invite/[token], login (noValidate only), components/{time-entries,engagements,offboarding,documents}.

Verification

  • grep confirms: 0 forms without noValidate, 0 leftover required JSX attributes.
  • tsc -p apps/web/tsconfig.json clean; next lint reports 0 errors (only pre-existing warnings in untouched files); pnpm --filter @contractor-os/web test → 32/32 pass.
  • Layout, copy, and submit/network logic unchanged — consistency fix, not a redesign.
  • Live before/after of the Add Contractor empty-submit (native bubble → inline errors) will be included in the final verification report.

Every form in apps/web now uses the same custom inline-error pattern the
login page uses, and no native HTML5 validation bubble can appear
anywhere in the app.

- `noValidate` added to every <form> (the definitive guarantee against
  native bubbles), including login for consistency.
- Forms that relied on native `required` (Add Contractor — the reported
  offender — plus Edit Contractor, Settings, Portal Profile, Portal
  Invoice, Time Entry, Engagement, Offboarding, Upload, Contact) now
  validate client-side before the network call and surface inline field
  errors via the shared Input `error` prop / a matching inline <p> for
  selects, textareas and file inputs.
- Reuses the shared Zod schemas (createContractorSchema,
  createInvoiceSchema, createTimeEntrySchema, createEngagementSchema,
  initiateOffboardingSchema, uploadDocumentSchema,
  updateOrganizationSettingsSchema, updateContractorSchema) so messages
  match the rest of the app; explicit required checks where no schema maps.
- The static Contact form became controlled with the same UX + a success
  banner instead of a no-op submit.

Layout, copy, and submit/network logic unchanged — this is a consistency
fix, not a redesign. tsc + next lint clean; web component tests pass (32).
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request standardizes form validation across the application by integrating Zod schemas and implementing field-level error handling for contractor management, organization settings, invoice creation, and user profiles. Feedback focuses on improving robustness and consistency, specifically by addressing a potential RangeError during date parsing in the document upload modal. Additionally, the reviewer recommended replacing raw elements with the shared Input component in several pages to ensure UI consistency and suggested providing more explicit feedback for invalid inputs in the organization settings to improve the user experience.

Comment on lines +35 to +37
expiresAt: expiresAt
? new Date(expiresAt).toISOString()
: undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Directly calling .toISOString() on a new Date() object created from user input can throw a RangeError if the date string is invalid. It's safer to verify the date is valid before calling .toISOString().

Suggested change
expiresAt: expiresAt
? new Date(expiresAt).toISOString()
: undefined,
expiresAt: expiresAt && !isNaN(new Date(expiresAt).getTime())
? new Date(expiresAt).toISOString()
: undefined,

Comment on lines 90 to +93
if (parsedDays.length === 0) {
setError('Please enter at least one valid reminder day (1–90)');
setIsSaving(false);
fieldErrors['reminderDays'] =
'Enter at least one valid reminder day (1–90)';
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current validation logic for reminderDays (lines 85-88) silently filters out invalid values such as non-numeric strings or numbers outside the 1-90 range. This can lead to a confusing user experience where the user's input is partially ignored without any feedback. It would be better to validate that every comma-separated value is valid and show an error message if any part of the input fails validation.

Comment on lines +202 to +210
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm text-slate-900 shadow-xs focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 disabled:bg-slate-50 disabled:text-slate-500 ${
errors['name'] ? 'border-error-500' : 'border-slate-200'
}`}
/>
{errors['name'] && (
<p className="mt-1.5 text-[13px] text-error-600">
{errors['name']}
</p>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This form uses a raw <input> element and manual error rendering, which is inconsistent with other forms in this PR that use the shared Input component (e.g., in the contractor edit page). Using the Input component would reduce code duplication and ensure a consistent UI/UX across the application. Please consider refactoring this and other similar instances in this file.

Comment on lines +141 to +152
className={`mt-1 block w-full rounded-lg border px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500 ${
errors['engagementId']
? 'border-error-500'
: 'border-slate-200'
}`}
/>
</label>
{errors['engagementId'] && (
<p className="mt-1.5 text-[13px] text-error-600">
{errors['engagementId']}
</p>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This page contains multiple instances of raw <input> elements with duplicated Tailwind classes and manual error message logic. For better maintainability and consistency with the rest of the application, these should be replaced with the shared Input component from @/components/ui/input.

The unified-validation change added inline error branches to
engagement-form (the one form in the coverage include set), dropping
branch coverage below the 89% ratchet. Add tests for the new
description-too-long and invalid-currency inline errors — coverage back
to 92%, 34/34 pass.
@jeffgicharu jeffgicharu merged commit d913381 into main May 19, 2026
19 checks passed
@jeffgicharu jeffgicharu deleted the fix/form-validation-consistency branch May 19, 2026 01:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant