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
111 changes: 111 additions & 0 deletions docs/form-validation-framework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Shared form validation framework

Closes #183.

Before this, each dashboard form (asset registration, compliance/whitelist
actions, minting) rolled its own validation and its own error markup —
one global banner in some forms, an ad-hoc field message in others,
different icons, different wording for "this field is required." The
shared framework in `src/features/forms/validation/` gives every form the
same building blocks so validation logic and error display look and behave
the same way everywhere.

It does **not** replace the existing per-domain validators
(`assetCreationRequest.ts`, `whitelist.ts`, `mintRequest.ts`) — those still
own the business rules for their domain (ticker format, duplicate checks,
compliance guards, etc). This framework standardizes how *any* form —
including those — tracks and displays field errors.

## What's in the module

| File | Purpose |
| --- | --- |
| `types.ts` | `FieldErrors`, `ValidationRule` — the shared shapes. |
| `rules.ts` | Composable rule builders (`required`, `minLength`, `maxLength`, `pattern`, `numberInRange`, `notIn`, `oneOf`) plus `validateField` / `validateForm` runners. |
| `useFormErrors.ts` | React hook that holds a form's `FieldErrors` state and exposes `setFieldError`, `clearFieldError`, `clearAll`, `errorFor`, `hasErrors`. |
| `FormFieldError.tsx` | Renders a single field's error message under its input (icon + red text, `role="alert"`). Renders nothing when there's no message. |
| `FormError.tsx` | Renders a form-level banner for errors that aren't tied to one field (e.g. a submit-time or async check failure). |

Everything is re-exported from `src/features/forms/validation/index.ts`.

## Using it in a new form

```tsx
import { useFormErrors, FormFieldError, FormError, validateForm, required, minLength, pattern } from '@/features/forms/validation';

type Field = 'name' | 'ticker';

function MyForm() {
const [name, setName] = useState('');
const [ticker, setTicker] = useState('');
const [formError, setFormError] = useState('');
const fieldErrors = useFormErrors<Field>();

const handleSubmit = () => {
const { errors, isValid } = validateForm<Field>(
{ name, ticker },
{
name: [required('Name is required.'), minLength(3, 'Name must be at least 3 characters.')],
ticker: [required('Ticker is required.'), pattern(/^[A-Z0-9]{2,10}$/, 'Ticker must be 2-10 letters/numbers.')],
},
);

fieldErrors.setErrors(errors);
if (!isValid) return;

// ...submit
};

return (
<form>
<FormError message={formError} />

<label htmlFor="my-name">Name</label>
<input id="my-name" value={name} onChange={(e) => setName(e.target.value)} />
<FormFieldError message={fieldErrors.errorFor('name')} />

<label htmlFor="my-ticker">Ticker</label>
<input id="my-ticker" value={ticker} onChange={(e) => setTicker(e.target.value)} />
<FormFieldError message={fieldErrors.errorFor('ticker')} />
</form>
);
}
```

## Adopting it in an existing form with its own validator

If a form already has a domain validator that returns one error code at a
time (like `validateAssetCreationRequest`), map each error code to the field
it belongs to and route it through `fieldErrors.setFieldError`, falling back
to the `FormError` banner for errors that aren't about a single field (e.g.
"fill in every field"). See `AssetCreationWizard.tsx` for a worked example
of this pattern (`ERROR_FIELD` map + `applyValidationError`).

## Where it's used today

- **`AssetCreationWizard`** (asset registration) — per-field errors for
asset name, ticker, amount, and jurisdiction; a `FormError` banner for
non-field-specific failures (e.g. missing fields).
- **`WhitelistManager`** (compliance/admin) — per-field error under the
address input, replacing the previous single ad-hoc error paragraph.
- **`MintWorkflow`** (minting) — its submit-time and async compliance-check
errors now render through the shared `FormError` banner instead of a
one-off `<div>`, so the visual style matches every other form.

## Accessibility

- `FormFieldError` and `FormError` both use `role="alert"` so assistive
technology announces new errors as they appear.
- Pass an `id` to `FormFieldError` and wire it to the input's
`aria-describedby` (see the examples in `AssetCreationWizard.tsx` and
`WhitelistManager.tsx`) so screen readers announce the specific error
when the field is focused, not just when it first appears.

## Testing

`rules.test.ts` and `useFormErrors.test.ts` cover the pure logic and the
hook in isolation (no DOM needed for the rules; `@testing-library/react`'s
`renderHook` for the hook). Component-level tests for each form continue to
assert on the rendered error text/role as before — adopting the shared
framework didn't change any form's public behavior or copy, only how the
error state and markup are produced internally.
67 changes: 52 additions & 15 deletions src/features/asset-creation/components/AssetCreationWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,28 @@ import {
ASSET_CREATION_ERROR_MESSAGES,
SUPPORTED_JURISDICTIONS,
ASSET_CLASS_OPTIONS,
type AssetCreationErrorCode,
} from '@/lib/assetCreationRequest';
import { useFormErrors, FormFieldError, FormError } from '@/features/forms/validation';
import type { IssuanceRequest } from '@/fixtures/issuer';

