Formulir feedback untuk website atau aplikasi

Formulir feedback untuk website atau aplikasi7 menit baca

Buat formulir feedback dalam aplikasi di Astro

Formulir feedback dalam aplikasi Astro: action yang mengambil pengguna dari locals middleware, halaman dari request, dan layar dari pola route.

Preet SutharPendiri, Ikiform

Tulis Astro action dengan accept: "form" yang memvalidasi rating dengan Zod, membaca pengguna yang login dari context.locals, dan meneruskan jawaban ke backend formulir Anda dengan secret dari astro:env. Render formulir dengan action diatur ke action tersebut, agar bisa mengirim tanpa JavaScript, dan tambahkan script pendek yang memanggil action yang sama di tempat saat JavaScript tersedia. Halaman yang dibahas jawaban berasal dari Referer request, dan Astro.routePattern menamai layarnya.

Tab Astro di panduan utama menanyakan kepada pengunjung mana pun seberapa berguna sebuah halaman. Halaman ini menaruh feedback di dalam aplikasi Astro dengan pengguna yang login, tempat server yang seharusnya menentukan siapa penulisnya dan formulir hanya meminta skor dan komentar. Untuk key dan penyiapan formulir, panduan dokumentasi Astro punya detailnya.

Siapa mengisi apa

Astro action adalah endpoint publik, bisa dijangkau di URL yang dinamai sesuai action-nya. Siapa pun bisa memanggil sendFeedback dengan body rekayasa, jadi action tidak bisa memercayai ID pengguna yang diberikan kepadanya. Pisahkan nilai berdasarkan siapa yang bisa dipercaya untuk menyediakannya:

NilaiDiatur olehCara
rating, commentPenggunaDivalidasi oleh schema Zod milik action
user_id, emailMiddlewarecontext.locals.user, dari cookie sesi di setiap request
pageRequestHeader Referer, yaitu halaman tempat formulir berada
screenRender halamanAstro.routePattern, seperti /app/projects/[id]/settings, di input tersembunyi

routePattern adalah alasan Astro terasa nyaman di sini. Ia menamai file yang merender halaman, sehingga layar pengaturan setiap proyek masuk ke satu label sementara page menyimpan alamat aslinya. Nilai ini melewati browser, jadi schema memeriksa bentuknya dan mengganti yang janggal dengan unknown.

Dua cara satu formulir feedback Astro mencapai action yang samatanpa JavaScriptdengan scriptPOSTformulirke halamansaat inimiddlewarelocals.userdari cookiesendFeedbackZod, lalufetchhalamandirenderulanggetActionResult()actions.sendFeedback()FormDatadariformulirmiddlewarecookie sama,penggunasamasendFeedbackhandleryang samaformulirdiganti{ data }atau {error }URL halamanhasilfetchJSONDua cara satu formulir feedback Astro mencapai action yang samatanpa JavaScriptdengan scriptPOST formulirke halaman saat inimiddlewarelocals.user dari cookiesendFeedbackZod, lalu fetchhalaman dirender ulanggetActionResult()actions.sendFeedback()FormData dari formulirmiddlewarecookie sama, pengguna samasendFeedbackhandler yang samaformulir diganti{ data } atau { error }URL halamanhasilfetchJSON
  1. Tanpa JavaScript, browser mengirim formulir ke halaman saat ini, dengan nama action di URL.
  2. Middleware berjalan lebih dulu di setiap request dan menaruh pengguna yang login di context.locals.
  3. Action memvalidasi rating dan layar, menambahkan pengguna dan halaman, lalu mengirim ke Ikiform dengan secret.
  4. Astro merender halaman lagi, dan Astro.getActionResult() menampilkan baris terima kasih atau error-nya.
  5. Dengan JavaScript, script memanggil action yang sama lewat fetch. Middleware dan handler berjalan persis seperti sebelumnya.
  6. Script mengganti formulir dengan baris terima kasih, atau menulis error di bawahnya, tanpa reload.
Satu handler, dua jalur. Kedua request berasal dari halaman, jadi keduanya membawa cookie sesi dan halaman di Referer.

Setup proyek

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

Deklarasikan secret di schema astro:env. Astro kemudian menolak berjalan tanpanya, memberi tipe pada import-nya, dan menjauhkannya dari bundle klien. Adapter server apa pun bisa dipakai; yang ditampilkan adalah 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" }),
    },
  },
});

Taruh IKIFORM_SECRET_KEY=ik_sk_... di .env untuk pengembangan lokal. Buat key di API keys dengan scope submissions:write. Lalu tempel definisi ini di tab Fields formulir:

Pratinjau

Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.

Bagaimana layar ini bekerja untuk Anda?

Diatur oleh server Anda dari request.

Schema di action mencerminkan batas-batas ini, jadi sebagian besar input buruk ditolak sebelum menghabiskan satu request.

Bangun

Taruh pengguna di locals

Middleware berjalan sebelum setiap halaman dan action. Baca sesi Anda di sana sekali, dengan apa pun yang disediakan kode auth Anda, dan setiap handler mendapat hasilnya. readSession mewakili kode itu. Sesi bawaan Astro juga bisa dipakai: baca context.session di action sebagai gantinya.

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;
  }
}

