Yayaw
Documentation
Vues

Vue Formulaire

Transformer une vue en formulaire qui crée une fiche, et le partager sur un lien public servi par votre application.

La vue Formulaire fonctionne comme les formulaires Notion : une vue de la table affiche un formulaire au lieu des lignes, et chaque réponse crée une fiche via actions.create. On choisit les colonnes à demander, leur ordre et leur formulation, et on peut partager le formulaire sur un lien public. React et Vue se comportent de la même façon.

Le formulaire est livré dans les éléments de registre de la table, sans installation supplémentaire : React utilise les primitives shadcn et react-day-picker déjà présents dans l’élément, Vue utilise Reka UI et @internationalized/date (une dépendance du registre Vue). La présentation étape par étape s’appuie sur le Questionnaire de shadcn : l’élément React déclare comme dépendances le composant shadcn questionnaire et son paquet @shadcn/react, que la CLI shadcn installe avec la table ; l’élément Vue embarque sa propre copie.

Activer

Ajoutez "form" à displayModes. Le mode est proposé lorsque la table peut créer des fiches : actions.create existe et allowCreate ne vaut pas false. Sinon, "form" est retiré même s’il est listé, et un lien qui le demande revient au mode par défaut.

form-config.ts
export const requestConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    displayModes: ["table", "form"],
    form: {
      submitLabel: "Envoyer la demande",
      hiddenValues: { status: "Draft" },
    },
  },
});

table.form est optionnel. Mettez false pour désactiver le mode, ou un objet de réglages par défaut (la forme ci-dessous) dont part chaque vue Formulaire. Un renderer passé dans displayModeRenderers.form remplace celui intégré.

Essayer la vue Formulaire

Chaque réponse crée un projet via actions.create, avec le statut Planned défini par hiddenValues. Passez le Mode d’affichage sur Table pour le voir. L’aperçu utilise les projets d’ouverture de magasins communs à tous les guides de vues ; voir les données des exemples pour project-config.ts et l’hôte en mémoire.

Page avec conditions

Choisissez Hardware comme catégorie : Serial number apparaît et Budget devient obligatoire. Choisissez Other pour qu’on vous demande des détails. Les règles sont expliquées dans questions conditionnelles.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Étapes avec relecture

Les mêmes questions et règles, une section par étape, puis une relecture des réponses avec un bouton Modifier pour chacune. La dernière étape demande le site avec l’éditeur de lieu. Voir étape par étape.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Agrandir ↗

Données de démonstration. Les modifications restent dans cet aperçu.

Paramètres du formulaire

Choisissez Formulaire dans Paramètres de la vue › Mode d’affichage. Paramètres de la vue › Formulaire affiche alors un résumé du formulaire (questions, sections, consentements, champs cachés, présentation et langues), Modifier le formulaire et Réinitialiser. Les formulaires se modifient dans l’éditeur de formulaire. Les réglages sont enregistrés avec la vue (config.form) et dans la clé d’URL <tableId>-form, comme pour les autres modes d’affichage. Réinitialiser revient aux réglages de formulaire de la table.

RéglagePar défautDescription
titleAucunTitre du formulaire.
descriptionAucuneTexte sous le titre.
questionsToutes les colonnes qu’un formulaire peut demander, dans l’ordre des colonnesLes questions, et d’éventuels sauts de section, consentements et champs cachés, dans l’ordre ; voir ci-dessous.
rulesAucuneAfficher, masquer ou rendre obligatoires des questions d’après les réponses précédentes ; voir Questions conditionnelles.
layout"page""page" affiche toutes les questions d’un coup, "steps" une question ou une section à la fois ; voir Étape par étape.
reviewfalseDans la présentation par étapes, termine par une relecture des réponses avant l’envoi.
hiddenValuesAucuneValeurs fixes enregistrées avec chaque réponse, pour les colonnes que le formulaire ne demande pas, par exemple { status: "Draft" }.
submitLabel« Envoyer »Libellé du bouton d’envoi.
successMessage« Merci, votre réponse a bien été enregistrée. »Texte de l’écran affiché après une réponse.
closedMessage« Ce formulaire n’accepte plus de réponses. »Texte affiché à la place des questions quand le formulaire est fermé.
allowAnotherResponsetrueAffiche Envoyer une autre réponse sur cet écran.
redirectUrlAucuneOù votre application peut envoyer les personnes après une réponse. Seules les adresses http(s):// et les chemins commençant par un seul / sont conservés. Le formulaire ne navigue jamais de lui-même ; voir Redirection.
localesAucuneLangues dans lesquelles le formulaire est rédigé ; voir Langues.
defaultLocaleLa première de localesLangue des textes simples, et repli des traductions manquantes.
editButtontrueAffiche Modifier le formulaire au-dessus du formulaire dans la vue Formulaire. Le formulaire reste modifiable depuis les paramètres de la vue.

Les réglages s’appliquent dans cet ordre : les valeurs intégrées, puis table.form, puis la vue.

Chaque question vaut { id, columnId, label?, help?, placeholder?, required?, optionLabels? } :

  • columnId est la colonne qui reçoit la réponse. Une colonne est demandée au plus une fois.

  • id est stable et vaut par défaut l’id de la colonne.

  • label remplace le nom de la colonne, help s’affiche sous le libellé, placeholder dans le champ vide.

  • required rend la question obligatoire. Les questions sont facultatives par défaut.

  • optionLabels remplace les libellés des options de la colonne, par valeur d’option, par exemple pour les traduire.

Chaque texte d’un formulaire (titre, description, libellés, aide, textes indicatifs, libellés d’options, textes de section, libellé d’envoi, messages de confirmation et de fermeture) est une chaîne simple ou une chaîne par langue ; voir Langues.

Un saut de section est aussi un élément de questions : { id, kind: "section", title?, description? }. Il ouvre un groupe titré avec les questions qui suivent : un intertitre dans la présentation sur une page, une étape dans la présentation par étapes. Les règles peuvent afficher ou masquer une section, et ses questions avec elle, par son id.

Un consentement (kind: "consent") et un champ caché (kind: "hidden") sont aussi des éléments de questions ; voir Consentement et Champs cachés. Une valeur fixe pour une colonne demandée par le formulaire est ignorée.

