#lessonlearned – Formular-Finisher-Architektur: ein Plugin-System für Storyblok-Formulare

Formulare auf einer Website sollen oft weit mehr leisten, als nur eine Nachricht zu versenden. Je nach Anwendungsfall müssen Bestätigungsmails verschickt, interne Empfänger benachrichtigt, Leads an externe Systeme übergeben oder Daten für spätere Auswertungen protokolliert werden. In diesem Lesson Learned zeigen wir, wie wir dafür eine Finisher-Architektur aufgebaut haben, mit der sich Folgeaktionen pro Formular flexibel in Storyblok konfigurieren lassen.

Erklärende Grafik zum Text zum Thema "Formular Finisher Architektur"

Warum Formulare mehr als nur E-Mails versenden

In unserem Projekt sollten Formulare je nach Kontext unterschiedliche Aufgaben übernehmen. Ein Kontaktformular auf einer Länderwebsite kann andere Empfänger haben als ein Formular auf einer anderen Domain. Manche Formulare sollten zusätzlich Daten an Systeme wie Salesforce oder Evalanche übergeben. Andere brauchten nur eine interne Benachrichtigung und eine Bestätigung an den Absender.

Die eigentliche Herausforderung bestand also nicht nur darin, Formulare technisch abzusenden, sondern die Folgeprozesse flexibel und wiederverwendbar zu organisieren.

Ziel war es, diese Logik nicht fest im Template oder in einzelnen Formularvarianten zu verankern, sondern so aufzubauen, dass Redakteurinnen und Redakteure die gewünschten Folgeaktionen direkt im CMS zusammenstellen können.

Unser Ansatz: eine zentrale Finisher-Sammlung

Dafür haben wir ein System aufgebaut, in dem jede Folgeaktion als eigener Finisher umgesetzt wird. Diese Finisher werden zentral registriert und anschließend in der im CMS definierten Reihenfolge ausgeführt.

Ein vereinfachter Ausschnitt aus der Sammlung sieht so aus:

// formFinisherCollection.ts
export const formFinisherCollection = () => ({
  FormLogFinisher: useFormLogFinisher,
  FormRecipientEmailFinisher: useFormRecipientEmailFinisher,
  FormSenderEmailFinisher: useFormSenderEmailFinisher,
  FormBuergerstromFinisher: useFormBuergerstromFinisher,
  FormEvalancheSubmitFinisher: useFormEvalancheSubmitFinisher,
  FormSalesforceDataSubmissionFinisher: useFormSalesforceDataSubmissionFinisher
})

ts

Jeder Finisher ist dabei als eigene Composable-Funktion umgesetzt und über den jeweiligen Storyblok-Komponentennamen auffindbar.

Finisher in der gewünschten Reihenfolge ausführen

Sobald ein Formular abgesendet wurde, lesen wir die am Formular hinterlegten Finisher aus und führen sie nacheinander aus. Dadurch bleibt nachvollziehbar, welcher Schritt wann passiert.

// useFormFinisher.ts
export const useFormFinisher = async (body, story, runtimeConfig) => {
  const status = { statusCode: 200, finisher: [] }
  const formBlok = extractBlokFromStoryOnUid(story, body.blokUid)
  const finisherCollection = formFinisherCollection()

  for (const element of formBlok.finisher) {
    if (typeof finisherCollection[element.component] !== 'undefined') {
      status.finisher.push({
        component: element.component,
        result: await finisherCollection[element.component](
          body.state,
          formBlok,
          element,
          body.rt,
          runtimeConfig
        )
      })
    }
  }

  status.statusCode = getWorstStatusCode(status)
  return status
}

ts

Die Finisher werden bewusst nacheinander ausgeführt. Das hat für uns zwei Vorteile: Zum einen bleibt die Reihenfolge steuerbar, zum anderen können Ergebnisse aus einem Schritt an den nächsten übergeben werden.

Ein Beispiel: E-Mail an interne Empfänger

Ein typischer Finisher in unserem Projekt war der Versand einer E-Mail an interne Empfänger. Dabei konnten Betreff, Inhalt und Empfänger teilweise dynamisch anhand der Formulardaten aufgebaut werden.

Ein vereinfachter Ausschnitt:

