Formulaire de feedback pour un site ou une application

Formulaire de feedback pour un site ou une application8 min de lecture

Créer un formulaire de feedback dans l'application avec Astro

Un formulaire de feedback dans une application Astro : une action qui prend l'utilisateur dans les locals du middleware, la page dans la requête et l'écran dans le motif de route.

Preet SutharFondateur, Ikiform

Écrivez une action Astro avec accept: "form" qui valide la note avec Zod, lit l'utilisateur connecté dans context.locals et transmet la réponse à votre backend de formulaires avec un secret issu de astro:env. Affichez le formulaire avec son action réglée sur l'action, pour qu'il envoie sans JavaScript, et ajoutez un court script qui appelle la même action sur place quand JavaScript est disponible. La page concernée vient du Referer de la requête, et Astro.routePattern nomme l'écran.

L'onglet Astro du guide principal demande à tout visiteur si une page lui a été utile. Cette page place le feedback dans une application Astro avec des utilisateurs connectés, où c'est le serveur qui doit décider qui a écrit, et où le formulaire ne demande qu'une note et un commentaire. Pour les clés et la configuration du formulaire, le guide Astro de la documentation donne les détails.

Qui remplit quoi

Les actions Astro sont des endpoints publics, accessibles à une URL qui porte le nom de l'action. N'importe qui peut appeler sendFeedback avec un corps forgé, donc l'action ne peut pas croire un id utilisateur qu'on lui donne. Répartissez les valeurs selon qui mérite confiance pour les fournir :

ValeurRenseigné parComment
rating, commentLa personne elle-mêmeValidé par le schéma Zod de l'action
user_id, emailMiddlewarecontext.locals.user, depuis le cookie de session à chaque requête
pageLa requêteL'en-tête Referer, qui est la page où se trouve le formulaire
screenLe rendu de la pageAstro.routePattern, comme /app/projects/[id]/settings, dans un input masqué

routePattern est ce qui rend Astro agréable ici. Il nomme le fichier qui a rendu la page : l'écran de réglages de chaque projet arrive donc sous un même libellé, tandis que page garde l'adresse réelle. Il passe par le navigateur, donc le schéma vérifie sa forme et remplace toute bizarrerie par unknown.

Deux façons pour un formulaire de feedback Astro d'atteindre la même actionsans JavaScriptavec le scriptPOST duformulairevers lapagecourantemiddlewarelocals.userdepuis lecookiesendFeedbackZod, puisfetchla pageseréaffichegetActionResult()actions.sendFeedback()FormDataduformulairemiddlewaremême cookie,mêmeutilisateursendFeedbackmêmehandlerformulaireremplacé{ data } ou{ error }URL de la pagerésultatfetchJSONDeux façons pour un formulaire de feedback Astro d'atteindre la même actionsans JavaScriptavec le scriptPOST du formulairevers la page courantemiddlewarelocals.user depuis le cookiesendFeedbackZod, puis fetchla page se réaffichegetActionResult()actions.sendFeedback()FormData du formulairemiddlewaremême cookie, même utilisateursendFeedbackmême handlerformulaire remplacé{ data } ou { error }URL de la pagerésultatfetchJSON
  1. Sans JavaScript, le navigateur envoie le formulaire à la page courante, avec le nom de l'action dans l'URL.
  2. Le middleware s'exécute en premier à chaque requête et place l'utilisateur connecté dans context.locals.
  3. L'action valide la note et l'écran, ajoute l'utilisateur et la page, et envoie à Ikiform avec le secret.
  4. Astro affiche à nouveau la page, et Astro.getActionResult() montre la ligne de remerciement ou l'erreur.
  5. Avec JavaScript, le script appelle la même action via fetch. Le middleware et le handler s'exécutent exactement comme avant.
  6. Le script remplace le formulaire par la ligne de remerciement, ou écrit l'erreur en dessous, sans rechargement.
Un seul handler, deux transports. Les deux requêtes viennent de la page, elles portent donc toutes deux le cookie de session et la page dans Referer.

Mise en place du projet

Arborescence du projet
src/
  actions/index.ts
  components/FeedbackForm.astro
  middleware.ts
  env.d.ts
  pages/app/projects/[id]/settings.astro
astro.config.mjs
.env

Déclarez le secret dans le schéma astro:env. Astro refuse alors de démarrer sans lui, type l'import et le garde hors du bundle client. N'importe quel adaptateur serveur fonctionne ; l'exemple utilise Node.

astro.config.mjs
import { defineConfig, envField } from "astro/config";
import node from "@astrojs/node";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  env: {
    schema: {
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});

Mettez IKIFORM_SECRET_KEY=ik_sk_... dans .env pour le développement local. Créez la clé sous API keys avec le scope submissions:write. Collez ensuite cette définition dans l'onglet Fields du formulaire :

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Comment cet écran fonctionne-t-il pour vous ?