Éditeur de formulaire

Modifier le formulaire, au-dessus de la vue Formulaire ou dans Paramètres de la vue › Formulaire, ouvre un éditeur presque plein écran par-dessus la vue :

  • La barre du haut contient la présentation (une seule page ou étape par étape), le sélecteur de langue Édition avec Ajouter une langue, les paramètres du formulaire, Partager le formulaire quand l’hôte fournit formLinks, Enregistrer et Fermer.

  • La structure, à gauche, liste dans l’ordre les questions, les sections et les consentements, puis les champs cachés et Hors du formulaire : les colonnes que le formulaire ne demande pas, qu’on peut poser ou doter d’une valeur fixe enregistrée avec chaque réponse. Faites glisser une entrée par sa poignée, ou appuyez sur Alt + ↑ / ↓, pour la déplacer. Ajouter propose une colonne à poser, une section, un consentement ou un champ caché.

  • Le centre montre un aperçu en direct dans la langue et la présentation en cours de modification, règles appliquées. Rien n’est envoyé. Afficher fermé prévisualise le message de fermeture.

  • Le volet de droite contient les propriétés de l’élément sélectionné, y compris les conditions d’une question.

Les modifications restent dans l’éditeur jusqu’à Enregistrer (ou Ctrl/Cmd + S). Fermer avec des modifications non enregistrées demande s’il faut les abandonner. Partager le formulaire publie le formulaire enregistré. Sur téléphone, l’éditeur occupe tout l’écran, avec les onglets Questions, Aperçu et Propriétés. editButton: false masque Modifier le formulaire au-dessus du formulaire.

Colonnes demandées

Un formulaire ne demande jamais une colonne marquée form: false, readonly, readOnly, editable: false, computed, system ou hidden, ni les colonnes calculées (accessorFn), ni les identifiants de métadonnées que portent souvent les tables (id, _id, uuid, createdAt, updatedBy… en camelCase ou snake_case). form: true autorise une colonne. Quand l’hôte déclare le formulaire de création de la table (getFormConfig), un formulaire ne demande que les champs de ce formulaire, sans ses champs masqués ou désactivés. Ces limites s’appliquent aux questions par défaut d’une vue Formulaire sans questions propres, au menu Ajouter, aux questions enregistrées et à buildPublicFormSnapshot.

Une vue Formulaire enregistrée ressemble à ceci :

request-view.ts
export const requestFormView = {
  id: "request",
  name: "Demande",
  config: {
    displayMode: "form",
    form: {
      title: "Demande de projet",
      description: "Parlez-nous du projet ; nous répondons sous deux jours.",
      questions: [
        { id: "name", columnId: "name", label: "Nom du projet", required: true },
        { id: "category", columnId: "category", required: true },
        { id: "price", columnId: "price", label: "Budget", help: "En euros, hors taxes." },
        { id: "dueDate", columnId: "dueDate", label: "Souhaité pour le" },
      ],
      hiddenValues: { status: "Draft" },
      submitLabel: "Envoyer la demande",
      successMessage: "Merci ! Votre demande est dans la colonne Brouillon.",
    },
  },
};

Questions et réponses

Chaque question utilise les contrôles de la table, choisis d’après le type de colonne :

Type de colonneContrôleRéponse
text, stringChamp texteTexte
codeTexte sur plusieurs lignesTexte
numberChamp texte, affiché avec le numberFormat de la colonne une fois quittéNombre (la virgule est acceptée comme séparateur décimal)
dateCalendrier dans un popover, dans la langue du formulaire, commençant au premier jour de la semaine de la localeYYYY-MM-DD
select, tagLa liste déroulante de la table ; les options s’affichent en tags quand la colonne utilise displayVariant: "tag", colorés selon coloredTagsLa valeur de l’option
multiSelectListe de cases à cocherTableau de valeurs d’options
booleanInterrupteurtrue ou false
url, imageChamp texteUne adresse complète http:// ou https://
locationL’éditeur de lieu de la tableUn lieu ; voir les colonnes de lieu

Les colonnes JSON, custom et dynamicType, ainsi que les colonnes calculées (accessorFn), n’ont pas de contrôle de formulaire : les réglages les listent sous « Indisponibles dans les formulaires ». Les colonnes de sélection et d’actions ne sont jamais proposées.

Les réponses sont vérifiées dans le navigateur d’après les questions obligatoires et les types de colonnes (nombres, dates valides, adresses web, options connues). Les erreurs s’affichent sous leur question, reliées par aria-describedby et aria-invalid ; un résumé (« 2 réponses sont à corriger. ») est annoncé, et le focus passe à la première question invalide.

La fiche passée à actions.create contient les valeurs fixes, puis les réponses, indexées par id de colonne. Les réponses aux questions masquées par une règle sont omises, comme les réponses vides ; une question oui/non envoie toujours true ou false. Quand create échoue, ses fieldErrors s’affichent sur leurs questions et son error dans le résumé. Après un succès, la table recharge ses lignes et le formulaire affiche le message de confirmation.

Questions conditionnelles

Des questions peuvent apparaître, disparaître ou devenir obligatoires d’après les réponses précédentes. Dans l’éditeur de formulaire, sélectionnez une question : ses conditions se modifient dans ses propriétés, sous Conditions. Choisissez Ajouter une condition :

  1. Choisissez l’effet de la règle : Afficher cette question si…, Masquer cette question si… ou Rendre cette question obligatoire si….

  2. Remplissez une carte de condition : la Question à lire, la Comparaison et la Valeur. Les comparaisons ont des libellés courts comme « est », « n’est pas », « contient », « avant le », « entre » ou « dans les derniers ». Les valeurs utilisent les contrôles du tableau : les questions à choix proposent leurs options (en étiquettes pour les colonnes à étiquettes), les listes des cases à cocher, les dates un calendrier, les intervalles De et À, les dates relatives un nombre de jours. Sur une carte large, les trois tiennent sur une ligne ; sur une carte étroite, ils s’empilent.

  3. Pour plusieurs conditions, choisissez Ajouter une condition, puis réglez Correspondance avec le contrôle Toutes / Au moins une : toutes les conditions (ET) ou l’une d’elles (OU). Ajouter un groupe ajoute un groupe imbriqué, affiché comme une carte en retrait avec son propre contrôle Toutes/Au moins une, par exemple « Catégorie est Matériel et (Budget > 1000 ou Souhaité pour le est dans les 7 prochains jours) ».