export const useFormRecipientEmailFinisher = async (
  state,
  formBlok,
  finisherBlok,
  rt,
  runtimeConfig
) => {
  const recipients = await getEmailRecipients(state, formBlok, finisherBlok, runtimeConfig)

  const htmlContent = useStringTemplateResolver(finisherBlok.bodytext, {
    ...state.values,
    ...state.additionalVars,
    all: getAllVariablesTable(state, formBlok),
    referer: state.referer
  })

  const emailMessage = {
    senderAddress: runtimeConfig.sendMail.azureCommunicationService.senderAddress,
    content: {
      subject: useStringTemplateResolver(finisherBlok.subject, state.values),
      html: useEmailContentHtml(htmlContent)
    },
    recipients: { to: recipients }
  }

  return await useSendMail(emailMessage, runtimeConfig)
}

ts

Besonders hilfreich war hier, dass Inhalte nicht statisch sein mussten. Über Vorlagen konnten Werte aus dem Formular direkt in Betreff und Nachricht übernommen werden.

Bedingungen für Felder direkt im CMS steuern

Neben den Finishers haben wir auch die Sichtbarkeit einzelner Felder flexibel gestaltet. Felder können abhängig von anderen Eingaben ein- oder ausgeblendet werden. Auch diese Regeln werden in Storyblok gepflegt.

Dabei unterstützen wir verschiedene Operatoren wie zum Beispiel:

  • ist gesetzt

  • ist nicht gesetzt

  • ist gleich

  • ist ungleich

  • enthält Wert

  • ist größer als

  • ist kleiner als

Ein vereinfachter Ausschnitt aus der Logik:

getVisibilityForField(action, conjunction, rules) {
  if (action === 'hide') {
    return !this.calculateRules(conjunction, rules)
  }

  if (action === 'unhide') {
    return this.calculateRules(conjunction, rules)
  }
}

ts

So lassen sich Formulare stärker an konkrete Abläufe anpassen, ohne dass für jede Variante eigene Templates entwickelt werden müssen.

Schutz vor Spam nicht vergessen

Damit die Formulare nicht nur flexibel, sondern auch robust sind, haben wir mehrere Schutzmechanismen ergänzt.

Dazu gehörten unter anderem:

  • HMAC-Validierung

  • ein verstecktes Honeypot-Feld

  • eine zeitbasierte Erkennung sehr schneller Absendungen

Ein vereinfachtes Beispiel:

honeypot.value.validate()

const elapsedTime = Date.now() - startTime.value

if (elapsedTime < 2000) {
  spamDetection.value = true
  return
}

ts

Wird ein Formular unrealistisch schnell abgesendet, ist das oft ein Hinweis auf automatisierte Eingaben. Solche Fälle können auf diese Weise früh erkannt werden.

Daten zwischen Finishers weitergeben

Ein besonders praktischer Aspekt unseres Ansatzes war die Möglichkeit, Daten zwischen einzelnen Finishers zu übergeben. Über zusätzliche Statuswerte können spätere Schritte auf Ergebnisse früherer Schritte zugreifen.

So kann zum Beispiel ein Finisher zunächst einen Lead in Salesforce anlegen, während ein nachfolgender E-Mail-Finisher die dabei erzeugte Kennung direkt in eine Bestätigungsmail einbaut.

Gerade bei mehrstufigen Prozessen war das für uns sehr hilfreich.

Was sich für uns bewährt hat

Im Projekt hat sich vor allem gezeigt, dass Formulare deutlich wartbarer werden, wenn die Folgeprozesse sauber entkoppelt sind.

Folgeaktionen modular denken

Statt für jedes Formular eine eigene Logik zu bauen, konnten wir wiederverwendbare Finisher erstellen und je nach Bedarf kombinieren.

Redaktionelle Konfiguration stärkt die Flexibilität

Da die Finisher in Storyblok gepflegt werden, lassen sich viele Anwendungsfälle abbilden, ohne dass dafür jedes Mal Code angepasst werden muss.

Reihenfolge bewusst steuern

Die sequenzielle Ausführung hat sich bewährt, weil dadurch Abhängigkeiten zwischen einzelnen Schritten klar und kontrollierbar bleiben.

Unser Learning

Unser wichtigstes Learning war, dass Formulare deutlich flexibler und zukunftssicherer werden, wenn Folgeaktionen nicht fest mit dem Formular selbst verdrahtet sind, sondern über ein erweiterbares Finisher-System organisiert werden.

Mit einer zentralen Finisher-Sammlung, konfigurierbaren Folgeaktionen in Storyblok und der Möglichkeit, Daten zwischen einzelnen Schritten weiterzugeben, konnten wir sehr unterschiedliche Formularanforderungen mit einer gemeinsamen technischen Grundlage abdecken. Das erleichtert sowohl die Weiterentwicklung als auch die redaktionelle Pflege erheblich.