diff --git a/docs/form-validation-framework.md b/docs/form-validation-framework.md new file mode 100644 index 0000000..bf7ef63 --- /dev/null +++ b/docs/form-validation-framework.md @@ -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(); + + const handleSubmit = () => { + const { errors, isValid } = validateForm( + { 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 ( +
+ + + + setName(e.target.value)} /> + + + + setTicker(e.target.value)} /> + + + ); +} +``` + +## 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 `
`, 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. diff --git a/src/features/asset-creation/components/AssetCreationWizard.tsx b/src/features/asset-creation/components/AssetCreationWizard.tsx index 3a9f427..ad61480 100644 --- a/src/features/asset-creation/components/AssetCreationWizard.tsx +++ b/src/features/asset-creation/components/AssetCreationWizard.tsx @@ -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> = { + 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. */ @@ -48,18 +66,34 @@ export default function AssetCreationWizard({ const [amount, setAmount] = useState(''); const [jurisdiction, setJurisdiction] = useState(SUPPORTED_JURISDICTIONS[0]); const [assetClass, setAssetClass] = useState(ASSET_CLASS_OPTIONS[0]); - const [error, setError] = useState(''); + const [formError, setFormError] = useState(''); const [lastCreated, setLastCreated] = useState(null); + const fieldErrors = useFormErrors(); + + /** 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; } @@ -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; } @@ -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'); }; @@ -168,11 +203,7 @@ export default function AssetCreationWizard({
- {error && ( -
- {error} -
- )} +

Submitting sends this asset for compliance review. This is a protocol-level @@ -207,11 +238,7 @@ export default function AssetCreationWizard({ approval — this does not mint supply directly.

- {error && ( -
- {error} -
- )} +
@@ -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} /> +
@@ -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} /> +
@@ -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} /> +
@@ -287,6 +320,9 @@ 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) => ( ))} +
diff --git a/src/features/compliance/components/WhitelistManager.tsx b/src/features/compliance/components/WhitelistManager.tsx index 633b0c9..38aadbf 100644 --- a/src/features/compliance/components/WhitelistManager.tsx +++ b/src/features/compliance/components/WhitelistManager.tsx @@ -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 { @@ -37,7 +40,7 @@ export default function WhitelistManager() { const [newAddress, setNewAddress] = useState(''); const [newNote, setNewNote] = useState(''); - const [formError, setFormError] = useState(null); + const fieldErrors = useFormErrors(); const [pendingAction, setPendingAction] = useState(null); @@ -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 }); }; @@ -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 + } + /> + @@ -144,13 +154,6 @@ export default function WhitelistManager() { /> - {formError && ( -

-

- )} -