Les conditions sont enregistrées avec le reste du formulaire quand vous choisissez Enregistrer dans l’éditeur.

Chaque règle est résumée dans les propriétés de la question, par exemple « Affichée si Catégorie est Matériel et Budget > 1000 ». Une condition qui ne peut pas fonctionner affiche son problème sur sa carte, par exemple « Cette question n’est plus posée. » ou « Cette comparaison ne convient pas à la question. ». Les règles sont enregistrées avec la vue dans form.rules, comme les autres réglages. Les formulaires de création, de modification et de modification groupée utilisent les mêmes règles en code ; voir Champs conditionnels.

Une règle vaut { id, when, then }. when est un groupe { join: "and" | "or", items } dont les éléments sont des conditions { fieldId, operator, value? } ou des groupes imbriqués ; les conditions lisent des ids de questions. then vaut { action, questionIds }, où questionIds liste des ids de questions ou de sections :

request-rules.ts
import type { FormRule } from "@/components/ui/yayaw-table/utils/form-conditions";
// Vue : "@/components/ui/yayaw-table-vue/form-conditions"

const categoryIs = (value: string) => ({ fieldId: "category", operator: "is" as const, value });

export const requestRules: FormRule[] = [
  {
    id: "hardware-serial",
    when: { join: "and", items: [categoryIs("Hardware")] },
    then: { action: "show", questionIds: ["serialNumber"] },
  },
  {
    id: "hardware-budget",
    when: { join: "and", items: [categoryIs("Hardware")] },
    then: { action: "require", questionIds: ["price"] },
  },
  {
    id: "other-details",
    when: { join: "and", items: [categoryIs("Other")] },
    then: { action: "show", questionIds: ["details"] },
  },
];

Comparaisons

Les comparaisons proposées dépendent du type de la question lue :

Type de questionComparaisons (operator)Valeur
Texte, texte sur plusieurs lignes, adresse webest (is), n’est pas (isNot), contient (contains), ne contient pas (notContains), commence par (startsWith)Texte
Nombre= (eq), ≠ (neq), < (lt), ≤ (lte), > (gt), ≥ (gte), est entre (between)Nombre, ou [from, to] pour between
Dateest le (on), est avant le (before), est après le (after), est entre (between), est dans les … derniers jours (inLast), est dans les … prochains jours (inNext)YYYY-MM-DD, [from, to], ou un nombre de jours
Choix unique, tagest (is), n’est pas (isNot), est l’un de (isAnyOf), n’est aucun de (isNoneOf)Une valeur d’option, ou une liste de valeurs
Choix multiplecontient l’un de (containsAny), contient tous (containsAll), ne contient aucun de (containsNone)Une liste de valeurs d’options
Oui/nonest coché (isChecked), n’est pas coché (isUnchecked)Aucune

Tous les types sauf oui/non ont aussi est vide (isEmpty) et n’est pas vide (isNotEmpty). Le texte est comparé sans les espaces de début et de fin et sans tenir compte de la casse, les dates au jour près, et un intervalle peut laisser une borne ouverte (null).

Combinaison des règles

  • Une question visée par une règle Afficher reste masquée tant qu’aucune de ces règles ne correspond.

  • Masquer l’emporte sur Afficher. Masquer une section masque ses questions.

  • Rendre obligatoire rend une question obligatoire tant qu’elle est affichée : son libellé reçoit l’astérisque et le contrôle required et aria-required.

  • Une action set ({ action: "set", questionIds, value }) écrit une valeur dans une question affichée. Elle est disponible en JSON et en code, pas dans le panneau de réglages.

  • Une réponse masquée compte comme vide pour toutes les autres conditions, si bien que les enchaînements se stabilisent : quand A affiche B et B affiche C, modifier A pour masquer B masque aussi C.

Les règles qui ne peuvent pas agir sont écartées au chargement du formulaire au lieu de le casser : une condition sur une question qui n’est plus posée, une comparaison qui ne convient pas au type de la question, une valeur manquante ou une option inconnue, un groupe vide, une règle sans cible, une question qui dépend d’elle-même, ou des règles qui dépendent les unes des autres en boucle.

Questions masquées

Une question masquée n’est ni vérifiée ni envoyée. La validation l’ignore, même si elle est obligatoire, et sa réponse est retirée de l’envoi, y compris une réponse saisie avant que la question ne soit masquée. La fiche passée à actions.create ne contient donc jamais de réponses à des questions masquées.

Les formulaires publics appliquent les mêmes règles sur le serveur : acceptPublicFormResponse évalue les règles de l’instantané d’après les réponses reçues, ignore les réponses aux questions masquées, exige les questions obligatoires affichées et applique les valeurs set. Une requête forgée ne peut pas remplir une question que les règles masquent.

Le moteur ne dépend d’aucune interface et peut tourner partout : evaluateForm(rules, values, fields) renvoie les ids visibles et obligatoires, validateRules liste les problèmes avec leur code (missingField, unknownField, operator, missingValue, unknownOption, emptyGroup, noTargets, unknownTarget, selfReference, cycle, laterQuestion), et normalizeRules renvoie les règles qui peuvent agir ainsi que celles écartées avec leur motif. Ils sont exportés par utils/form-conditions en React et form-conditions en Vue.

Étape par étape

Dans Paramètres du formulaire › Présentation, activez Étape par étape pour poser une question à la fois (layout: "steps"). Activez Relire les réponses avant l’envoi (review: true) pour terminer par un récapitulatif des réponses, chacune avec un bouton Modifier qui ramène à son étape.

Pour poser plusieurs questions par étape, regroupez-les avec Ajouter une section. Chaque section est une étape, avec son titre et sa description ; les questions placées avant la première section forment la première étape. Les sections se déplacent, se modifient et se suppriment comme les questions ; en supprimer une retire aussi les règles restées sans cible. Dans la présentation sur une page, les sections s’affichent comme des intertitres.