type WizardStep = 'form' | 'review' | 'success';
type AssetCreationField = 'assetName' | 'ticker' | 'amount' | 'jurisdiction';

/**
* `validateAssetCreationRequest` reports one error code at a time. Map each
* code to the field it belongs to so it renders under the right input via
* the shared `FormFieldError`; codes that aren't about a single field (e.g.
* several fields left blank) fall back to the form-level `FormError` banner.
*/
const ERROR_FIELD: Partial<Record<AssetCreationErrorCode, AssetCreationField>> = {
ASSET_NAME_TOO_SHORT: 'assetName',
INVALID_TICKER: 'ticker',
DUPLICATE_TICKER: 'ticker',
NON_POSITIVE_AMOUNT: 'amount',
AMOUNT_TOO_LARGE: 'amount',
UNSUPPORTED_JURISDICTION: 'jurisdiction',
};

export interface AssetCreationWizardProps {
/** Tickers already registered, for duplicate-ticker validation. */
Expand Down Expand Up @@ -48,18 +66,34 @@ export default function AssetCreationWizard({
const [amount, setAmount] = useState('');
const [jurisdiction, setJurisdiction] = useState<string>(SUPPORTED_JURISDICTIONS[0]);
const [assetClass, setAssetClass] = useState<string>(ASSET_CLASS_OPTIONS[0]);
const [error, setError] = useState('');
const [formError, setFormError] = useState('');
const [lastCreated, setLastCreated] = useState<IssuanceRequest | null>(null);
const fieldErrors = useFormErrors<AssetCreationField>();

/** Routes a validation error code to the right field or the form banner. */
const applyValidationError = (code: AssetCreationErrorCode) => {
const message = ASSET_CREATION_ERROR_MESSAGES[code];
const field = ERROR_FIELD[code];
if (field) {
fieldErrors.clearAll();
fieldErrors.setFieldError(field, message);
setFormError('');
} else {
fieldErrors.clearAll();
setFormError(message);
}
};

const handleReview = () => {
setError('');
setFormError('');
fieldErrors.clearAll();
const validation = validateAssetCreationRequest(
{ assetName, ticker, amount, jurisdiction, assetClass },
{ existingTickers },
);

if (!validation.valid) {
setError(ASSET_CREATION_ERROR_MESSAGES[validation.error!]);
applyValidationError(validation.error!);
return;
}

Expand All @@ -75,7 +109,7 @@ export default function AssetCreationWizard({
// Re-validate on confirm: existingTickers may have changed while the
// user sat on the review screen (e.g. another request was created).
if (!validation.valid || validation.parsedAmount === undefined) {
setError(ASSET_CREATION_ERROR_MESSAGES[validation.error!]);
applyValidationError(validation.error!);
setStep('form');
return;
}
Expand All @@ -102,7 +136,8 @@ export default function AssetCreationWizard({
setAmount('');
setJurisdiction(SUPPORTED_JURISDICTIONS[0]);
setAssetClass(ASSET_CLASS_OPTIONS[0]);
setError('');
setFormError('');
fieldErrors.clearAll();
setLastCreated(null);
setStep('form');
};
Expand Down Expand Up @@ -168,11 +203,7 @@ export default function AssetCreationWizard({
</div>
</dl>

{error && (
<div role="alert" className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
{error}
</div>
)}
<FormError message={formError} />

<p className="text-xs text-slate-500 mb-4">
Submitting sends this asset for compliance review. This is a protocol-level
Expand Down Expand Up @@ -207,11 +238,7 @@ export default function AssetCreationWizard({
approval — this does not mint supply directly.
</p>

{error && (
<div role="alert" className="bg-red-50 text-red-600 p-3 rounded mb-4 text-sm">
{error}
</div>
)}
<FormError message={formError} />

<div className="space-y-4 mb-6">
<div>
Expand All @@ -225,7 +252,9 @@ export default function AssetCreationWizard({
placeholder="Manhattan Commercial Real Estate"
value={assetName}
onChange={(e) => setAssetName(e.target.value)}
aria-describedby={fieldErrors.errorFor('assetName') ? 'ac-asset-name-error' : undefined}
/>
<FormFieldError id="ac-asset-name-error" message={fieldErrors.errorFor('assetName')} />
</div>

<div>
Expand All @@ -241,7 +270,9 @@ export default function AssetCreationWizard({
onChange={(e) => setTicker(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-describedby={fieldErrors.errorFor('ticker') ? 'ac-ticker-error' : undefined}
/>
<FormFieldError id="ac-ticker-error" message={fieldErrors.errorFor('ticker')} />
</div>

<div>
Expand Down Expand Up @@ -275,7 +306,9 @@ export default function AssetCreationWizard({
onChange={(e) => setAmount(e.target.value)}
min="0"
step="any"
aria-describedby={fieldErrors.errorFor('amount') ? 'ac-amount-error' : undefined}
/>
<FormFieldError id="ac-amount-error" message={fieldErrors.errorFor('amount')} />
</div>

<div>
Expand All @@ -287,13 +320,17 @@ export default function AssetCreationWizard({
className="w-full border border-slate-300 rounded p-2 focus:ring-2 focus:ring-aegis-brand outline-none bg-white"
value={jurisdiction}
onChange={(e) => setJurisdiction(e.target.value)}
aria-describedby={
fieldErrors.errorFor('jurisdiction') ? 'ac-jurisdiction-error' : undefined
}
>
{SUPPORTED_JURISDICTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<FormFieldError id="ac-jurisdiction-error" message={fieldErrors.errorFor('jurisdiction')} />
</div>
</div>

Expand Down
25 changes: 14 additions & 11 deletions src/features/compliance/components/WhitelistManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
guardWhitelistAction,
type WhitelistEntry,
} from '@/lib/whitelist';
import { useFormErrors, FormFieldError } from '@/features/forms/validation';
import { formatTimestamp, truncateAddress } from '@/utils/formatting';

type WhitelistFormField = 'address';

type LoadState = 'loading' | 'loaded' | 'error';

interface PendingAction {
Expand All @@ -37,7 +40,7 @@ export default function WhitelistManager() {

const [newAddress, setNewAddress] = useState('');
const [newNote, setNewNote] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const fieldErrors = useFormErrors<WhitelistFormField>();

const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);

Expand Down Expand Up @@ -66,17 +69,17 @@ export default function WhitelistManager() {

const validation = validateWhitelistAddress(address);
if (!validation.valid) {
setFormError(validation.reason);
fieldErrors.setFieldError('address', validation.reason);
return;
}

const guardReason = guardWhitelistAction(entries, address, 'add');
if (guardReason) {
setFormError(guardReason);
fieldErrors.setFieldError('address', guardReason);
return;
}

setFormError(null);
fieldErrors.clearAll();
setPendingAction({ action: 'add', address, note: newNote.trim() || undefined });
};

Expand Down Expand Up @@ -124,6 +127,13 @@ export default function WhitelistManager() {
onChange={(e) => setNewAddress(e.target.value)}
placeholder="GABC…"
className="w-full border border-slate-300 rounded p-2 font-mono text-sm focus:ring-2 focus:ring-aegis-brand outline-none"
aria-describedby={
fieldErrors.errorFor('address') ? 'whitelist-new-address-error' : undefined
}
/>
<FormFieldError
id="whitelist-new-address-error"
message={fieldErrors.errorFor('address')}
/>
</div>

Expand All @@ -144,13 +154,6 @@ export default function WhitelistManager() {
/>
</div>

{formError && (
<p role="alert" className="flex items-center gap-2 text-sm text-red-600">
<AlertTriangle size={14} className="shrink-0" aria-hidden="true" />
{formError}
</p>
)}

<button
type="submit"
disabled={!newAddress.trim()}
Expand Down
29 changes: 29 additions & 0 deletions src/features/forms/validation/FormError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { AlertTriangle } from 'lucide-react';

export interface FormErrorProps {
/** The error message to display. Renders nothing when `undefined`/empty. */
message?: string;
}

/**
* Consistent form-level error banner, for errors that aren't tied to a
* single field (e.g. a submit-time failure, a duplicate-ticker rejection,
* or an async check like compliance/whitelist status). Pair with
* `FormFieldError` for individual field errors so every form in the
* dashboard shows errors the same way.
*
* @see docs/form-validation-framework.md
*/
export default function FormError({ message }: FormErrorProps) {
if (!message) return null;

return (
<div
role="alert"
className="mb-4 flex items-start gap-2 rounded bg-red-50 p-3 text-sm text-red-600"
>
<AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden="true" />
<span>{message}</span>
</div>
);
}
27 changes: 27 additions & 0 deletions src/features/forms/validation/FormFieldError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { AlertTriangle } from 'lucide-react';

export interface FormFieldErrorProps {
/** The error message to display. Renders nothing when `undefined`/empty. */
message?: string;
/** id to pair with the input's `aria-describedby`, for screen readers. */
id?: string;
}

/**
* Consistent inline field error, used under any labeled input across the
* dashboard's forms (asset registration, compliance/whitelist actions,
* minting, admin forms). Renders `null` when there's no message so callers
* can render it unconditionally.
*
* @see docs/form-validation-framework.md
*/
export default function FormFieldError({ message, id }: FormFieldErrorProps) {
if (!message) return null;

return (
<p id={id} role="alert" className="mt-1 flex items-center gap-1.5 text-sm text-red-600">
<AlertTriangle size={14} className="shrink-0" aria-hidden="true" />
{message}
</p>
);
}
Loading
Loading