Renseigné par votre serveur à partir de la requête.

Le schéma de l'action reprend ces limites, donc la plupart des saisies invalides sont refusées avant de coûter une requête.

La construire

Placer l'utilisateur dans locals

Le middleware s'exécute avant chaque page et chaque action. Lisez-y votre session une fois, avec ce que fournit votre code d'authentification, et chaque handler reçoit le résultat. readSession représente ce code. Les sessions natives d'Astro fonctionnent aussi : lisez plutôt context.session dans l'action.

src/middleware.ts
import { defineMiddleware } from "astro:middleware";
import { readSession } from "./lib/auth";

export const onRequest = defineMiddleware(async (context, next) => {
  context.locals.user = await readSession(context.cookies.get("session")?.value);
  return next();
});
src/env.d.ts
declare namespace App {
  interface Locals {
    user: { id: string; email: string } | null;
  }
}

L'action du formulaire

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";

function pageFrom(referer: string | null) {
  try {
    const url = new URL(referer ?? "");
    return url.origin + url.pathname;
  } catch {
    return "";
  }
}

export const server = {
  sendFeedback: defineAction({
    accept: "form",
    input: z.object({
      rating: z.coerce.number().int().min(1, "Pick a score from 1 to 5.").max(5, "Pick a score from 1 to 5."),
      comment: z.string().max(4000).nullish(),
      screen: z.string().regex(/^\/[a-z0-9/[\]._-]{0,119}$/).catch("unknown"),
      _gotcha: z.string().nullish(),
    }),
    handler: async (input, context) => {
      const user = context.locals.user;
      if (!user) throw new ActionError({ code: "UNAUTHORIZED", message: "Your session ended. Sign in again, then send it." });

      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify({
          rating: input.rating,
          comment: input.comment ?? "",
          page: pageFrom(context.request.headers.get("referer")),
          screen: input.screen,
          user_id: user.id,
          email: user.email,
          _gotcha: input._gotcha ?? "",
        }),
        signal: AbortSignal.timeout(8000),
      }).catch(() => null);

      const result = await response?.json().catch(() => null);
      if (result?.ok) return { sent: true };
      throw new ActionError({
        code: "BAD_GATEWAY",
        message: result?.error?.message ?? "We couldn't send it. Try again in a moment.",
      });
    },
  }),
};
  • Le second argument du handler est le contexte de l'action. Il contient locals, request et cookies, mais pas redirect : une session manquante devient donc une erreur UNAUTHORIZED que le formulaire peut afficher.
  • .catch("unknown") sur screen transforme un libellé forgé en unknown au lieu de faire échouer toute la réponse.
  • Les inputs vides arrivent à une action form sous la forme null, c'est pourquoi comment et le honeypot sont nullish.
  • L'envoi du formulaire comme l'appel du script sont émis par la page : Referer contient donc son adresse dans les deux cas. pageFrom garde l'origine et le chemin.
  • Origin est transmis pour que les tests locaux comptent comme localhost. Plus de détails ci-dessous.

Le composant formulaire

src/components/FeedbackForm.astro
---
import { actions, isInputError } from "astro:actions";

const result = Astro.getActionResult(actions.sendFeedback);
const scores = ["Broken", "Frustrating", "Fine", "Good", "Great"];
---

{result?.data?.sent ? (
  <p role="status">Thanks. We read every one.</p>
) : (
  <form method="POST" action={actions.sendFeedback} data-feedback>
    <fieldset>
      <legend>How is this screen working for you?</legend>
      {scores.map((label, index) => (
        <label><input type="radio" name="rating" value={index + 1} required /> {label}</label>
      ))}
    </fieldset>
    <label for="feedback-comment">What would make it better?</label>
    <textarea id="feedback-comment" name="comment" rows="4" maxlength="4000"></textarea>
    <input type="hidden" name="screen" value={Astro.routePattern} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p role="alert">
      {result?.error && (isInputError(result.error) ? result.error.fields.rating?.[0] : result.error.message)}
    </p>
    <button>Send feedback</button>
  </form>
)}

<script>
  import { actions } from "astro:actions";

  const form = document.querySelector<HTMLFormElement>("form[data-feedback]");
  form?.addEventListener("submit", async (event) => {
    event.preventDefault();
    const button = form.querySelector("button")!;
    const alert = form.querySelector("[role=alert]")!;
    button.disabled = true;
    button.textContent = "Sending";
    const { error } = await actions.sendFeedback(new FormData(form));
    if (!error) {
      form.replaceWith(Object.assign(document.createElement("p"), { role: "status", textContent: "Thanks. We read every one." }));
      return;
    }
    alert.textContent = error.message;
    button.disabled = false;
    button.textContent = "Send feedback";
  });