guided-request-view.ts
export const guidedRequestView = {
  id: "guided-request",
  name: "Demande guidée",
  config: {
    displayMode: "form",
    form: {
      title: "Demande de projet guidée",
      layout: "steps",
      review: true,
      questions: [
        { id: "about", kind: "section", title: "Le projet" },
        { id: "name", columnId: "name", label: "Nom du projet", required: true },
        { id: "category", columnId: "category", required: true },
        { id: "details", columnId: "details", label: "Dites-nous en plus" },
        { id: "planning", kind: "section", title: "Budget et calendrier" },
        { id: "price", columnId: "price", label: "Budget" },
        { id: "serialNumber", columnId: "serialNumber", label: "Numéro de série" },
        { id: "dueDate", columnId: "dueDate", label: "Souhaité pour le" },
      ],
      rules: requestRules,
    },
  },
};

Dans la présentation par étapes :

  • La progression affiche « Étape 2 sur 5 » au-dessus des questions.

  • Suivant vérifie les questions de l’étape et place le focus sur la première invalide ; Entrée dans un champ d’une seule ligne mène aussi à Suivant. Retour revient à l’étape précédente.

  • Passer apparaît quand aucune question affichée de l’étape n’est obligatoire.

  • Les étapes dont toutes les questions sont masquées par les règles sont sautées, et une étape apparaît dès qu’une règle l’affiche. La progression ne compte que les étapes affichées.

  • Si l’envoi échoue, le formulaire revient à la première étape qui contient une erreur.

  • Dans cette présentation, les règles Rendre obligatoire et les actions set ne peuvent lire que des questions précédentes (code de problème laterQuestion) ; les règles Afficher et Masquer peuvent lire n’importe quelle question.

Les réponses peuvent survivre à un rechargement. YayawTableForm accepte draftStorageKey pour garder les réponses et l’étape en cours dans le localStorage du navigateur sous cette clé jusqu’à l’envoi. Pour votre propre stockage, contrôlez-les plutôt : value et onValueChange pour les réponses, step et onStepChange pour l’étape (un id de question ou de section, "start" pour les questions avant la première section, ou "review") ; Vue utilise v-model:value et v-model:step. readFormProgress(key) et writeFormProgress(key, progress) lisent et écrivent le formulaire stocké. Évitez draftStorageKey pour des réponses sensibles sur des appareils partagés.

Langues

Un formulaire peut être rédigé en plusieurs langues. Chaque texte de formulaire est un FormText : une chaîne simple, comme avant, ou une chaîne par langue :

localized-request-view.ts
export const localizedRequestForm = {
  locales: ["fr", "en"],
  defaultLocale: "fr",
  title: { fr: "Demande de projet", en: "Project request" },
  questions: [
    { id: "name", columnId: "name", label: { fr: "Nom du projet", en: "Project name" }, required: true },
  ],
  submitLabel: { fr: "Envoyer la demande", en: "Send request" },
};

Le formulaire affiche chaque texte dans sa locale : la locale exacte (fr-CA), puis sa langue (fr), puis la defaultLocale du formulaire, puis le texte par défaut propre à l’élément (le nom de la colonne, un libellé intégré), sinon la première version disponible. Une chaîne simple vaut pour toutes les langues : les formulaires déjà enregistrés fonctionnent sans changement. table.form.locales liste les langues de l’hôte, que chaque formulaire propose ; defaultLocale vaut par défaut la première de locales.

Dans l’éditeur, le sélecteur Édition choisit la langue rédigée, Ajouter une langue en ajoute une et Langue par défaut règle defaultLocale. Chaque texte présent dans d’autres langues mais absent de celle en cours affiche un badge Traduction manquante ; les textes non traduits se replient comme ci-dessus. Quand un formulaire a plusieurs langues, la vue Formulaire affiche un sélecteur Langue pour prévisualiser chacune. YayawTableForm affiche la langue de sa prop locale.

Consentement

Un consentement est une case à cocher liée à aucune colonne, par exemple pour accepter une politique de confidentialité (RGPD) :

{
  id: "privacy",
  kind: "consent",
  text: { fr: "J’accepte le traitement de mes réponses conformément à la {link}." },
  link: { label: { fr: "politique de confidentialité" }, href: "/confidentialite" },
  version: "2026-09",
}
  • text est la déclaration ; {link} marque l’emplacement du lien. Sans {link}, le lien suit le texte entre parenthèses. Non défini, il vaut « J’accepte le traitement de mes réponses. »

  • link vaut { label?, href? } : href est une adresse https:// ou un chemin de votre site ; sans lui, le libellé s’affiche sans lien.

  • version enregistre les conditions acceptées (par défaut "1").

Un consentement est toujours obligatoire : une case non cochée bloque la réponse, et les règles ne peuvent pas le masquer. Dans la présentation par étapes, il s’affiche là où il est placé, ou à la dernière étape. Il n’est jamais écrit dans une colonne. Les consentements acceptés arrivent sur votre serveur dans metadata.consents : id, version, la déclaration telle qu’affichée dans la langue de la réponse, href, locale et acceptedAt, fixé par votre serveur.

Champs cachés

Un champ caché n’est jamais affiché. Il lit une valeur de la page avec chaque réponse :

SourceValeur
{ type: "urlParam", name }Un paramètre d’URL, comme utm_source
{ type: "pageUrl" }L’adresse de la page
{ type: "referrer" }La page de provenance
{ type: "locale" }La langue du formulaire
{ type: "static", value }Un texte fixe
{ id: "campaign", kind: "hidden", source: { type: "urlParam", name: "utm_campaign" }, columnId: "campaign" }

Avec columnId, une colonne que le formulaire ne demande pas, la valeur est écrite dans cette colonne et vérifiée comme une réponse de son type. Sans elle, la valeur est gardée dans metadata.context sous l’id du champ. Les valeurs des champs cachés viennent du navigateur et ne sont pas fiables : le serveur n’accepte que les sources de l’instantané, en texte limité à 500 caractères. Les adresses de page et de provenance doivent être des adresses http(s) ; elles sont gardées sans leur requête ni leur fragment, et écartées au-delà de 2 048 caractères. Un texte fixe vient toujours de l’instantané, jamais du navigateur.

