Files
KantSites/src/Controller/FormAdminController.php
T
kantarciandClaude Opus 5.5 e15b2517b1 Translate pages, areas, menus, forms and posts field by field instead of rebuilding them
A site can have further languages (Settings → Languages). A page is built once; each language
adds only its texts, keyed by the stable block IDs, plus its own address under /en/… (ADR 111,
document schema v27, migration 20261009000100 for page_translation).

- Editor: DE | EN switch, a list of all texts with original, state (missing, translated,
  original changed) and filter; structure stays with the main language. Undo, drafts,
  publishing and revisions cover all languages at once.
- Website: /en and translated slugs, lang, canonical, hreflang with x-default, og:locale,
  sitemap; links to translated pages follow the language; a language switch block.
- Header, footer and linked sections translate the same way; menu entries carry labels per
  language; fixed template texts come from a small dictionary.
- Forms: labels, help, options, button and success message per language; messages and the
  step script speak the language, the submission and the email stay in the main language.
- Posts, archives, category names, consent text and the 404 page follow the language.
- Single-language sites see none of it and never touch the new table.

The forms administration becomes an overview with one row per form and its own edit page;
fields are one line each with details collapsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:56:52 +02:00

147 lines
7.9 KiB
PHP

<?php
namespace App\Controller;
use App\Content\ButtonDesign;
use App\Content\FormRepository;
use App\Content\Languages;
use App\Content\MediaLibrary;
use App\Content\Theme;
use App\Domain\InvalidForm;
use App\Domain\StaleWrite;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class FormAdminController extends AbstractController
{
public function __construct(private FormRepository $forms, private Theme $theme, private MediaLibrary $media, private Languages $languages)
{
}
/**
* Die Übersicht: jedes Formular eine Zeile, dazu die Einsendungen. Bearbeitet wird ein
* Formular auf seiner eigenen Seite - mit mehreren Formularen war die eine lange Seite mit
* allen Editoren und Vorschauen nicht mehr zu überblicken (Umut, 09.10.2026).
*/
#[Route('/%kant.admin_path%/forms', name: 'admin_forms', methods: ['GET'])]
public function index(Request $request): Response
{
// Empfaenger, Felder und die Einsendungen selbst: nichts davon gehoert in die Hand einer
// Rolle, die nicht einmal eine Seite veroeffentlichen darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
return $this->render('admin/forms.html.twig', [
'forms' => $this->forms->all(),
'counts' => $this->forms->submissionCounts(),
'submissions' => $this->forms->submissions($request->query->getInt('seite', 1)),
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
]);
}
/** Ein Formular bearbeiten, oder mit der Adresse „neu“ eines anlegen. */
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_edit', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['GET'])]
public function edit(string $slug): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$current = 'neu' === $slug ? null : $this->forms->findBySlug($slug);
if ('neu' !== $slug && null === $current) {
throw $this->createNotFoundException('Dieses Formular gibt es nicht.');
}
$theme = $this->theme->load();
return $this->render('admin/forms.html.twig', [
'editing' => true,
'current' => $current,
'types' => FormRepository::TYPES,
'colors' => FormRepository::COLORS,
// Die Bilder fuer eine Bildauswahl: die Mediathek als Liste, damit je Option gewaehlt statt getippt wird.
'images' => array_map(static fn (array $item): array => ['id' => $item['id'], 'title' => $item['title'], 'src' => $item['variants'][0]['src'] ?? ''], $this->media->page(1, false, 96)['items']),
'languages' => array_map(fn (string $code): array => $this->languages->describe($code), $this->languages->extra()),
// Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website und
// kennt die Palette, damit eine Farbe gewaehlt statt getippt wird.
'schema' => ['colors' => $theme['colors'], 'fonts' => $theme['fonts'], 'buttons' => $theme['buttons'] ?? ButtonDesign::defaults($theme['base']), 'base' => $theme['base']],
]);
}
#[Route('/%kant.admin_path%/forms/{slug}', name: 'admin_form_save', requirements: ['slug' => '[a-z0-9-]{1,80}|neu'], methods: ['POST'])]
public function save(Request $request, string $slug): Response
{
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
if (!$this->isCsrfTokenValid('forms', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$fields = [];
// Je weitere Sprache: Felder tr_<sprache>_label, _help, _unit, _options am Feld, dazu
// success_<sprache> und buttonLabel_<sprache> am Formular (ADR 111). Die Optionen stehen
// als Kommaliste in derselben Reihenfolge wie die der Hauptsprache.
$translations = [];
foreach ($this->languages->extra() as $code) {
$translations[$code] = ['success' => $request->request->getString('success_'.$code), 'buttonLabel' => $request->request->getString('buttonLabel_'.$code), 'fields' => []];
}
foreach ($request->request->all('fields') as $row) {
if (!is_array($row) || '' === trim((string) ($row['label'] ?? ''))) {
continue;
}
$originals = array_map(trim(...), explode(',', (string) ($row['options'] ?? '')));
foreach (array_keys($translations) as $code) {
$translatedOptions = array_map(trim(...), explode(',', (string) ($row['tr_'.$code.'_options'] ?? '')));
$translations[$code]['fields'][(string) ($row['name'] ?? '')] = [
'label' => (string) ($row['tr_'.$code.'_label'] ?? ''),
'help' => (string) ($row['tr_'.$code.'_help'] ?? ''),
'unit' => (string) ($row['tr_'.$code.'_unit'] ?? ''),
'options' => array_combine($originals, array_pad(array_slice($translatedOptions, 0, count($originals)), count($originals), '')),
];
}
$fields[] = [
'name' => (string) ($row['name'] ?? ''),
'type' => (string) ($row['type'] ?? 'text'),
'label' => (string) $row['label'],
'required' => '' !== (string) ($row['required'] ?? ''),
'help' => (string) ($row['help'] ?? ''),
'options' => array_map(trim(...), explode(',', (string) ($row['options'] ?? ''))),
'images' => (string) ($row['images'] ?? ''),
'min' => (string) ($row['min'] ?? ''),
'max' => (string) ($row['max'] ?? ''),
'unit' => (string) ($row['unit'] ?? ''),
// Die Bedingung: technischer Name eines früheren Auswahlfeldes und die Werte, bei
// denen dieses Feld gilt, als Kommaliste.
'showIfField' => (string) ($row['showIfField'] ?? ''),
'showIfValues' => (string) ($row['showIfValues'] ?? ''),
];
}
try {
$this->forms->save(
'neu' === $slug ? $request->request->getString('slug') : $slug,
$request->request->getString('name'),
$fields,
[
'recipient' => $request->request->getString('recipient'),
'sender' => $request->request->getString('sender'),
'subject' => $request->request->getString('subject'),
'success' => $request->request->getString('success'),
'replyToField' => $request->request->getString('replyToField'),
'buttonLabel' => $request->request->getString('buttonLabel'),
'summary' => '' !== $request->request->getString('summary'),
'translations' => $translations,
...array_map(fn (string $field): string => $request->request->getString($field), array_combine(array_keys(FormRepository::COLORS), array_keys(FormRepository::COLORS))),
],
$request->request->getInt('expectedVersion', -1),
);
} catch (InvalidForm|StaleWrite $e) {
return $this->back($e->getMessage(), 'error', $slug);
}
return $this->back('Das Formular wurde gespeichert.', 'success', 'neu' === $slug ? $request->request->getString('slug') : $slug);
}
private function back(string $message, string $type = 'error', ?string $slug = null): Response
{
$this->addFlash($type, $message);
// Zurück zum bearbeiteten Formular; ohne Adresse (abgelaufene Sitzung) zur Übersicht.
return null === $slug ? $this->redirectToRoute('admin_forms') : $this->redirectToRoute('admin_form_edit', ['slug' => $slug]);
}
}