Intégration Frontend (Angular · React · Vue · Svelte)

Ce guide montre comment consommer le protocole input-spec côté client sans imposer de design visuel. Tous les exemples s’appuient uniquement sur les API publiques exportées.

API principales :

  • InputFieldSpec
  • FieldValidator.validate(fieldSpec, value)

1. Schéma de validation partagé

Exemple de spécification de champ (username) :

const usernameSpec: InputFieldSpec = {
  displayName: 'Nom d\'utilisateur',
  description: '3–20 caractères alphanum + underscore',
  dataType: 'STRING',
  expectMultipleValues: false,
  required: true,
  formatHint: 'username',
  constraints: [
    { name: 'minL', type: 'minLength', params: { value: 3 } },
    { name: 'maxL', type: 'maxLength', params: { value: 20 } },
    { name: 'syntax', type: 'pattern', params: { regex: '^[a-zA-Z0-9_]+' }, errorMessage: 'Alphanum + underscore uniquement' }
  ]
};

2. Angular (>= v17, standalone, Typed Forms)

2.1 AsyncValidator standalone + Signal de statut

import { Directive, Input, forwardRef, computed, signal } from '@angular/core';
import { NG_ASYNC_VALIDATORS, AbstractControl, AsyncValidator, ValidationErrors } from '@angular/forms';
import { InputFieldSpec, validateField } from 'input-spec';
import { from, of } from 'rxjs';

@Directive({
  selector: '[inputSpecField]',
  providers: [
    { provide: NG_ASYNC_VALIDATORS, useExisting: forwardRef(() => InputSpecFieldDirective), multi: true }
  ]
})
export class InputSpecFieldDirective implements AsyncValidator {
  @Input('inputSpecField') spec!: InputFieldSpec | null;

  status = signal<'idle'|'valid'|'invalid'|'checking'>('idle');
  errorsMap = signal<Record<string,string[]>>({});

  validate(control: AbstractControl) {
    if (!this.spec) return of(null);
    this.status.set('checking');
    return from(
      validateField(this.spec, control.value).then(result => {
        if (result.isValid) {
          this.status.set('valid');
          this.errorsMap.set({});
          return null;
        }
        const grouped: Record<string,string[]> = {};
        for (const err of result.errors) {
          (grouped[err.constraintName] ||= []).push(err.message);
        }
        this.errorsMap.set(grouped);
        this.status.set('invalid');
        return { inputSpec: grouped } as ValidationErrors;
      }).catch(e => {
        this.status.set('invalid');
        return { inputSpecInternal: { message: e?.message || 'validation_error' } };
      })
    );
  }
}

2.2 Utilisation (standalone component)

<input formControlName="username" [inputSpecField]="usernameSpec" />
@if (fc('username').errors as errs) {
  @for (k of Object.keys(errs.inputSpec || {}); track k) {
    <strong></strong>
    @for (m of errs.inputSpec[k].messages; track m) {
      <div></div>
    }
  }
}

3. React (18+, Hooks, Transition Safe)

Hook avec gestion debounced + état détaillé.

import { useCallback, useState, useRef, useTransition } from 'react';
import { InputFieldSpec, validateField } from 'input-spec';

export function useInputSpec(spec: InputFieldSpec, { debounceMs = 250 } = {}) {
  const [errors, setErrors] = useState<Record<string,string[]>>({});
  const [valid, setValid] = useState(true);
  const [isPending, startTransition] = useTransition();
  const timer = useRef<number | undefined>();

  const run = useCallback(async (value: any) => {
    const result = await validateField(spec, value);
    startTransition(() => {
      if (result.isValid) {
        setErrors({});
        setValid(true);
      } else {
        const grouped: Record<string,string[]> = {};
        for (const err of result.errors) {
          (grouped[err.constraintName] ||= []).push(err.message);
        }
        setErrors(grouped);
        setValid(false);
      }
    });
  }, [spec]);

  const validate = useCallback((value: any) => {
    if (timer.current) window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => { run(value); }, debounceMs);
  }, [debounceMs, run]);

  return { validate, errors, valid, isPending };
}

Utilisation :