Contrat serveur

YayawTableForm appelle onSubmit(values, meta). values est la fiche, indexée par id de colonne. meta contient :

  • context : votre prop context, inchangée ;

  • consents : les consentements cochés, par id de consentement (true) ;

  • fields : les valeurs des champs cachés lues sur la page, par id de champ (non fiables) ;

  • locale : la langue dans laquelle le formulaire s’est affiché ;

  • metadata : les métadonnées de la réponse vues du navigateur.

Pour un formulaire public, envoyez consents, fields et locale à votre serveur avec les valeurs, et passez-les à acceptPublicFormResponse(snapshot, values, { consents, fields, locale, acceptedAt, translate }). acceptedAt (une Date ou une chaîne) est apposé sur chaque consentement accepté ; translate transmet les libellés personnalisés utilisés par votre page (form.<key>), pour que les déclarations intégrées soient enregistrées telles qu’affichées. Une réponse acceptée renvoie { ok: true, values, metadata } : écrivez values dans la table et gardez metadata (consents, context) avec la réponse. withFormServerContext(accepted, { pageId, revision, formToken }) ajoute ce que votre serveur sait à metadata.server ; les clés sont des caractères de mot, les valeurs du texte, des nombres ou des oui/non. Les valeurs du navigateur n’atteignent jamais metadata.server.

Formulaire autonome

YayawTableForm affiche le même formulaire hors d’une table. Il n’importe ni nuqs, ni jotai, ni TanStack Query, ni le provider de la table : il peut donc être monté sur une page publique sans l’état de la table. React : @/components/ui/yayaw-table/form/yayaw-table-form. Vue : @/components/ui/yayaw-table-vue/form/YayawTableForm.vue.

PropTypeDescription
columnsFormColumn[]Colonnes de la table ; seules celles demandées sont nécessaires, comme snapshot.columns.
formFormViewSettingsRéglages, par exemple formSettingsFromView(view) ou snapshot.form.
onSubmit(values, meta) => FormSubmitResultCrée la fiche. meta contient context, consents, fields, locale et metadata ; voir Contrat serveur. Renvoyez { ok: true }, ou { errors, message } avec des messages indexés par id de colonne. Peut renvoyer une promesse.
validate(values) => Record<string, string> | undefinedVérifications supplémentaires après celles intégrées ; peut renvoyer une promesse.
onSuccess({ values, redirectUrl }) => voidAppelé après un succès. Votre application décide de suivre ou non redirectUrl.
translationsRecord<string, string>Libellés personnalisés, sous la forme { submit } ou { "form.submit" }.
translate(key, fallback) => stringLibellés personnalisés sous forme de fonction ; l’emporte sur translations.
localestringLa langue du lecteur : les textes du formulaire s’y affichent (voir Langues) ; les libellés intégrés sont en anglais, ou en français pour les locales fr*. Par défaut "en".
contextRecord<string, unknown>Données de l’hôte transmises telles quelles à onSubmit (campagne, provenance, jeton de captcha).
extraFieldsReactNodeReact uniquement : champs de l’hôte affichés avant le bouton d’envoi, comme un pot de miel ou un captcha. Vue utilise le slot extra-fields.
closedbooleanAffiche « Ce formulaire n’accepte plus de réponses. » à la place des questions.
value, onValueChangeFormDraft, (draft) => voidRéponses contrôlées, par id de colonne. Vue : v-model:value.
step, onStepChangestring, (step) => voidÉtape en cours contrôlée, dans la présentation par étapes. Vue : v-model:step.
draftStorageKeystringGarde les réponses et l’étape dans le localStorage sous cette clé jusqu’à l’envoi.
import {
  acceptPublicFormResponse,
  formSettingsFromView,
  publicFormSnapshot,
} from "@/components/ui/yayaw-table/utils/form-view";
// Vue : "@/components/ui/yayaw-table-vue/form-view"

formSettingsFromView(view, defaults?) lit les réglages de formulaire d’une vue enregistrée. Le modèle de utils/form-view (Vue : form-view) ne dépend d’aucune interface ni bibliothèque d’état : votre serveur peut aussi l’importer.

Partager sur un lien public

La table ne sert pas de pages. Pour publier un formulaire, votre application fournit actions.formLinks ; la table affiche alors Partager le formulaire au-dessus du formulaire d’une vue enregistrée. Une vue non enregistrée affiche à la place « Enregistrez cette vue pour partager son formulaire. ».

formLinks?: {
  status: (viewId: string) => Promise<{ published: boolean; url?: string; acceptsResponses?: boolean } | null>;
  /** Construisez l'instantané sur votre serveur depuis la vue enregistrée ; ignorez le second argument, déprécié. */
  publish: (viewId: string, snapshot?: PublicFormSnapshot) => Promise<{ url: string }>;
  unpublish: (viewId: string) => Promise<void>;
  setAcceptingResponses?: (viewId: string, accepting: boolean) => Promise<void>;
};

Partager le formulaire ouvre un popover, ou un tiroir sur téléphone :

  • Publier sur le web appelle publish(viewId) et affiche le lien renvoyé, en lecture seule, avec Copier le lien et Ouvrir. Le désactiver appelle unpublish(viewId).

  • Accepter les réponses appelle setAcceptingResponses(viewId, accepting). Il n’apparaît que si vous fournissez cette fonction. Un acceptsResponses absent dans status signifie que le lien accepte les réponses.

  • Mettre à jour le formulaire public publie à nouveau les réglages actuels. La republication est manuelle : les modifications en cours n’arrivent jamais sur le lien public tant que personne ne choisit de le mettre à jour.

status(viewId) est lu à l’ouverture du popover.

Construire l’instantané sur votre serveur

publish(viewId) demande à votre serveur de publier la vue enregistrée. Chargez cette vue depuis votre propre stockage et construisez la copie publique avec buildPublicFormSnapshot, à partir de vos propres définitions de colonnes :

const snapshot = buildPublicFormSnapshot({
  view, // la vue enregistrée, telle que votre serveur la stocke
  columns, // les définitions de colonnes de votre serveur
  allowedColumnIds: ["name", "category", "price", "dueDate", "status"],
});

