Compare commits

...
2 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 24c1d9c871 Release 1.22.3
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:56:07 +02:00
kantarciandClaude Opus 5 a976c146de 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 <noreply@anthropic.com>
2026-09-22 09:56:07 +02:00
15 changed files with 307 additions and 8 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.22.2
1.22.3
+12
View File
@@ -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!“
+8
View File
@@ -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: '✓ '; }
+26
View File
@@ -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: <div data-link-field> mit einem <input data-link-input> 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 });
+55 -1
View File
@@ -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();
}
+42
View File
@@ -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 });
+45
View File
@@ -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.');
+16
View File
@@ -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<string>, tag: list<string>} */
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
+42
View File
@@ -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
{
+2 -2
View File
@@ -66,11 +66,11 @@
</div>
<div>
<label for="categories-{{ page.id }}">Kategorien</label>
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen" data-term-input="{{ termNames.category|default([])|json_encode|e('html_attr') }}">
</div>
<div>
<label for="tags-{{ page.id }}">Schlagwörter</label>
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration" data-term-input="{{ termNames.tag|default([])|json_encode|e('html_attr') }}">
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
</div>
<div>
+1 -1
View File
@@ -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} %}
<label for="menu-anchor-{{ n }}">Sprungziel (optional)</label><input id="menu-anchor-{{ n }}" data-key="anchor" name="items[{{ n }}][anchor]" maxlength="65" value="{{ row.anchor }}" placeholder="kontakt">
{% else %}
<label for="menu-url-{{ n }}">Adresse</label><input id="menu-url-{{ n }}" data-key="url" name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt">
<label for="menu-url-{{ n }}">Adresse</label><div class="link-field" data-link-field><input id="menu-url-{{ n }}" data-key="url" data-link-input name="items[{{ n }}][url]" maxlength="2048" required value="{{ row.url }}" placeholder="https://… oder /kontakt"></div>
{% endif %}
<label class="check-label" for="menu-newTab-{{ n }}"><input id="menu-newTab-{{ n }}" type="checkbox" data-key="newTab" name="items[{{ n }}][newTab]" value="1"{{ row.newTab|default(false) ? ' checked' : '' }}> In neuem Tab öffnen</label>
<div class="menu-icon-fields"><div><label for="menu-icon-{{ n }}">Icon</label><select id="menu-icon-{{ n }}" data-key="icon" name="items[{{ n }}][icon]"><option value="">Kein Icon</option>{% for icon in menuIcons %}<option value="{{ icon }}"{{ row.icon|default('') == icon ? ' selected' : '' }}>{{ icon }}</option>{% endfor %}</select></div><div><label for="menu-iconPosition-{{ n }}">Icon-Position</label><select id="menu-iconPosition-{{ n }}" data-key="iconPosition" name="items[{{ n }}][iconPosition]"><option value="before"{{ row.iconPosition|default('before') == 'before' ? ' selected' : '' }}>Vor dem Text</option><option value="after"{{ row.iconPosition|default('before') == 'after' ? ' selected' : '' }}>Nach dem Text</option></select></div></div>
+2 -1
View File
@@ -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 %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/menu-editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{% include 'admin/menu-icons.html.twig' %}
@@ -16,7 +17,7 @@
</section>
<details class="card"><summary>Eigener Link</summary><div class="card-body">
<label for="link-label">Beschriftung</label><input id="link-label" maxlength="80" placeholder="Kontakt">
<label for="link-url">Adresse</label><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt">
<label for="link-url">Adresse</label><div class="link-field" data-link-field><input id="link-url" maxlength="2048" placeholder="https://… oder /kontakt" data-link-input></div>
<p class="field-help">Auch mailto: und tel: sind möglich.</p>
<label class="check-label" for="link-new-tab"><input id="link-new-tab" type="checkbox"> In neuem Tab öffnen</label>
<button type="button" class="secondary" id="menu-add-url">Link hinzufügen</button>
+1 -1
View File
@@ -5,7 +5,7 @@
{% block title %}{{ plural }}{% endblock %}
{% block heading %}{{ plural }}{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script><script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script><script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/term-chips.js?v={{ kant_version() }}"></script>{% endblock %}
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
{% block content %}
+2 -1
View File
@@ -3,7 +3,7 @@
{% block title %}Plugins{% endblock %}
{% block heading %}Plugins{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block scripts %}<script type="module" src="/assets/media-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/link-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block lead %}Erweiterungen bringen eigene Bausteine, Vorlagen und Adressen mit. Sie liegen als Ordner unter <code>plugins/</code>; 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 @@
<label for="{{ id }}">{{ schema.label }}</label>
{% if schema.kind == 'textarea' %}<textarea id="{{ id }}" name="{{ name }}" rows="3" maxlength="{{ schema.maxLength }}">{{ value }}</textarea>
{% elseif schema.kind == 'select' %}<select id="{{ id }}" name="{{ name }}">{% for wert, text in schema.choices %}<option value="{{ wert }}"{{ value == wert ? ' selected' : '' }}>{{ text }}</option>{% endfor %}</select>
{% elseif schema.kind == 'url' %}<div class="link-field" data-link-field><input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}" placeholder="/kontakt oder https://…" data-link-input></div>
{% elseif schema.kind == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
+52
View File
@@ -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('#<input id="plugin-probe-ziel" name="settings\[ziel\]" value="/kontakt"[^>]*data-link-input#', $link);
$bild = $render(['kind' => 'media', 'label' => 'Bild', 'help' => ''], '');
self::assertStringContainsString('data-media-field', $bild);
self::assertStringContainsString('<input type="hidden" id="plugin-probe-ziel" name="settings[ziel]" value="">', $bild);
self::assertStringNotContainsString('Kennung aus der Mediathek', $bild);
}
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
{
$client = $this->login('editor');