const { validate, errors, valid } = useInputSpec(usernameSpec);
<input onChange={e => validate(e.target.value)} />
{!valid && Object.entries(errors).map(([c, msgs]) => (
  <div key={c}>
    <strong>{c}</strong>
    <ul>{msgs.map(m => <li key={m}>{m}</li>)}</ul>
  </div>
))}

4. Vue 3 (Composition API +

import { ref } from 'vue';
import { InputFieldSpec, validateField } from 'input-spec';

export function useInputSpec(spec: InputFieldSpec, { debounceMs = 250 } = {}) {
  const errors = ref<Record<string,string[]>>({});
  const valid = ref(true);
  const pending = ref(false);
  let handle: number | undefined;

  function schedule(value: any) {
    if (handle) window.clearTimeout(handle);
    handle = window.setTimeout(async () => {
      pending.value = true;
      const result = await validateField(spec, value);
      if (result.isValid) {
        errors.value = {};
        valid.value = true;
      } else {
        const grouped: Record<string,string[]> = {};
        for (const err of result.errors) {
          (grouped[err.constraintName] ||= []).push(err.message);
        }
        errors.value = grouped;
        valid.value = false;
      }
      pending.value = false;
    }, debounceMs);
  }

  return { errors, valid, pending, validate: schedule };
}

Template :

<input @input="e => validate(e.target.value)" />
<div v-if="!valid">
  <div v-for="(msgs, key) in errors" :key="key">
    <strong></strong>
    <ul><li v-for="m in msgs" :key="m"></li></ul>
  </div>
</div>

5. Svelte (Store helper + debounce)

import { writable } from 'svelte/store';
import { validateField, type InputFieldSpec } from 'input-spec';

export function createInputSpecValidator(spec: InputFieldSpec, { debounceMs = 250 } = {}) {
  const errors = writable<Record<string,string[]>>({});
  const valid = writable(true);
  const pending = writable(false);
  let handle: number | undefined;

  function validate(value: any) {
    if (handle) clearTimeout(handle);
    handle = setTimeout(async () => {
      pending.set(true);
      const result = await validateField(spec, value);
      if (result.isValid) {
        errors.set({});
        valid.set(true);
      } else {
        const grouped: Record<string,string[]> = {};
        for (const err of result.errors) {
          (grouped[err.constraintName] ||= []).push(err.message);
        }
        errors.set(grouped);
        valid.set(false);
      }
      pending.set(false);
    }, debounceMs) as unknown as number;
  }

  return { errors, valid, pending, validate };
}

Utilisation :

<script lang="ts">
  import { createInputSpecValidator } from './inputSpecStore';
  import type { InputFieldSpec } from 'input-spec';

  export let usernameSpec: InputFieldSpec;
  const { validate, errors, valid } = createInputSpecValidator(usernameSpec);
  let value = '';
</script>

<input bind:value on:input={(e) => validate(value)} />
{#if !$valid}
  {#each Object.entries($errors) as [k,msgs]}
    <div><strong>{k}</strong><ul>{#each msgs as m}<li>{m}</li>{/each}</ul></div>
  {/each}
{/if}

6. Valeurs dynamiques (Autocomplete v2)

Exemple générique (fetch JSON) pour un champ avec fieldSpec.valuesEndpoint en mode SUGGESTIONS.

export async function fetchSuggestions(fieldSpec: InputFieldSpec, query: string) {
  if (!fieldSpec.valuesEndpoint) return [];
  const ve = fieldSpec.valuesEndpoint;
  if (ve.mode === 'CLOSED' && query.length === 0) {
    // Charger la première page si pagination
  }
  const url = new URL(ve.uri, window.location.origin);
  if (ve.requestParams?.searchParam) {
    url.searchParams.set(ve.requestParams.searchParam, query);
  }
  const res = await fetch(url.toString(), { method: ve.method || 'GET' });
  const data = await res.json();
  const container = ve.responseMapping?.dataField ? data[ve.responseMapping.dataField] : data;
  return Array.isArray(container) ? container : [];
}

7. Gestion des erreurs

  • Toujours consommer le tableau errors: ValidationError[].
  • Regrouper par constraintName pour l’affichage.
  • Ne pas re-dupliquer les règles côté client (source unique serveur).

8. Arborescence recommandée

frontend/
  validation/
    input-spec.directive.ts      # Directive Angular
    input-spec.hook.ts           # Hook React
    input-spec.composable.ts     # Composable Vue
    input-spec.store.ts          # Store helper Svelte

9. Tests ciblés

| Couche | Cible | Exemple | |——–|——-|———| | Parsing spec | Obligatoire + ordre contraintes | Objet mal formé -> gestion amont | | Intégration validateur | Agrégation d’erreurs | Pattern + longueur -> 2 erreurs (ordre préservé) | | Liaison UI | Rendu des erreurs | Input invalide -> messages présents |


10. Checklist de migration minimale

  • Remplacer les regex historiques dispersées par InputFieldSpec.
  • Centraliser la définition des contraintes côté serveur.
  • Utiliser uniquement les champs du protocole (aucun flag ad hoc).
  • Regrouper les messages UI par constraintName.
  • Domaine dynamique distant : implémenter un fetch custom.

11. Notes

  • Aucune opinion sur l’UI : total contrôle visuel.
  • Les exemples font toujours une validation complète.
  • Étendre via des wrappers sans modifier la forme du protocole.

12. Améliorations futures (optionnel)

  • Wrapper avec debounce autour de validateField.
  • Cache pour paires (spec, valeur) inchangées.
  • Validation batch d’un ensemble de InputFieldSpec.

13. FAQ rapide Frontend

Question Réponse courte Exemple
Valider seulement au blur ? N’appelez validate que dans onBlur / @blur / on:blur. React: <input onBlur={e => validate(e.target.value)} />
Debounce différent selon champ ? Passez debounceMs différent par hook/composable/store. useInputSpec(spec, { debounceMs: 600 })
Comment pré-charger un domain fermé ? Si valuesEndpoint.mode==='CLOSED', fetch au montage et mettez les options en cache local. useEffect(()=>{ fetchSuggestions(spec,''); },[])
Mapping i18n des erreurs ? Transformez chaque message avant affichage via une table locale. const msg = t(err.messageKey || err.message)
Validation multi‑champs (dépendances) ? Validez chaque champ après mise à jour du contexte partagé. Mettre un état global formData + re‑valider dépendants
Intégration React Hook Form ? Utiliser un resolver async qui appelle validateField pour chaque champ. Voir pattern resolver classique (non répété ici)
Annuler une validation en cours ? Utiliser un contrôleur Abort ou un flag « génération » de requête. Stocker currentRunId et ignorer les réponses obsolètes
Suggestions côté Vue avec composition ? Exposer suggestions + méthode load(query) dans le composable. const suggestions = ref([]); puis assigner après fetch
Angular: afficher état pending ? Lire directive.status() signal. @if (dir.status()==='checking'){ <span>…</span> }
Svelte: reset après submit ? Réinitialiser stores errors.set({}); valid.set(true);. Dans handler on:submit

Snippet: Resolver React Hook Form minimal

import { validateField } from 'input-spec';

export const makeResolver = (specs: Record<string, InputFieldSpec>) => async (values: any) => {
  const errors: Record<string, any> = {};
  await Promise.all(Object.entries(specs).map(async ([name, spec]) => {
    const res = await validateField(spec, values[name]);
    if (!res.isValid) {
      errors[name] = {
        type: 'input-spec',
        message: res.errors[0]?.message,
        messages: res.errors.map(e => e.message)
      };
    }
  }));
  return { values: Object.keys(errors).length ? {} : values, errors };
};

Snippet: Pré-chargement de suggestions (React)

useEffect(() => {
  let cancelled = false;
  (async () => {
    if (fieldSpec.valuesEndpoint && fieldSpec.valuesEndpoint.mode === 'CLOSED') {
      const initial = await fetchSuggestions(fieldSpec, '');
      if (!cancelled) setInitialOptions(initial);
    }
  })();
  return () => { cancelled = true; };
}, [fieldSpec]);

Snippet: Adapter i18n simple

const dictionary: Record<string,string> = {
  'validation.required': 'Champ obligatoire',
  'validation.pattern': 'Format invalide'
};
function translate(message: string) {
  return dictionary[message] || message;
}

© input-spec – Guide d’intégration Frontend (FR)


© 2025 Protocol Documentation. Built with Just the Docs.