Il ne garde que les colonnes qui existent, qu’un formulaire peut demander et qui figurent dans allowedColumnIds (absent, toutes ces colonnes sont permises). Une valeur fixe n’est gardée que si elle convient à sa colonne : une option connue, un nombre, une date, une adresse web ou une valeur oui/non. Les questions, les sections, les règles qui peuvent agir et la présentation sont incluses. Un defaults optionnel transmet vos réglages table.form au niveau de la table.

La table passe toujours un instantané construit dans le navigateur en second argument, pour les hôtes écrits avant ce changement. Il est déprécié : tout ce qui vient du navigateur peut être modifié, ignorez-le donc et construisez l’instantané depuis la vue enregistrée. Un hôte qui stockait l’instantané du navigateur doit passer à buildPublicFormSnapshot et republier ses formulaires.

L’instantané ne contient que ce dont une page publique a besoin :

interface PublicFormSnapshot {
  version: 1;
  viewId?: string;
  /** À envoyer au navigateur avec `columns` : questions, sections, règles et présentation. */
  form: FormViewSettings;
  /** Seulement les colonnes demandées : id, header, type, options, et displayVariant, coloredTags, numberFormat s'ils sont définis. */
  columns: FormColumn[];
  /** À garder sur le serveur : acceptPublicFormResponse les ajoute à chaque réponse. */
  hiddenValues: Record<string, FormHiddenValue>;
  /** À garder sur le serveur : les champs cachés avec leurs sources, colonnes et textes fixes. */
  hiddenFields?: FormHiddenField[];
  /** À garder sur le serveur : les colonnes écrites par les champs cachés. */
  hiddenColumns?: FormColumn[];
}

Il ne contient ni lignes, ni filtres, ni autres colonnes, ni réglages de la table. form ne porte aucune valeur fixe, et ses champs cachés ne gardent que leurs sources : leurs colonnes et textes fixes restent dans hiddenFields.

Responsabilités de votre application

Un formulaire public écrit dans votre base de données pour le compte de visiteurs anonymes. La table vous fournit l’instantané et les vérifications ; le reste revient à votre serveur :

  1. Autoriser la publication. Seules les personnes autorisées à créer des fiches dans la table (et à la partager) peuvent appeler publish, unpublish et setAcceptingResponses. Vérifiez-le sur le serveur.

  2. Construire l’instantané sur le serveur avec buildPublicFormSnapshot({ view, columns, allowedColumnIds }), depuis la vue que vous avez stockée et vos propres définitions de colonnes, limitées aux colonnes que les visiteurs peuvent remplir. Ignorez l’instantané transmis par le navigateur, pour qu’une requête forgée ne puisse pas ajouter de colonnes, de questions ni de valeurs fixes que vous n’autorisez pas.

  3. Stocker l’instantané avec la vue, la table et un jeton public aléatoire. Servez le formulaire sur une route indexée par ce jeton plutôt que par l’id de la vue.

  4. Servir une route publique qui affiche YayawTableForm avec seulement snapshot.form et snapshot.columns. N’envoyez jamais hiddenValues, hiddenFields, hiddenColumns, les lignes ou d’autres colonnes au navigateur.

  5. Revalider chaque réponse avec acceptPublicFormResponse(snapshot, input, { consents, fields, locale, acceptedAt }). Il ne garde que les colonnes demandées, applique les règles (les réponses aux questions masquées sont retirées, les questions obligatoires affichées sont vérifiées), vérifie à nouveau les réponses et les consentements, lit les champs cachés et ajoute les valeurs fixes. Refusez les réponses quand le formulaire est dépublié ou fermé.

  6. Écrire avec votre propre code serveur, limité à cette seule table, sans jamais faire confiance aux valeurs du client pour les colonnes que le formulaire ne demande pas.

  7. Limiter les abus : limitez le débit par adresse et par formulaire, plafonnez la taille des requêtes, et ajoutez un pot de miel ou un captcha via extraFields et context.

  8. Republier après un changement de schéma. L’instantané est une copie : les colonnes renommées ou supprimées, les nouvelles options et les changements de règles n’arrivent sur le formulaire public qu’après sa mise à jour.

Redirection après une réponse

redirectUrl est un réglage, pas une navigation. onSuccess le reçoit, et votre page publique décide de le suivre ou non. Comparez-le à vos propres destinations autorisées avant de rediriger.

Exemple Next.js

Le serveur conserve les instantanés ; db représente votre couche de données.

app/forms/form-links.ts
"use server";

import { randomBytes } from "node:crypto";
import { buildPublicFormSnapshot } from "@/components/ui/yayaw-table/utils/form-view";
import { requestColumns } from "@/lib/request-columns";
import { requireEditor } from "@/lib/auth";
import { db } from "@/lib/db";

/** Colonnes que les visiteurs anonymes peuvent remplir dans cette table. */
const PUBLIC_COLUMNS = ["name", "category", "price", "dueDate", "serialNumber", "details", "status"];

// La table passe aussi un instantané construit dans le navigateur en second argument, déprécié : non lu.
export async function publishForm(viewId: string) {
  await requireEditor("requests");
  // La vue enregistrée telle que votre API de vues la stocke, pas telle que le navigateur l'envoie.
  const view = await db.tableViews.find("requests", viewId);
  if (!view) {
    throw new Error("Enregistrez cette vue avant de la partager.");
  }
  const snapshot = buildPublicFormSnapshot({
    view,
    columns: requestColumns,
    allowedColumnIds: PUBLIC_COLUMNS,
  });
  const existing = await db.publicForms.findByView(viewId);
  const token = existing?.token ?? randomBytes(16).toString("base64url");
  await db.publicForms.upsert({ viewId, token, snapshot });
  return { url: `${process.env.APP_URL}/f/${token}` };
}

export async function formStatus(viewId: string) {
  await requireEditor("requests");
  const form = await db.publicForms.findByView(viewId);
  return form
    ? { published: true, url: `${process.env.APP_URL}/f/${form.token}`, acceptsResponses: form.accepting }
    : { published: false };
}

export async function unpublishForm(viewId: string) {
  await requireEditor("requests");
  await db.publicForms.deleteByView(viewId);
}

