From a976c146de63479e4daaaebb033ccee88bb49ea3 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Tue, 22 Sep 2026 09:56:07 +0200 Subject: [PATCH] Choose menu anchors, link targets in plugin settings and existing post terms A menu entry picks its anchor from the linked page, link settings of plugins and own menu links get page and PDF choice next to the address, and the post settings offer existing categories and tags to click. Co-Authored-By: Claude Opus 5 --- docs/STATUS.md | 12 +++++ public/assets/admin.css | 8 ++++ public/assets/link-field.js | 26 +++++++++++ public/assets/menu-editor.js | 56 ++++++++++++++++++++++- public/assets/term-chips.js | 42 +++++++++++++++++ scripts/editor-check.mjs | 45 ++++++++++++++++++ src/Controller/EditorController.php | 16 +++++++ src/Controller/MenuController.php | 42 +++++++++++++++++ templates/admin/content-results.html.twig | 4 +- templates/admin/menu-entry.html.twig | 2 +- templates/admin/menus.html.twig | 3 +- templates/admin/pages.html.twig | 2 +- templates/admin/plugins.html.twig | 3 +- tests/EditorSecurityTest.php | 52 +++++++++++++++++++++ 14 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 public/assets/link-field.js create mode 100644 public/assets/term-chips.js diff --git a/docs/STATUS.md b/docs/STATUS.md index ebc7a03..21d70e7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026) + +**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2. + +- **Sprungziel im Menü:** Eine Auswahl aus den Ankern der verknüpften Seite, beschriftet mit der ersten Überschrift des Bausteins, etwa „So erreichen Sie uns (#kontakt)“. Die Liste liefert `GET /admin/api/sprungziele/{id}` (Rolle wie die Menüs). Wechselt die Seite, lädt die Liste neu. Ein gespeicherter Anker, den es nicht mehr gibt, bleibt als „(auf der Seite nicht gefunden)“ sichtbar. Ohne Skript bleibt das Textfeld. +- **Linkfelder in der Plugin-Verwaltung und die Adresse eines eigenen Menülinks:** Das Textfeld bleibt, weil eine fremde Adresse getippt wird. Daneben stehen „Seite wählen“ und „PDF wählen“ (`link-field.js`, Markup `data-link-field`/`data-link-input`). +- **Kategorien und Schlagwörter eines Beitrags:** Die vorhandenen Namen stehen als Knöpfe unter dem Feld. Ein Klick nimmt einen Namen auf oder heraus (`term-chips.js`), neue Namen tippt man weiter ein. So entstehen nicht „Projekt“ und „Projekte“ nebeneinander. + +**Bekannt, nicht neu:** axe meldet im Menü-Editor `nested-interactive`, weil der Ziehknopf in der Zusammenfassung jedes Eintrags steckt. Die Editor-Prüfung prüft dort nur die neuen Felder. + +**Geprüft:** neue Tests `EditorSecurityTest::testMenuAnchorsAndPostTermsAreOfferedForChoosing` und `…testPluginSettingsForLinksAndImagesAreChosenNotTyped`. Die Editor-Prüfung wählt im Menü eine Seite als eigenen Link, prüft die Sprungziel-Auswahl eines neuen Seitenlinks, legt einen Beitrag mit Kategorie an und nimmt die Kategorie per Klick auf und wieder heraus (jeweils mit axe). + ## Fassung 1.22.2: Auswahl statt abgetippter Kennungen und Adressen (22.09.2026) **Anlass:** Umut zu den Beitragseinstellungen: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild“, dann: „Andere bereiche prüfen ob es sowas noch gibt!“ diff --git a/public/assets/admin.css b/public/assets/admin.css index 8eb63be..2aa7b88 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -990,3 +990,11 @@ details.card > summary:hover { background: #fafbf8 } .media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb } .media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 } .media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px } +/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */ +.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px } +.link-field-actions .text-button { padding: 4px 0; font-size: 13px } +/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */ +.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px } +.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer } +.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 } +.term-chip[aria-pressed="true"]::before { content: '✓ '; } diff --git a/public/assets/link-field.js b/public/assets/link-field.js new file mode 100644 index 0000000..21f6534 --- /dev/null +++ b/public/assets/link-field.js @@ -0,0 +1,26 @@ +import { openPageDialog } from './page-picker.js'; +import { openDocuments } from './media.js'; + +/* + * Ein Linkfeld in der Verwaltung: die Adresse bleibt ein Textfeld - eine fremde Seite tippt man + * ein -, daneben „Seite wählen“ und „PDF wählen“. Ohne Skript bleibt das Textfeld allein. + * + * Markup:
mit einem darin. Felder, die später + * erscheinen (neue Menüeinträge), bekommen ihre Knöpfe beim ersten Fokus. + */ +const enhance = field => { + if (field.dataset.linkReady) return; + const input = field.querySelector('[data-link-input]'); + if (!input) return; + field.dataset.linkReady = '1'; + const row = document.createElement('div'); row.className = 'link-field-actions'; + const choose = value => { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); }; + const page = document.createElement('button'); page.type = 'button'; page.className = 'text-button'; page.textContent = 'Seite wählen'; + page.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) })); + const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'text-button'; pdf.textContent = 'PDF wählen'; + pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) })); + row.append(page, pdf); input.after(row); +}; +const all = () => document.querySelectorAll('[data-link-field]').forEach(enhance); +all(); +new MutationObserver(all).observe(document.body, { childList: true, subtree: true }); diff --git a/public/assets/menu-editor.js b/public/assets/menu-editor.js index 55049a2..0a68f1b 100644 --- a/public/assets/menu-editor.js +++ b/public/assets/menu-editor.js @@ -1,4 +1,5 @@ import { setupPicker } from './page-picker.js'; +import './link-field.js'; const form = document.querySelector('#menu-editor'); const list = form.querySelector('#menu-items'); @@ -86,7 +87,8 @@ function createRow(data) { picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = ''; body.append(picker); setupPicker(picker); field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65; - } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; } + anchorChoice(row); + } else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); } const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1'; for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) { const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`; @@ -101,6 +103,58 @@ function createRow(data) { row.append(actions); return row; } +/* + * Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was + * gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen. + */ +const adminBase = document.querySelector('meta[name="admin-base"]').content; +function anchorChoice(row) { + const input = row.querySelector('input[data-key="anchor"]'); + const pageValue = row.querySelector('.picker-value'); + if (!input || !pageValue || input.dataset.enhanced) return; + input.dataset.enhanced = '1'; + const label = row.querySelector(`label[for="${input.id}"]`); + const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden'; + if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; } + input.after(select); + const help = el('p', 'field-help', ''); select.after(help); + let request = 0; + const load = async () => { + const mine = ++request, current = input.value; + select.replaceChildren(new Option('Seitenanfang', '')); + if (current) select.append(new Option(`#${current}`, current)); + select.value = current; + help.textContent = ''; + if (!pageValue.value) { select.disabled = true; return; } + select.disabled = false; + try { + const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' }); + if (!response.ok || response.redirected) throw new Error(); + const { items } = await response.json(); + if (mine !== request) return; + select.replaceChildren(new Option('Seitenanfang', '')); + for (const item of items) select.append(new Option(item.label, item.anchor)); + if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current)); + select.value = current; + help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor bekommt eine Sektion unter „Erweitert“ einen Anker.'; + } catch { + if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.'; + } + }; + select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); }); + pageValue.addEventListener('change', () => { input.value = ''; load(); }); + load(); +} +list.querySelectorAll('.menu-entry').forEach(anchorChoice); +function linkButtons(input) { + // Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen. + if (input.closest('[data-link-field]')) return; + input.dataset.linkInput = ''; + const box = el('div', 'link-field'); box.dataset.linkField = ''; + input.before(box); box.append(input); +} +list.querySelectorAll('input[data-key="url"]').forEach(linkButtons); + function restore(value) { form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state(); } diff --git a/public/assets/term-chips.js b/public/assets/term-chips.js new file mode 100644 index 0000000..e33f6f0 --- /dev/null +++ b/public/assets/term-chips.js @@ -0,0 +1,42 @@ +/* + * Kategorien und Schlagwörter eines Beitrags: das Textfeld mit der Liste durch Komma bleibt, was + * gespeichert wird. Darunter stehen die vorhandenen Namen zum Anklicken - ein Klick nimmt einen + * Namen auf oder heraus. Neue Namen tippt man weiter ein. + * + * Über den Dokumentknoten, weil die Suche in der Inhaltsliste die Ergebnisse austauscht. + */ +const split = value => value.split(',').map(name => name.trim()).filter(Boolean); +const same = (a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }) === 0; + +const enhance = input => { + if (input.dataset.termReady) return; + let names = []; + try { names = JSON.parse(input.dataset.termInput || '[]'); } catch { names = []; } + input.dataset.termReady = '1'; + if (!names.length) return; + const box = document.createElement('div'); box.className = 'term-chips'; box.setAttribute('role', 'group'); + const label = document.querySelector(`label[for="${input.id}"]`)?.textContent || 'Vorhandene Namen'; + box.setAttribute('aria-label', `${label}: vorhandene zum Übernehmen`); + const sync = () => { + const chosen = split(input.value); + for (const chip of box.children) chip.setAttribute('aria-pressed', String(chosen.some(name => same(name, chip.dataset.name)))); + }; + for (const name of names) { + const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'term-chip'; chip.dataset.name = name; chip.textContent = name; + chip.addEventListener('click', () => { + const chosen = split(input.value); + const next = chosen.some(entry => same(entry, name)) ? chosen.filter(entry => !same(entry, name)) : [...chosen, name]; + input.value = next.join(', '); + input.dispatchEvent(new Event('input', { bubbles: true })); + sync(); + }); + box.append(chip); + } + input.addEventListener('input', sync); + const help = input.parentElement.querySelector('.field-help'); + (help ?? input).after(box); + sync(); +}; +const all = () => document.querySelectorAll('[data-term-input]').forEach(enhance); +all(); +new MutationObserver(all).observe(document.body, { childList: true, subtree: true }); diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 12ede30..d0748ce 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -1182,6 +1182,51 @@ try { assert.equal(await gespeichert.locator('input[name="replyToField"]').inputValue(), 'e_mail'); assert.equal(await gespeichert.locator('[data-field-showif]').nth(2).inputValue(), 'art'); checks.push({ name: 'form editor: condition, reply address and option images are picked', passed: true }); + // Menü: der eigene Link bekommt eine Seite per Suche, ein Seitenlink sein Sprungziel per Auswahl. + await page.goto(origin + '/admin/menus'); + await page.locator('.menu-add details summary').click(); + await page.locator('#link-url').locator('xpath=..').getByRole('button', { name: 'Seite wählen', exact: true }).click(); + await page.locator('dialog.page-dialog [role="option"]').first().click(); + await page.locator('dialog.page-dialog').waitFor({ state: 'detached' }); + assert.match(await page.locator('#link-url').inputValue(), /^\/[a-z0-9\-/]*$/); + await page.locator('#menu-search').focus(); + await page.locator('#menu-search-results [role="option"]').first().click(); + await page.locator('#menu-add-page').click(); + const neuerEintrag = page.locator('#menu-items .menu-entry').last(); + const sprungziel = neuerEintrag.getByLabel('Sprungziel', { exact: true }); + assert.equal(await sprungziel.evaluate(node => node.tagName), 'SELECT', 'the anchor is a choice'); + assert.equal(await sprungziel.locator('option').first().textContent(), 'Seitenanfang'); + // Nur die neuen Felder: die Zusammenfassung der Menüeinträge trägt seit jeher einen Ziehknopf + // (nested-interactive), das ist eine eigene, bekannte Baustelle. + await neuerEintrag.locator('summary').click(); + const menuAxe = await new AxeBuilder({ page }).include('#menu-items .menu-entry:last-child .menu-entry-fields').include('.menu-add').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + assert.deepEqual(menuAxe.violations.map(v => v.id), [], 'anchor choice and link field'); + checks.push({ name: 'menu: page search for own links, anchors as a choice', passed: true }); + // Beitrag: vorhandene Kategorien stehen zum Anklicken am Feld. + await page.goto(origin + '/admin/posts'); + await page.locator('summary').filter({ hasText: 'Neuen Beitrag anlegen' }).click(); + await page.locator('#new-title').fill('Prüfbeitrag'); + await page.locator('form.create-form button[type="submit"]').click(); + await page.waitForLoadState('load'); + await page.goto(origin + '/admin/posts'); + let beitrag = page.locator('details.sheet').first(); + await beitrag.locator('summary').click(); + await beitrag.locator('input[name="categories"]').fill('Projekte'); + await beitrag.locator('input[name="categories"]').locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click(); + await page.waitForLoadState('load'); + await page.goto(origin + '/admin/posts'); + beitrag = page.locator('details.sheet').first(); + await beitrag.locator('summary').click(); + const kategorie = beitrag.locator('input[name="categories"]'); + await kategorie.fill(''); + const vorschlag = beitrag.locator('.term-chip', { hasText: 'Projekte' }).first(); + await vorschlag.click(); + assert.equal(await kategorie.inputValue(), 'Projekte'); + assert.equal(await vorschlag.getAttribute('aria-pressed'), 'true'); + await vorschlag.click(); + assert.equal(await kategorie.inputValue(), ''); + await axe('post settings with term suggestions'); + checks.push({ name: 'post settings suggest existing categories', passed: true }); assert.deepEqual(errors, []); await writeFile('reports/editor/result.json', JSON.stringify({ checks, publicDataUntouched: true }, null, 2)); console.log('Editor end-to-end checks passed; isolated database, production data unchanged.'); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 0c9adc7..812174f 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -64,6 +64,9 @@ final class EditorController extends AbstractController 'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status, 'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages', 'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')], + // Vorhandene Kategorien und Schlagwoerter als Vorschlag - sonst entstehen „Projekt“ + // und „Projekte“ nebeneinander. + 'termNames' => 'post' === $type ? $this->termNames() : ['category' => [], 'tag' => []], ]; if ($request->isXmlHttpRequest()) { return $this->json([ @@ -118,6 +121,19 @@ final class EditorController extends AbstractController return $this->redirectToRoute('admin_editor', ['id' => $id]); } + /** @return array{category: list, tag: list} */ + private function termNames(): array + { + $names = ['category' => [], 'tag' => []]; + foreach ($this->posts->allTerms() as $term) { + if (isset($names[$term['kind']])) { + $names[$term['kind']][] = $term['name']; + } + } + + return $names; + } + /** The editor reads the style vocabulary and the palette from here, so both stay in one place. */ #[Route('/api/design', name: 'admin_design', methods: ['GET'])] public function design(): JsonResponse diff --git a/src/Controller/MenuController.php b/src/Controller/MenuController.php index 9119821..6ac152f 100644 --- a/src/Controller/MenuController.php +++ b/src/Controller/MenuController.php @@ -7,6 +7,7 @@ use App\Content\PageRepository; use App\Domain\InvalidMenu; use App\Domain\StaleWrite; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -17,6 +18,47 @@ final class MenuController extends AbstractController { } + /** + * Die Sprungziele einer Seite, fuer die Auswahl im Menue: jeder Baustein mit eigenem Anker, + * beschriftet mit seiner ersten Ueberschrift. Wer ein Sprungziel setzt, soll es waehlen und + * nicht wissen muessen, wie es im Editor hiess. + */ + #[Route('/%kant.admin_path%/api/sprungziele/{id}', name: 'admin_menu_anchors', requirements: ['id' => '[A-Za-z0-9_-]{1,64}'], methods: ['GET'])] + public function anchors(string $id): JsonResponse + { + $this->denyAccessUnlessGranted('ROLE_PUBLISHER'); + $page = $this->pages->findForEditor($id); + if (null === $page) { + return $this->json(['items' => []], 404); + } + $items = []; + $heading = static function (array $block) use (&$heading): string { + if ('heading' === ($block['type'] ?? null)) { + return trim(html_entity_decode(strip_tags((string) ($block['props']['text'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + foreach ($block['children'] ?? [] as $child) { + $text = $heading($child); + if ('' !== $text) { + return $text; + } + } + + return ''; + }; + $walk = static function (array $blocks) use (&$walk, &$items, $heading): void { + foreach ($blocks as $block) { + if (is_string($block['anchor'] ?? null) && '' !== $block['anchor']) { + $title = $heading($block); + $items[] = ['anchor' => $block['anchor'], 'label' => '' === $title ? '#'.$block['anchor'] : mb_strimwidth($title, 0, 70, '…').' (#'.$block['anchor'].')']; + } + $walk($block['children'] ?? []); + } + }; + $walk($page['document']['blocks'] ?? []); + + return $this->json(['items' => $items]); + } + #[Route('/%kant.admin_path%/menus', name: 'admin_menus', methods: ['GET'])] public function index(Request $request): Response { diff --git a/templates/admin/content-results.html.twig b/templates/admin/content-results.html.twig index 06f5689..ae7d237 100644 --- a/templates/admin/content-results.html.twig +++ b/templates/admin/content-results.html.twig @@ -66,11 +66,11 @@
- +
- +

Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.

diff --git a/templates/admin/menu-entry.html.twig b/templates/admin/menu-entry.html.twig index 4d1a069..35c4e2d 100644 --- a/templates/admin/menu-entry.html.twig +++ b/templates/admin/menu-entry.html.twig @@ -10,7 +10,7 @@ {% include 'admin/content-picker.html.twig' with {id: 'menu-target-' ~ n, name: 'items[' ~ n ~ '][pageId]', label: 'Verknüpfter Inhalt', selected: row.page} %} {% else %} - + {% endif %} diff --git a/templates/admin/menus.html.twig b/templates/admin/menus.html.twig index ca94b23..1a19b93 100644 --- a/templates/admin/menus.html.twig +++ b/templates/admin/menus.html.twig @@ -3,6 +3,7 @@ {% block title %}Menüs{% endblock %} {% block heading %}Menüs{% endblock %} {% block lead %}Inhalte suchen und hinzufügen. Rechts bestimmst du Beschriftung, Reihenfolge und Unterpunkte.{% endblock %} +{% block styles %}{% endblock %} {% block scripts %}{% endblock %} {% block content %} {% include 'admin/menu-icons.html.twig' %} @@ -16,7 +17,7 @@
Eigener Link
- +

Auch mailto: und tel: sind möglich.

diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 82cfd81..016dcf4 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -5,7 +5,7 @@ {% block title %}{{ plural }}{% endblock %} {% block heading %}{{ plural }}{% endblock %} {% block styles %}{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} {% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %} {% block content %} diff --git a/templates/admin/plugins.html.twig b/templates/admin/plugins.html.twig index fa432e1..70e931a 100644 --- a/templates/admin/plugins.html.twig +++ b/templates/admin/plugins.html.twig @@ -3,7 +3,7 @@ {% block title %}Plugins{% endblock %} {% block heading %}Plugins{% endblock %} {% block styles %}{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} {% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter plugins/; hier schaltest du sie ein und aus und stellst sie ein. Ein ausgeschaltetes Plugin lässt seine Bausteine in den Seiten stehen, zeigt sie aber nicht mehr.{% endblock %} {# Ein Feld aus dem Schema eines Plugins - dieselben sieben Arten wie im Editor. #} @@ -19,6 +19,7 @@ {% if schema.kind == 'textarea' %} {% elseif schema.kind == 'select' %} + {% elseif schema.kind == 'url' %} {% elseif schema.kind == 'number' %} {% else %}{% endif %} {% if schema.help %}

{{ schema.help }}

{% endif %} diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index 307eb34..a5061b8 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -706,6 +706,58 @@ final class EditorSecurityTest extends WebTestCase self::assertSame(['umbau' => 'Umbau'], $choices['tags']); } + public function testMenuAnchorsAndPostTermsAreOfferedForChoosing(): void + { + $media = new MediaLibrary($this->db, $this->path.'-media'); + $pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media)); + $document = DemoContent::starter('Leistungen'); + $document['schemaVersion'] = 21; + $document['blocks'][] = ['id' => 'kontakt-sektion', 'type' => 'section', 'anchor' => 'kontakt', 'children' => [ + ['id' => 'kontakt-titel', 'type' => 'heading', 'props' => ['text' => 'So erreichen Sie uns', 'level' => 2]], + ]]; + $id = $pages->createPage('leistungen', null, 'page', $document); + + // Die Sprungziele einer Seite, beschriftet mit ihrer Überschrift - nur für Rollen, die + // Menüs bearbeiten. + $client = $this->login('editor'); + $client->request('GET', '/admin/api/sprungziele/'.$id); + self::assertResponseStatusCodeSame(403); + $client = $this->login('admin', $client); + $client->request('GET', '/admin/api/sprungziele/'.$id); + self::assertResponseIsSuccessful(); + self::assertSame([['anchor' => 'kontakt', 'label' => 'So erreichen Sie uns (#kontakt)']], json_decode($client->getResponse()->getContent(), true)['items']); + $client->request('GET', '/admin/api/sprungziele/gibt-es-nicht'); + self::assertResponseStatusCodeSame(404); + + // Vorhandene Kategorien und Schlagwörter stehen am Feld, damit man sie wählt statt neu tippt. + $post = $pages->createPage('beitrag', null, 'post', DemoContent::pages()['home']); + (new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']); + $crawler = $client->request('GET', '/admin/posts'); + self::assertSame(['Projekte'], json_decode($crawler->filter('input[name="categories"]')->attr('data-term-input'), true)); + self::assertSame(['Umbau'], json_decode($crawler->filter('input[name="tags"]')->attr('data-term-input'), true)); + self::assertCount(1, $crawler->filter('script[src^="/assets/term-chips.js"]')); + } + + public function testPluginSettingsForLinksAndImagesAreChosenNotTyped(): void + { + // Das Feld einer Plugin-Einstellung, direkt aus dem Makro der Plugin-Seite: ein Link + // bleibt ein Textfeld mit Knöpfen daneben, ein Bild wird das Bildfeld der Mediathek. + self::bootKernel(); + $twig = static::getContainer()->get('twig'); + $render = static fn (array $schema, string $value): string => $twig->createTemplate( + "{% import 'admin/plugins.html.twig' as f %}{{ f.feld('probe', 'ziel', schema, value) }}", + )->render(['schema' => $schema, 'value' => $value]); + + $link = $render(['kind' => 'url', 'label' => 'Ziel', 'maxLength' => 200, 'help' => ''], '/kontakt'); + self::assertStringContainsString('data-link-field', $link); + self::assertMatchesRegularExpression('#]*data-link-input#', $link); + + $bild = $render(['kind' => 'media', 'label' => 'Bild', 'help' => ''], ''); + self::assertStringContainsString('data-media-field', $bild); + self::assertStringContainsString('', $bild); + self::assertStringNotContainsString('Kennung aus der Mediathek', $bild); + } + public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void { $client = $this->login('editor');