Pick images, pages, forms, sections and terms instead of typing ids and addresses
The share image and featured image of a page become image fields with the library dialog, as do image settings of plugins. The editor offers forms, sections, categories and tags as lists, a page search next to every link target and in text links, the redirect target has a page search, and the form editor picks the reply field, the condition and the option images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31fb53c1fc
commit
711af312af
19 files changed
+444
-41
No files matched your search
@@ -1,5 +1,22 @@
|
||||
# Projektstatus
|
||||
|
||||
## 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!“
|
||||
|
||||
Die ganze Verwaltung und der Editor wurden nach Feldern durchsucht, in die man eine Kennung, eine Adresse oder einen technischen Namen tippen musste, der auf etwas anderes zeigt. Umgebaut:
|
||||
|
||||
- **Vorschaubild beim Teilen und Beitragsbild** (Seiten und Beiträge → Einstellungen): Bildfeld mit Vorschau, „Bild wählen oder hochladen“ im Mediathek-Dialog, „Entfernen“ (`admin/media-field.html.twig`, `media-field.js`, Twig-Funktion `kant_media`). Die Kennung steht nur noch im versteckten Feld.
|
||||
- **Plugin-Einstellungen der Art Bild:** dasselbe Bildfeld statt „Kennung aus der Mediathek“.
|
||||
- **Editor, Formular-Baustein, Sektions-Baustein, Beitragsliste (Kategorie, Schlagwort):** Auswahllisten statt Adressen. Die Listen kommen mit `/api/design` (`choices`). Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt als „(nicht mehr vorhanden)“ sichtbar.
|
||||
- **Linkziele im Editor** (Button, Bild, Symbolkarte, CTA, Symbol, Link-Felder von Plugins) und **Links im Textbaustein:** „Seite wählen“ mit Seitensuche (`openPageDialog` in `page-picker.js`) neben „PDF aus Mediathek wählen“. Freie Adressen bleiben möglich. Der Schalter „In neuem Tab öffnen“ erscheint bei Plugin-Feldern nicht mehr, deren Schema kennt ihn nicht.
|
||||
- **Weiterleitungen, neue Adresse:** Seitensuche. Ein anderer Pfad (etwa `kategorie/projekte`) lässt sich weiter eintippen (`data-free` im Seitenwähler).
|
||||
- **Formular-Editor:** „Antwortadresse aus Feld“ wählt aus den E-Mail-Feldern, „Nur zeigen, wenn“ aus den Auswahlfeldern weiter oben, mit Kästchen für die Antworten. „Bild je Option“ öffnet den Mediathek-Dialog mit Vorschau statt einer Aufklappliste. Ohne Skript bleiben die Textfelder, die gespeichert werden.
|
||||
|
||||
**Bewusst getippt bleibt:** was an der Stelle erst entsteht (Adresse einer neuen Seite, eines Formulars, einer Sektion, Farb- und Schriftschlüssel, technischer Feldname, Anker), die alte Adresse einer Weiterleitung, Einbettungsadressen, Schlüssel für Turnstile. Noch offen: Link-Einstellungen von Plugins in der Plugin-Verwaltung (Textfeld), Sprungziel im Menü, Vorschläge vorhandener Kategorien und Schlagwörter in den Beitragseinstellungen.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId` und `…testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses`. Die Editor-Prüfung (`npm run test:editor`) wählt jetzt im Browser ein Vorschaubild und speichert es. Sie wählt eine Seite als Linkziel (mit axe) und prüft, dass Formular, Kategorie und Schlagwort Auswahlfelder sind. Im Formular-Editor wählt sie Bedingung, Antwortadresse und ein Optionsbild, legt das Formular an und liest es wieder (mit axe).
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
+15
-2
@@ -898,8 +898,14 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker select { margin: 0 }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
@@ -977,3 +983,10 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.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 }
|
||||
+31
-9
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -330,13 +331,19 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') {
|
||||
if (key === 'href' || options.link) {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => { input.value = item.src; applyValue(); history.mergeKey = null; } }));
|
||||
container.append(pdf);
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||||
container.append(row);
|
||||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
if (!block) {
|
||||
@@ -346,6 +353,21 @@ function renderContent(block, panel) {
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
/*
|
||||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||||
*/
|
||||
const pick = (kind, current, empty = null) => {
|
||||
const list = designSchema?.choices?.[kind];
|
||||
if (!list) return {};
|
||||
const choices = {};
|
||||
if (empty !== null) choices[''] = empty;
|
||||
else if (!current) choices[''] = 'Bitte wählen …';
|
||||
Object.assign(choices, list);
|
||||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||||
return { choices };
|
||||
};
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -410,7 +432,7 @@ function renderContent(block, panel) {
|
||||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
@@ -490,7 +512,7 @@ function renderContent(block, panel) {
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
@@ -526,8 +548,8 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
@@ -774,7 +796,7 @@ function renderPluginFields(block, panel, plugin, field) {
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength });
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-13
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
@@ -115,8 +116,10 @@ if (root) {
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option eine Liste der Mediathek. Das Textfeld bleibt das
|
||||
// gespeicherte Original und wird aus den Listen geschrieben; ohne Skript tippt man hinein.
|
||||
// Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit
|
||||
// Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der
|
||||
// Auswahl geschrieben; ohne Skript tippt man hinein.
|
||||
const bildVon = new Map(bilder.map(bild => [bild.id, bild]));
|
||||
const renderImagePickers = card => {
|
||||
const area = card.querySelector('[data-field-images-input]');
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
@@ -129,23 +132,124 @@ if (root) {
|
||||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||||
box.dataset.signature = options.join('|');
|
||||
box.replaceChildren();
|
||||
for (const option of options) {
|
||||
const row = el('label', 'field-image-picker');
|
||||
row.append(el('span', 'field-image-picker-option', option));
|
||||
const select = document.createElement('select');
|
||||
select.append(new Option('Kein Bild', ''));
|
||||
for (const bild of bilder) select.append(new Option(bild.title, bild.id, false, current[option] === bild.id));
|
||||
select.dataset.option = option;
|
||||
select.addEventListener('change', () => {
|
||||
const write = () => {
|
||||
const map = {};
|
||||
for (const picker of box.querySelectorAll('select')) if (picker.value) map[picker.dataset.option] = picker.value;
|
||||
for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
});
|
||||
row.append(select); box.append(row);
|
||||
};
|
||||
for (const option of options) {
|
||||
const row = el('div', 'field-image-picker'); row.dataset.option = option;
|
||||
const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48;
|
||||
const name = el('span', 'field-image-picker-name');
|
||||
const choose = el('button', 'secondary', ''); choose.type = 'button';
|
||||
const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button';
|
||||
remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`);
|
||||
const show = id => {
|
||||
row.dataset.media = id || '';
|
||||
const bild = id ? bildVon.get(id) : null;
|
||||
thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src');
|
||||
name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild';
|
||||
choose.textContent = id ? 'Anderes Bild' : 'Bild wählen';
|
||||
choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`);
|
||||
remove.hidden = !id;
|
||||
};
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => {
|
||||
if (!item) return;
|
||||
bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src });
|
||||
show(item.id); write();
|
||||
} }));
|
||||
remove.addEventListener('click', () => { show(''); write(); choose.focus(); });
|
||||
show(current[option] || '');
|
||||
row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und
|
||||
* dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das
|
||||
* gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben.
|
||||
*/
|
||||
const fieldsBefore = card => {
|
||||
const list = [];
|
||||
for (const other of cards()) {
|
||||
if (other === card) break;
|
||||
const name = other.querySelector('[data-field-name]').value.trim();
|
||||
const type = other.querySelector('[data-field-type]').value;
|
||||
if (name && ['select', 'radio', 'checkboxes'].includes(type)) {
|
||||
list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name,
|
||||
options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
};
|
||||
const renderCondition = card => {
|
||||
const fieldInput = card.querySelector('[data-field-showif]');
|
||||
const valuesInput = card.querySelector('[data-field-showvalues]');
|
||||
const candidates = fieldsBefore(card);
|
||||
const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]);
|
||||
let box = card.querySelector('[data-condition-picker]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = '';
|
||||
fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true;
|
||||
fieldInput.closest('[data-field-condition]').prepend(box);
|
||||
const help = box.parentElement.querySelector(':scope > .field-help');
|
||||
if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.';
|
||||
}
|
||||
if (box.dataset.signature === signature) return;
|
||||
box.dataset.signature = signature;
|
||||
const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
||||
box.replaceChildren();
|
||||
const id = `${fieldInput.id}-select`;
|
||||
const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id;
|
||||
const select = document.createElement('select'); select.id = id; select.dataset.key = 'field';
|
||||
select.append(new Option('Immer zeigen', ''));
|
||||
for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name));
|
||||
if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value));
|
||||
select.value = fieldInput.value;
|
||||
select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); });
|
||||
box.append(label, select);
|
||||
const chosen = candidates.find(c => c.name === fieldInput.value);
|
||||
if (chosen) {
|
||||
const set = document.createElement('fieldset'); set.className = 'field-condition-values';
|
||||
set.append(el('legend', '', '… eine dieser Antworten gewählt ist'));
|
||||
const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
chosen.options.forEach((option, index) => {
|
||||
const check = el('label', 'check-label');
|
||||
const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option);
|
||||
box2.dataset.key = `value-${index}`;
|
||||
box2.addEventListener('change', () => {
|
||||
valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', ');
|
||||
refresh();
|
||||
});
|
||||
check.append(box2, document.createTextNode(` ${option}`)); set.append(check);
|
||||
});
|
||||
box.append(set);
|
||||
}
|
||||
if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus();
|
||||
};
|
||||
const reply = form.querySelector('[name="replyToField"]');
|
||||
const renderReply = () => {
|
||||
if (!reply) return;
|
||||
const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value }))
|
||||
.filter(field => field.name && field.type === 'email');
|
||||
let select = form.querySelector('[data-reply-picker]');
|
||||
if (!select) {
|
||||
select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id;
|
||||
reply.id += '-value'; reply.type = 'hidden'; reply.after(select);
|
||||
select.addEventListener('change', () => { reply.value = select.value; });
|
||||
}
|
||||
const signature = JSON.stringify([emails, reply.value]);
|
||||
if (select.dataset.signature === signature) return;
|
||||
select.dataset.signature = signature;
|
||||
select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', ''));
|
||||
for (const field of emails) select.append(new Option(field.label || field.name, field.name));
|
||||
if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value));
|
||||
select.value = reply.value;
|
||||
const help = reply.parentElement.querySelector('.field-help');
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
@@ -153,6 +257,7 @@ if (root) {
|
||||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||||
renderCondition(card);
|
||||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
@@ -162,6 +267,7 @@ if (root) {
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderReply();
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
|
||||
@@ -37,4 +37,8 @@
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-document{margin:6px 0 4px}
|
||||
.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
|
||||
.href-document{margin:0}
|
||||
/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */
|
||||
.page-dialog{max-width:560px;min-height:420px;overflow:visible}
|
||||
.page-dialog .content-picker{margin-top:16px}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild.
|
||||
*
|
||||
* Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse
|
||||
* austauscht - ein Feld, das danach erscheint, funktioniert trotzdem.
|
||||
*/
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-media-choose], [data-media-remove]');
|
||||
const field = button?.closest('[data-media-field]');
|
||||
if (!field) return;
|
||||
const input = field.querySelector('input[type="hidden"]');
|
||||
const thumb = field.querySelector('.media-field-thumb');
|
||||
const name = field.querySelector('.media-field-name');
|
||||
const choose = field.querySelector('[data-media-choose]');
|
||||
const remove = field.querySelector('[data-media-remove]');
|
||||
const show = item => {
|
||||
input.value = item?.id ?? '';
|
||||
thumb.hidden = !item;
|
||||
if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src');
|
||||
name.textContent = item ? item.title : 'Kein Bild gewählt';
|
||||
choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !item;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; }
|
||||
// Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte,
|
||||
// deren Titel schon sagt, worum es geht.
|
||||
openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } });
|
||||
});
|
||||
@@ -52,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
||||
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
||||
const free = picker.dataset.free === '1';
|
||||
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
||||
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
@@ -66,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
|
||||
/**
|
||||
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
||||
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
||||
*/
|
||||
export function openPageDialog({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
||||
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
||||
<div class="content-picker">
|
||||
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
||||
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
||||
<input type="hidden" class="picker-value">
|
||||
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>`;
|
||||
const picker = dialog.querySelector('.content-picker');
|
||||
picker.dataset.source = source; picker.classList.add('menu-add');
|
||||
document.body.append(dialog);
|
||||
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#page-dialog-search').focus();
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* the alternative would be a selection engine of our own. The formatting it produces is normalised
|
||||
* on the server anyway, so a browser quirk cannot store something unexpected.
|
||||
*/
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
|
||||
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
|
||||
@@ -116,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text', plain = false } =
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
|
||||
const fill = value => { linkInput.value = value; linkInput.focus(); };
|
||||
linkRow.append(linkInput,
|
||||
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
|
||||
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
|
||||
newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
@@ -375,9 +375,28 @@ try {
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/);
|
||||
await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? ''));
|
||||
checks.push({ name: 'a PDF from the library as a link target', passed: true });
|
||||
// Eine eigene Seite als Linkziel: gesucht und gewählt, nicht als Pfad abgetippt.
|
||||
await page.getByRole('button', { name: 'Seite wählen', exact: true }).click();
|
||||
const pageDialog = page.locator('dialog.page-dialog');
|
||||
await pageDialog.locator('[role="option"]').first().waitFor();
|
||||
const pageAxe = await new AxeBuilder({ page }).include('dialog.page-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
assert.deepEqual(pageAxe.violations.map(v => v.id), [], 'the page dialog has no accessibility violations');
|
||||
await pageDialog.locator('[role="option"]').first().click();
|
||||
await pageDialog.waitFor({ state: 'detached' });
|
||||
assert.match(await page.locator('#property-href').inputValue(), /^\/[a-z0-9\-/]*$/);
|
||||
checks.push({ name: 'a page as a link target, picked by search', passed: true });
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
// Verknüpfte Dinge kommen aus Listen: Formular, Kategorie und Schlagwort sind Auswahlfelder.
|
||||
for (const [type, id] of [['form', 'property-form'], ['postlist', 'property-category'], ['postlist', 'property-tag']]) {
|
||||
await addFromLibrary(`button[data-add="${type}"]`);
|
||||
assert.equal(await page.locator(`#${id}`).evaluate(node => node.tagName), 'SELECT', `${id} is a choice, not a typed address`);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
}
|
||||
checks.push({ name: 'forms, categories and tags are picked from lists', passed: true });
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// --- Der Werkzeugkasten kennt den Zusammenhang ---------------------------------------------
|
||||
@@ -1108,6 +1127,61 @@ try {
|
||||
await axe('mobile media library');
|
||||
await page.screenshot({ path: 'reports/editor/media-phone.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Schließen', exact: true }).click();
|
||||
// Das Vorschaubild einer Seite: aus der Mediathek gewählt, nicht als Kennung abgetippt.
|
||||
await page.setViewportSize({ width: 1600, height: 1100 });
|
||||
await page.goto(origin + '/admin');
|
||||
const sheet = page.locator('details.sheet').first();
|
||||
await sheet.locator('summary').click();
|
||||
const ogField = sheet.locator('[data-media-field]').filter({ has: page.locator('input[name="ogMediaId"]') });
|
||||
await ogField.getByRole('button', { name: 'Bild wählen oder hochladen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
const chosenOg = await ogField.locator('input[name="ogMediaId"]').inputValue();
|
||||
assert.match(chosenOg, /^[a-f0-9]{32}$/);
|
||||
assert.equal(await ogField.locator('.media-field-thumb').isVisible(), true, 'the chosen image is shown');
|
||||
await axe('content settings with an image field');
|
||||
await ogField.locator('xpath=ancestor::form').getByRole('button', { name: 'Speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const savedOg = page.locator('input[name="ogMediaId"]').first();
|
||||
assert.equal(await savedOg.inputValue(), chosenOg, 'the image survives saving');
|
||||
checks.push({ name: 'image fields in the content settings pick from the library', passed: true });
|
||||
// Formular-Editor: Bedingung, Antwortadresse und Bild je Option werden gewählt, nicht getippt.
|
||||
await page.goto(origin + '/admin/forms');
|
||||
const neu = page.locator('#neues-formular');
|
||||
if (!(await neu.evaluate(details => details.open))) await neu.locator('summary').click();
|
||||
await neu.locator('[name="name"]').fill('Anfrage Prüfung');
|
||||
await neu.locator('[name="slug"]').fill('anfrage-pruefung');
|
||||
await neu.locator('[name="recipient"]').fill('info@example.com');
|
||||
const karte = index => neu.locator('[data-field-list] [data-field]').nth(index);
|
||||
await karte(0).locator('[data-field-label]').fill('E-Mail');
|
||||
await karte(0).locator('[data-field-type]').selectOption('email');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(1).locator('[data-field-label]').fill('Art');
|
||||
await karte(1).locator('[data-field-type]').selectOption('radio');
|
||||
await karte(1).locator('[data-field-options-input]').fill('Wohnung, Haus');
|
||||
await neu.locator('[data-add-field]').click();
|
||||
await karte(2).locator('[data-field-label]').fill('Etage');
|
||||
await karte(2).getByLabel('Nur zeigen, wenn', { exact: true }).selectOption({ label: 'Art' });
|
||||
await karte(2).getByLabel('Wohnung', { exact: true }).check();
|
||||
assert.equal(await karte(2).locator('[data-field-showif]').inputValue(), 'art');
|
||||
assert.equal(await karte(2).locator('[data-field-showvalues]').inputValue(), 'Wohnung');
|
||||
await neu.getByLabel('Antwortadresse aus Feld', { exact: true }).selectOption({ label: 'E-Mail' });
|
||||
assert.equal(await neu.locator('input[name="replyToField"]').inputValue(), 'e_mail');
|
||||
await karte(1).getByRole('button', { name: 'Bild für „Wohnung“ wählen', exact: true }).click();
|
||||
await page.locator('#media-dialog [data-media-id]').first().click();
|
||||
await page.locator('#media-insert').click();
|
||||
await page.locator('#media-dialog').waitFor({ state: 'detached' });
|
||||
assert.match(await karte(1).locator('[data-field-images-input]').inputValue(), /^Wohnung = [a-f0-9]{32}$/);
|
||||
assert.equal(await karte(1).locator('.field-image-picker-thumb').first().isVisible(), true);
|
||||
await axe('form editor with pickers');
|
||||
await neu.getByRole('button', { name: 'Formular anlegen', exact: true }).click();
|
||||
await page.waitForLoadState('load');
|
||||
const gespeichert = page.locator('form[action$="/forms/anfrage-pruefung"]');
|
||||
await gespeichert.waitFor({ state: 'attached' });
|
||||
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 });
|
||||
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.');
|
||||
|
||||
@@ -139,9 +139,26 @@ final class EditorController extends AbstractController
|
||||
'buttonOptions' => \App\Content\ButtonDesign::OPTIONS,
|
||||
'buttonPresets' => \App\Content\ButtonDesign::PRESETS,
|
||||
'csrfToken' => $this->csrf->getToken('editor')->getValue(),
|
||||
// Was ein Baustein per Adresse verknuepft, waehlt man aus einer Liste - niemand soll
|
||||
// die Adresse eines Formulars oder einer Kategorie auswendig wissen muessen.
|
||||
'choices' => $this->choices(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, string>> Adresse => Name, je Art */
|
||||
private function choices(): array
|
||||
{
|
||||
$choices = ['forms' => [], 'sections' => $this->partials->choices('section'), 'categories' => [], 'tags' => []];
|
||||
foreach ($this->forms->all() as $form) {
|
||||
$choices['forms'][$form['slug']] = $form['name'];
|
||||
}
|
||||
foreach ($this->posts->allTerms() as $term) {
|
||||
$choices['category' === $term['kind'] ? 'categories' : 'tags'][$term['slug']] = $term['name'];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
/** Saves or removes a reusable style, so a look can be applied to other blocks and pages. */
|
||||
#[Route('/api/style-presets', name: 'admin_style_preset', methods: ['POST'])]
|
||||
public function stylePreset(Request $request): JsonResponse
|
||||
|
||||
@@ -33,9 +33,19 @@ final class PluginExtension extends AbstractExtension
|
||||
new TwigFunction('kant_plugin_props', $this->props(...)),
|
||||
new TwigFunction('kant_plugin_media', $this->mediaItem(...)),
|
||||
new TwigFunction('kant_plugin_settings', $this->plugins->settingsOf(...)),
|
||||
new TwigFunction('kant_media', $this->adminMedia(...)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Bild fuer ein Bildfeld der Verwaltung - auch ein archiviertes: es ist noch gewaehlt und
|
||||
* wird noch ausgeliefert, also soll es auch zu sehen sein.
|
||||
*/
|
||||
public function adminMedia(string $id): ?array
|
||||
{
|
||||
return 1 === preg_match('/^[a-f0-9]{32}$/D', $id) ? $this->media->find($id) : null;
|
||||
}
|
||||
|
||||
/** Die geprüften Eigenschaften eines Bausteins - oder null, wenn sie nicht ins Schema passen. */
|
||||
public function props(array $block): ?array
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% set selected = selected|default(null) %}
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
|
||||
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}{% if free|default(false) %} data-free="1"{% endif %}>
|
||||
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
|
||||
<div class="picker-input">
|
||||
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
|
||||
|
||||
@@ -53,8 +53,7 @@
|
||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||
{% include 'admin/media-field.html.twig' with {id: 'og-' ~ page.id, name: 'ogMediaId', label: 'Vorschaubild beim Teilen', value: page.og_media_id|default(''), help: 'Erscheint, wenn jemand die Seite in sozialen Netzen oder Messengern teilt.'} only %}
|
||||
</div>
|
||||
{% if page.type == 'post' %}
|
||||
<div>
|
||||
@@ -75,8 +74,7 @@
|
||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||
{% include 'admin/media-field.html.twig' with {id: 'media-' ~ page.id, name: 'featuredMediaId', label: 'Beitragsbild', value: page.featured_media_id|default(''), help: 'Erscheint in Beitragslisten und im Feed.'} only %}
|
||||
</div>
|
||||
<div>
|
||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% set nav = 'forms' %}
|
||||
{# Die Vorschau zeichnet den Absendeknopf mit dem Standardbutton der Website - dafuer braucht die
|
||||
Seite dessen Stylesheet, genau wie die Designseite. #}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/form-editor.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block title %}Formulare{% endblock %}
|
||||
{% block heading %}Formulare{% endblock %}
|
||||
@@ -105,7 +105,7 @@
|
||||
{% else %}
|
||||
<div class="card-head">
|
||||
<h2>{{ form.name }} <span class="count">{{ form.fields|length }} {{ form.fields|length == 1 ? 'Feld' : 'Felder' }}</span></h2>
|
||||
<span class="muted">Im Editor als Formularblock mit der Adresse <code>{{ form.slug }}</code></span>
|
||||
<span class="muted">Im Editor über den Formularblock wählbar · Adresse <code>{{ form.slug }}</code></span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card-body form-editor">
|
||||
@@ -120,7 +120,7 @@
|
||||
<div>
|
||||
<label for="slug-new">Adresse</label>
|
||||
<input id="slug-new" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" required placeholder="kontakt">
|
||||
<p class="field-help">Wird im Formularblock eingetragen. Kleinbuchstaben, Ziffern, Bindestrich.</p>
|
||||
<p class="field-help">Kurzname des Formulars, etwa kontakt. Kleinbuchstaben, Ziffern, Bindestrich. Im Editor wird das Formular dann aus einer Liste gewählt.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{# Ein Bild aus der Mediathek als Formularfeld: Vorschau, Wählen oder Hochladen im Dialog,
|
||||
Entfernen. Die Kennung steht nur im versteckten Feld - niemand soll sie abtippen müssen.
|
||||
Erwartet: id, name, label, value, help (optional). #}
|
||||
{% set item = value ? kant_media(value) : null %}
|
||||
<div class="media-field" data-media-field role="group" aria-labelledby="{{ id }}-label">
|
||||
<span class="media-field-label" id="{{ id }}-label">{{ label }}</span>
|
||||
<input type="hidden" id="{{ id }}" name="{{ name }}" value="{{ value }}">
|
||||
<div class="media-field-current">
|
||||
<img class="media-field-thumb" alt="" width="96" height="64"{% if item %} src="{{ (item.variants|first).src }}"{% else %} hidden{% endif %}>
|
||||
<span class="media-field-name" aria-live="polite">{{ item ? item.title : (value ? 'Bild nicht gefunden' : 'Kein Bild gewählt') }}</span>
|
||||
</div>
|
||||
<div class="media-field-actions">
|
||||
<button type="button" class="secondary" data-media-choose>{{ value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
|
||||
<button type="button" class="text-button danger-text" data-media-remove{{ value ? '' : ' hidden' }}>Entfernen</button>
|
||||
</div>
|
||||
{% if help|default('') %}<p class="field-help">{{ help }}</p>{% endif %}
|
||||
</div>
|
||||
@@ -4,7 +4,8 @@
|
||||
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
||||
{% block title %}{{ plural }}{% endblock %}
|
||||
{% block heading %}{{ plural }}{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% 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 lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
{% set nav = 'plugins' %}
|
||||
{% 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 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. #}
|
||||
{% macro feld(plugin, key, schema, value) %}
|
||||
{% set id = 'plugin-' ~ plugin ~ '-' ~ key %}
|
||||
{% set name = 'settings[' ~ key ~ ']' %}
|
||||
{% if schema.kind == 'boolean' %}
|
||||
{% if schema.kind == 'media' %}
|
||||
<div>{% include 'admin/media-field.html.twig' with {id: id, name: name, label: schema.label, value: value|default(''), help: schema.help|default('')} only %}</div>
|
||||
{% elseif schema.kind == 'boolean' %}
|
||||
<div><label class="check-label" for="{{ id }}"><input id="{{ id }}" type="checkbox" name="{{ name }}" value="1"{{ value ? ' checked' : '' }}> {{ schema.label }}</label>{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}</div>
|
||||
{% else %}
|
||||
<div>
|
||||
@@ -16,7 +20,6 @@
|
||||
{% 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 == 'number' %}<input id="{{ id }}" name="{{ name }}" type="number" step="any" min="{{ schema.min }}" max="{{ schema.max }}" value="{{ value }}">
|
||||
{% elseif schema.kind == 'media' %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" placeholder="Kennung aus der Mediathek" pattern="[a-f0-9]{32}">
|
||||
{% else %}<input id="{{ id }}" name="{{ name }}" value="{{ value }}" maxlength="{{ schema.maxLength }}">{% endif %}
|
||||
{% if schema.help %}<p class="field-help">{{ schema.help }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -292,9 +292,8 @@
|
||||
<input id="from" name="from" maxlength="400" required placeholder="alte-seite">
|
||||
</div>
|
||||
<div>
|
||||
<label for="to">Neue Adresse</label>
|
||||
<input id="to" name="to" maxlength="400" placeholder="leistungen/webdesign">
|
||||
<p class="field-help">Bei 410 leer lassen.</p>
|
||||
{% include 'admin/content-picker.html.twig' with {id: 'to', name: 'to', label: 'Neue Adresse', valueKey: 'path', free: true} only %}
|
||||
<p class="field-help">Seite suchen und wählen – oder einen anderen Pfad eintippen, etwa kategorie/projekte. Bei 410 leer lassen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="status">Art</label>
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Content\Theme;
|
||||
use App\Domain\InvalidForm;
|
||||
@@ -268,6 +269,35 @@ final class EditorSecurityTest extends WebTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId(): void
|
||||
{
|
||||
// Umut: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild"
|
||||
$client = $this->login('editor');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$id = $pages->createPage('mit-bild', null, 'post', DemoContent::pages()['home']);
|
||||
$media = str_repeat('c', 32);
|
||||
$this->db->insert('media', ['id' => $media, 'title' => 'Klassenzimmer', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$media.'/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$this->db->executeStatement('UPDATE page SET featured_media_id = ? WHERE id = ?', [$media, $id]);
|
||||
|
||||
$crawler = $client->request('GET', '/admin/posts');
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringNotContainsString('ID aus der Mediathek', $client->getResponse()->getContent());
|
||||
self::assertCount(1, $crawler->filter('script[src^="/assets/media-field.js"]'));
|
||||
// Das gewählte Beitragsbild: Kennung versteckt, Vorschau und Titel sichtbar.
|
||||
$featured = $crawler->filter('input[name="featuredMediaId"]')->closest('[data-media-field]');
|
||||
self::assertCount(1, $featured);
|
||||
self::assertSame('hidden', $featured->filter('input[name="featuredMediaId"]')->attr('type'));
|
||||
self::assertSame($media, $featured->filter('input[name="featuredMediaId"]')->attr('value'));
|
||||
self::assertSame('/media/'.$media.'/480', $featured->filter('img')->attr('src'));
|
||||
self::assertStringContainsString('Klassenzimmer', $featured->text());
|
||||
self::assertStringContainsString('Anderes Bild wählen', $featured->text());
|
||||
// Ohne Vorschaubild: Wählen statt Kennung eintippen.
|
||||
$og = $crawler->filter('input[name="ogMediaId"]')->closest('[data-media-field]');
|
||||
self::assertCount(1, $og);
|
||||
self::assertStringContainsString('Bild wählen oder hochladen', $og->text());
|
||||
self::assertNull($og->filter('img')->attr('src'));
|
||||
}
|
||||
|
||||
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
@@ -653,6 +683,29 @@ final class EditorSecurityTest extends WebTestCase
|
||||
self::assertSame('Kontakt', $this->db->fetchOne('SELECT name FROM form'));
|
||||
}
|
||||
|
||||
public function testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses(): void
|
||||
{
|
||||
// Was ein Baustein nur verknüpft, kommt als Liste Adresse => Name - der Editor macht
|
||||
// daraus Auswahlfelder statt Textfelder für Adressen.
|
||||
$media = new MediaLibrary($this->db, $this->path.'-media');
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, $media));
|
||||
(new FormRepository($this->db))->save('anfrage', 'Anfrage', [['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true, 'help' => '', 'options' => []]], ['recipient' => 'info@example.com'], 0);
|
||||
$band = DemoContent::starter('Werbeband');
|
||||
$band['blocks'] = [['id' => 'band-text', 'type' => 'text', 'props' => ['text' => 'Aktion']]];
|
||||
(new PartialRepository($this->db, new DocumentValidator()))->create('section', 'band', 'Werbeband', $band);
|
||||
$post = $pages->createPage('ein-beitrag', null, 'post', DemoContent::pages()['home']);
|
||||
(new PostRepository($this->db, $media, new SiteSettings($this->db, $media)))->saveMetadata($post, [], ['Projekte'], ['Umbau'], $pages->findForEditor($post)['version']);
|
||||
|
||||
$client = $this->login('editor');
|
||||
$client->request('GET', '/admin/api/design');
|
||||
self::assertResponseIsSuccessful();
|
||||
$choices = json_decode($client->getResponse()->getContent(), true)['choices'];
|
||||
self::assertSame(['anfrage' => 'Anfrage'], $choices['forms']);
|
||||
self::assertSame(['band' => 'Werbeband'], $choices['sections']);
|
||||
self::assertSame(['projekte' => 'Projekte'], $choices['categories']);
|
||||
self::assertSame(['umbau' => 'Umbau'], $choices['tags']);
|
||||
}
|
||||
|
||||
public function testStylePresetsNeedAnEditorRoleACsrfTokenAndAValidStyle(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
|
||||
Reference in new issue
Block a user