export async function setAccepting(viewId: string, accepting: boolean) {
  await requireEditor("requests");
  await db.publicForms.update(viewId, { accepting });
}

La table les reçoit dans actions.formLinks :

app/requests/actions.ts
formLinks: {
  status: formStatus,
  publish: publishForm,
  unpublish: unpublishForm,
  setAcceptingResponses: setAccepting,
},

La page publique charge l’instantané sur le serveur et n’envoie que le formulaire et ses colonnes :

app/f/[token]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PublicForm } from "./public-form";

export default async function Page({ params }: { params: Promise<{ token: string }> }) {
  const { token } = await params;
  const published = await db.publicForms.findByToken(token);
  if (!published) {
    notFound();
  }
  const { form, columns } = published.snapshot;
  return (
    <main className="mx-auto max-w-3xl px-4 py-10">
      <PublicForm closed={!published.accepting} columns={columns} form={form} token={token} />
    </main>
  );
}
app/f/[token]/public-form.tsx
"use client";

import { useState } from "react";
import { YayawTableForm } from "@/components/ui/yayaw-table/form/yayaw-table-form";
import type { FormColumn, FormViewSettings } from "@/components/ui/yayaw-table/utils/form-view";
import { submitPublicForm } from "./submit";

export function PublicForm(props: {
  token: string;
  form: FormViewSettings;
  columns: FormColumn[];
  closed: boolean;
}) {
  const [website, setWebsite] = useState("");
  return (
    <YayawTableForm
      closed={props.closed}
      columns={props.columns}
      context={{ website }}
      extraFields={
        // Pot de miel : non affiché aux personnes, souvent rempli par les robots.
        <input
          autoComplete="off"
          className="hidden"
          name="website"
          onChange={(event) => setWebsite(event.target.value)}
          tabIndex={-1}
          value={website}
        />
      }
      form={props.form}
      locale="fr"
      onSubmit={(values, { consents, context, fields, locale }) =>
        submitPublicForm(props.token, values, { consents, context, fields, locale })
      }
    />
  );
}

L’action serveur revalide la réponse d’après l’instantané stocké, règles comprises, avant de l’écrire :

app/f/[token]/submit.ts
"use server";

import { headers } from "next/headers";
import {
  acceptPublicFormResponse,
  type FormSubmitResult,
  formLabel,
} from "@/components/ui/yayaw-table/utils/form-view";
import { db } from "@/lib/db";
import { rateLimit } from "@/lib/rate-limit";

export async function submitPublicForm(
  token: string,
  input: unknown,
  meta: { consents?: unknown; context?: Record<string, unknown>; fields?: unknown; locale?: unknown }
): Promise<FormSubmitResult> {
  const address = (await headers()).get("x-forwarded-for") ?? "unknown";
  if (!(await rateLimit(`form:${token}:${address}`, { limit: 10, windowSeconds: 600 }))) {
    return { ok: false, message: "Trop de réponses. Réessayez plus tard." };
  }
  const published = await db.publicForms.findByToken(token);
  if (!published?.accepting) {
    return { ok: false, message: formLabel("closed", "fr") };
  }
  if (meta.context?.website) {
    return { ok: true }; // Pot de miel rempli : faire comme si, ne rien stocker.
  }
  const checked = acceptPublicFormResponse(published.snapshot, input, {
    consents: meta.consents,
    fields: meta.fields,
    locale: meta.locale,
    acceptedAt: new Date(),
  });
  if (!checked.ok) {
    const errors = Object.fromEntries(
      Object.entries(checked.errors).map(([columnId, code]) => [columnId, formLabel(code, "fr")])
    );
    return { ok: false, errors };
  }
  // Seulement les réponses aux questions affichées et les valeurs fixes, écrites par votre code.
  const record = await db.requests.create(checked.values);
  // Les consentements et les champs cachés non liés ne sont jamais des colonnes : gardez-les avec la réponse.
  await db.responseMetadata.create({ recordId: record.id, metadata: checked.metadata });
  return { ok: true };
}

formLabel(code, locale) transforme les codes d’erreur de acceptPublicFormResponse (errorRequired, errorNumber, errorDate, errorUrl, errorOption, errorLocation, et errorConsent indexé par id de consentement) en messages intégrés, en anglais ou en français.

Exemple Nuxt

Le même principe avec une route serveur Nuxt. La publication fonctionne comme dans l’exemple Next.js, derrière vos propres routes d’API.

pages/f/[token].vue
<script setup lang="ts">
import YayawTableForm from "@/components/ui/yayaw-table-vue/form/YayawTableForm.vue";
import type { FormSubmitMeta } from "@/components/ui/yayaw-table-vue/form-view";

const token = useRoute().params.token as string;
const { data } = await useFetch(`/api/forms/${token}`); // { form, columns, closed }
const website = ref("");

const submit = (values: Record<string, unknown>, meta: FormSubmitMeta) =>
  $fetch(`/api/forms/${token}`, {
    method: "POST",
    body: { values, consents: meta.consents, fields: meta.fields, locale: meta.locale, website: website.value },
  });
</script>

<template>
  <YayawTableForm
    v-if="data"
    :closed="data.closed"
    :columns="data.columns"
    :form="data.form"
    locale="fr"
    :on-submit="submit"
  >
    <template #extra-fields>
      <input v-model="website" autocomplete="off" class="hidden" name="website" tabindex="-1" />
    </template>
  </YayawTableForm>
</template>
server/api/forms/[token].post.ts
import { acceptPublicFormResponse, formLabel } from "@/components/ui/yayaw-table-vue/form-view";
import { db } from "~/server/utils/db";

export default defineEventHandler(async (event) => {
  const token = getRouterParam(event, "token") ?? "";
  const published = await db.publicForms.findByToken(token);
  if (!published?.accepting) {
    return { ok: false, message: formLabel("closed", "fr") };
  }
  const body = await readBody(event);
  if (body?.website) {
    return { ok: true };
  }
  const checked = acceptPublicFormResponse(published.snapshot, body?.values, {
    consents: body?.consents,
    fields: body?.fields,
    locale: body?.locale,
    acceptedAt: new Date(),
  });
  if (!checked.ok) {
    const errors = Object.fromEntries(
      Object.entries(checked.errors).map(([columnId, code]) => [columnId, formLabel(code, "fr")])
    );
    return { ok: false, errors };
  }
  const record = await db.requests.create(checked.values);
  await db.responseMetadata.create({ recordId: record.id, metadata: checked.metadata });
  return { ok: true };
});