Action-nya

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.",
      });
    },
  }),
};
  • Argumen kedua handler adalah konteks action. Isinya locals, request, dan cookies, tetapi tidak ada redirect, jadi sesi yang tidak ada menjadi error UNAUTHORIZED yang bisa ditampilkan formulir.
  • .catch("unknown") pada screen mengubah label palsu menjadi unknown alih-alih menggagalkan seluruh jawaban.
  • Input formulir yang kosong sampai ke action form sebagai null, itulah sebabnya comment dan honeypot bersifat nullish.
  • Baik form post maupun panggilan dari script dikirim oleh halaman, jadi Referer berisi alamatnya di kedua kasus. pageFrom menyimpan origin dan path-nya.
  • Origin diteruskan agar uji lokal dihitung sebagai localhost. Penjelasan lebih lanjut di bawah.

Komponen formulir

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} membuat formulir bekerja tanpa JavaScript sama sekali. Astro menangani kiriman dan merender halaman dengan hasilnya.
  • isInputError memisahkan kegagalan schema, yang punya fields, dari error yang dilempar handler, seperti pesan sesi.
  • Script meng-import actions dari astro:actions dan memanggil handler yang sama dengan data formulir. Astro mem-bundle-nya, jadi tidak ada file tambahan.
  • Submit tanpa JavaScript meninggalkan halaman sebagai hasil POST, dan refresh akan meminta pengiriman ulang. Jika itu mengganggu Anda, getActionContext di middleware memungkinkan Anda menyimpan hasilnya dan me-redirect.

Halamannya

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 rendering

Form action hanya bekerja di halaman yang dirender on demand. Halaman yang di-prerender dibangun sekali, jadi tidak ada request yang bisa dikirimi. Dengan output statis default, tandai setiap halaman aplikasi dengan export const prerender = false, atau atur output: "server" jika sebagian besar situs berada di balik login. Middleware tetap berjalan di halaman yang di-prerender saat build, ketika tidak ada cookie, jadi locals.user bernilai null di sana.

Honeypot, pemeriksaan manusia, dan batas

Input tersembunyi _gotcha diteruskan, dan yang terisi disimpan sebagai spam. Pemeriksaan manusia tidak pernah berjalan untuk kiriman dengan secret key, dan kewajiban login menggantikan tugasnya. Secara default Astro memeriksa Origin dari form post ke halaman on demand, sehingga situs lain tidak bisa mengirim formulir di browser pengguna.

Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati Allowed domains dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih rate limit Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan IKIFORM_SECRET_KEY di environment variable khusus server.

Deploy

HostAdapterSecret-nya
Vercel@astrojs/vercelTambahkan IKIFORM_SECRET_KEY di environment variable proyek. Jika Anda memakai sesi Astro, konfigurasikan storage driver; Vercel tidak punya default.
Netlify@astrojs/netlifyTambahkan di environment variable situs. Sesi secara default memakai Netlify Blobs.
Cloudflare@astrojs/cloudflareJalankan npx wrangler secret put IKIFORM_SECRET_KEY dan taruh di .dev.vars secara lokal. astro:env/server membacanya, begitu juga env dari cloudflare:workers. Sesi memakai binding KV bernama SESSION.

Sejak Astro 6, Astro.locals.runtime.env sudah dihapus dari adapter Cloudflare. Kode yang membaca key dari sana perlu import astro:env di atas.

Uji lokal gratis

Workspace tanpa paket mendapat 50 kiriman uji gratis per bulan dari localhost, dan Ikiform mengenalinya dari header Origin. Browser mengirim http://localhost:4321 ke astro dev, tetapi fetch milik action ke Ikiform tidak mengirim origin. Baris Origin meneruskan origin browser, jadi uji lokal gratis, dan di produksi header-nya adalah domain Anda sendiri.

Pastikan berfungsi

  1. Jalankan astro dev, login, buka halaman pengaturan sebuah proyek, dan kirim nilai 2 dengan komentar.
  2. Di Submissions, pastikan screen bernilai /app/projects/[id]/settings, page berisi ID asli, serta user_id dan email adalah milik Anda.
  3. Matikan JavaScript dan kirim lagi. Halaman dimuat ulang dengan baris terima kasih.
  4. Hapus cookie sesi lalu kirim. Formulir menampilkan pesan login.
  5. Balas email notifikasi. Balasannya ditujukan ke pengguna yang login.

Hal yang sering menjebak

MasalahYang harus dilakukan
Formulir terkirim tetapi halaman tidak menampilkan hasilHalaman di-prerender. Tambahkan export const prerender = false atau beralih ke output: "server".
IKIFORM_SECRET_KEY is missing saat build dimulaiSecret divalidasi setiap kali astro:env/server di-import. Sediakan variabel itu saat build, atau placeholder di sana dan nilai aslinya saat runtime.
402 plan_required pada uji lokalOrigin yang diteruskan kosong atau bukan localhost. Buka aplikasi di localhost:4321 dan pertahankan baris Origin.
locals.user selalu null di produksiNama atau domain cookie berbeda antar-environment, atau halaman masih di-prerender. Log context.cookies.has("session") di middleware.
422 validation_failedKiriman tidak cocok dengan definisi field formulir. error.fields menyebutkan setiap field dan alasannya. Samakan name input dengan ID field.

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.