</script>
  • action={actions.sendFeedback} fait fonctionner le formulaire sans aucun JavaScript. Astro gère l'envoi et affiche la page avec le résultat.
  • isInputError distingue les échecs du schéma, qui ont des fields, des erreurs levées par le handler, comme le message de session.
  • Le script importe actions depuis astro:actions et appelle le même handler avec les données du formulaire. Astro le bundle, il n'y a donc pas de fichier supplémentaire.
  • Un envoi sans JavaScript laisse la page comme résultat d'un POST, et un rafraîchissement demande de renvoyer. Si cela vous gêne, getActionContext dans le middleware permet de stocker le résultat et de rediriger.

La page

src/pages/app/projects/[id]/settings.astro
---
import AppLayout from "../../../../layouts/AppLayout.astro";
import FeedbackForm from "../../../../components/FeedbackForm.astro";

export const prerender = false;

if (!Astro.locals.user) return Astro.redirect("/sign-in");
---

<AppLayout title="Project settings">
  <h1>Project settings</h1>
  <aside>
    <FeedbackForm />
  </aside>
</AppLayout>

Mode de rendu

Une action de formulaire ne fonctionne que sur une page rendue à la demande. Les pages prérendues sont construites une seule fois, il n'y a donc aucune requête vers laquelle envoyer. Avec la sortie statique par défaut, marquez chaque page de l'application avec export const prerender = false, ou réglez output: "server" si la majeure partie du site est derrière une connexion. Le middleware s'exécute quand même sur les pages prérendues au moment du build, où il n'y a pas de cookie : locals.user y vaut donc null.

Honeypot, vérification humaine et limites

L'input masqué _gotcha est transmis, et un honeypot rempli est stocké comme spam. La vérification humaine ne s'exécute jamais pour un envoi avec clé secrète, et c'est l'obligation de connexion qui joue ce rôle. Astro vérifie par défaut l'Origin des envois de formulaire vers les pages à la demande : un autre site ne peut donc pas soumettre le formulaire dans le navigateur d'un utilisateur.

Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Déployer

HébergeurAdaptateurLe secret
Vercel@astrojs/vercelAjoutez IKIFORM_SECRET_KEY dans les variables d'environnement du projet. Si vous utilisez les sessions Astro, configurez un driver de stockage ; Vercel n'en a pas par défaut.
Netlify@astrojs/netlifyAjoutez-la dans les variables d'environnement du site. Les sessions utilisent Netlify Blobs par défaut.
Cloudflare@astrojs/cloudflareLancez npx wrangler secret put IKIFORM_SECRET_KEY et mettez-la dans .dev.vars en local. astro:env/server la lit, tout comme env de cloudflare:workers. Les sessions utilisent un binding KV nommé SESSION.

Depuis Astro 6, Astro.locals.runtime.env a disparu de l'adaptateur Cloudflare. Le code qui y lisait la clé a besoin de l'import astro:env ci-dessus.

Tests locaux gratuits

Un espace de travail sans offre a droit à 50 soumissions de test gratuites par mois depuis localhost, qu'Ikiform reconnaît grâce à l'en-tête Origin. Le navigateur envoie http://localhost:4321 à astro dev, mais le fetch de l'action vers Ikiform n'envoie aucune origine. La ligne Origin transmet celle du navigateur : les tests locaux sont donc gratuits, et en production l'en-tête est votre propre domaine.

Vérifiez que tout fonctionne

  1. Lancez astro dev, connectez-vous, ouvrez la page de réglages d'un projet et envoyez un 2 avec un commentaire.
  2. Dans Submissions, vérifiez que screen vaut /app/projects/[id]/settings, que page contient le vrai id et que user_id et email sont les vôtres.
  3. Désactivez JavaScript et envoyez à nouveau. La page se recharge avec la ligne de remerciement.
  4. Supprimez le cookie de session et envoyez. Le formulaire affiche le message de connexion.
  5. Répondez à l'e-mail de notification. Il est adressé à l'utilisateur connecté.

Pièges

ProblèmeQue faire
Le formulaire envoie mais la page n'affiche aucun résultatLa page est prérendue. Ajoutez export const prerender = false ou passez à output: "server".
IKIFORM_SECRET_KEY is missing au démarrage du buildLes secrets sont validés dès que astro:env/server est importé. Fournissez la variable au build, ou une valeur bidon au build et la vraie valeur à l'exécution.
402 plan_required sur les tests locauxL'Origin transmis est vide ou n'est pas localhost. Ouvrez l'application sur localhost:4321 et gardez la ligne Origin.
locals.user vaut toujours null en productionLe nom ou le domaine du cookie diffère selon l'environnement, ou la page est encore prérendue. Loguez context.cookies.has("session") dans le middleware.
422 validation_failedL'envoi ne correspond pas à la définition des champs du formulaire. error.fields nomme chaque champ et la raison. Faites correspondre le name de l'input à l'id du champ.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.