La route GET renvoie { form: snapshot.form, columns: snapshot.columns, closed: !accepting } et rien d’autre. Ajoutez la limitation de débit dans un middleware serveur.

Traductions

La vue Formulaire a des libellés intégrés en anglais et en français ; le français est utilisé quand la locale commence par fr. Remplacez n’importe lequel avec des clés plates form.<key> dans les traductions de la table, en React comme en Vue, par exemple "form.submit": "Valider". YayawTableForm accepte les mêmes clés, avec ou sans le préfixe form..

  • Formulaire : submit, submitting, success, successTitle, another, required, choose, pickDate, clearDate, closed, noQuestions, submitError.

  • Erreurs : errorRequired, errorNumber, errorDate, errorUrl, errorOption, errorLocation, errorConsent, et errorSummary avec {count} sous forme plurielle : {count, plural, one {1 réponse est à corriger.} other {{count} réponses sont à corriger.}}.

  • Réglages : settings, settingsTitle, title, description, questions, ask, moveUp, moveDown, editQuestion (ces quatre avec {label}), label, help, placeholder, requiredToggle, hidden, hiddenHint, hiddenValue (avec {label}), none, excluded (avec {columns}), afterSubmit, submitLabel, successMessage, allowAnother, redirectUrl, redirectHint, reset.

  • Langues : editingLanguage, previewLanguage, addLanguage, defaultLanguage, languages, translationHint (avec {language}), missingTranslation, closedMessage, optionLabels, optionLabel (avec {option}).

  • Consentements et champs cachés : addConsent, consent, consentStatement, consentText, consentTextLink et consentLinkHint (avec {link}), consentLinkLabel, linkText, linkUrl, consentVersion, consentNote, newTab, addHiddenField, hiddenFields, hiddenFieldsHint, hiddenField, hiddenSource, sourceUrlParam, sourcePageUrl, sourceReferrer, sourceLocale, sourceStatic, paramName, staticValue, saveIn, responseDetails.

  • Éditeur de formulaire : editForm, builderDescription, builderOutline, builderPreview, builderProperties, builderPreviewNote, builderShowClosed, save, unsavedChanges, builderSaved, discardTitle, discardDescription, discard, keepEditing, saveAndClose, addItem, addQuestion, noColumnsLeft, notInForm, notInFormHint, askQuestion, removeQuestion, remove, fixedValue, reorderHint, builderMoved (avec {label}, {position} et {count}), builderAdded et builderRemoved (avec {label}), columnOf (avec {label}), untitledForm, questionKind, hasConditions, inTheView, editButtonSetting, editButtonHint, resetHint, saveToPublish, summaryHint, summaryReview, et les comptes au pluriel summaryQuestions, summarySections, summaryConsents, summaryHiddenFields.

  • Partage : share, publish, publishHint, publicLink, copy, copied, open, acceptResponses, republish, republishHint, saveFirst, shareError, loading, close.

  • Présentation et étapes : layout, layoutPage, layoutSteps, review, next, back, skip, stepProgress (avec {current} et {total}), reviewTitle, reviewChange (avec {label}), noAnswer, yes, no.

  • Sections : addSection, section, sectionTitle, sectionDescription, untitledSection, editSection, removeSection (ces deux avec {label}).

  • Éditeur de règles : conditions, addRule, ruleAction, actionShow, actionHide, actionRequire, ruleJoin, joinAnd, joinOr, ruleField, ruleOperator, ruleValue, ruleFrom, ruleTo, ruleDays, chooseQuestion, addCondition, addGroup, ruleGroup, removeCondition, removeGroup, removeRule, editConditions, conditionsTitle (avec {label}), conditionsDescription, noConditions, conditionsProblem, done, joinAll, joinAny, ruleDaysUnit.

  • Problèmes de règles : issueMissingField, issueUnknownField, issueOperator, issueMissingValue, issueUnknownOption, issueEmptyGroup, issueCycle, issueLaterQuestion, issueSelfReference, issueTarget.

  • Résumés de règles : thenShow, thenHide, thenRequire (avec {condition}), thenSet (avec {value} et {condition}), ruleAnd, ruleOr, ruleCustom, et une formulation par comparaison avec {field} et {value} : opIs, opIsNot, opContains, opNotContains, opStartsWith, opEq, opNeq, opLt, opLte, opGt, opGte, opBetween (avec {from} et {to} à la place de {value}), opOn, opBefore, opAfter, opInLast, opInNext, opIsAnyOf, opIsNoneOf, opContainsAny, opContainsAll, opContainsNone, opIsChecked, opIsUnchecked, opIsEmpty, opIsNotEmpty.

  • Libellés de comparaison : le menu des comparaisons affiche des libellés courts et complets, un par comparaison : cmpIs, cmpIsNot, cmpContains, cmpNotContains, cmpStartsWith, cmpEq, cmpNeq, cmpLt, cmpLte, cmpGt, cmpGte, cmpBetween, cmpOn, cmpBefore, cmpAfter, cmpInLast, cmpInNext, cmpIsAnyOf, cmpIsNoneOf, cmpContainsAny, cmpContainsAll, cmpContainsNone, cmpIsChecked, cmpIsUnchecked, cmpIsEmpty, cmpIsNotEmpty, par exemple « est », « avant le » ou « dans les derniers ». Les formulations op* ci-dessus restent utilisées pour les résumés.

Le nom du mode dans le sélecteur de mode d’affichage est views.display.form en React et display.form en Vue.

TableDisplayMode inclut "form"

TableDisplayMode inclut désormais "form". Si votre code garde une table exhaustive Record<TableDisplayMode, …>, comme des icônes ou des libellés par mode, ajoutez une entrée form pour qu’il compile toujours :

const modeLabels: Record<TableDisplayMode, string> = {
  // ...
  form: "Formulaire",
};