Shop G4: Reiter Artikel mit Liste (Hoch/Runter, Aktiv-Schalter, Löschen mit Rückfrage), Editor (Preis, Grundpreis, Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel) und Feldfehlern

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:14:05 +00:00
1 parent 648d0e07fe
commit cc668d3765
3 files changed
+1157 -5

No files matched your search

+547 -5
View File
@@ -1,8 +1,550 @@
/* Webshop, Reiter „Artikel“: Platzhalter, bis der Reiter gebaut ist (Plan Block G, Tasks G3 bis G6 ersetzen diese Datei).
render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */
/* Webshop, Reiter „Artikel“ (Plan 2026-10-05-webshop.md, Task G4; Spec 4.3). render(panel, ctx): ctx = { projectId, nurLesen, aktuell(), zeigeMeldung(text, art), request } (shop.js).
Zwei Ansichten im selben Reiter: die Liste (Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten, Loeschen) und der Editor (alle Felder des Artikels,
Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel). Server: GET/POST /api/shop/produkte, PUT/DELETE /api/shop/produkte/:id, POST /api/shop/produkte/sortierung,
POST /api/shop/bild (rohe Bytes), Vorschau GET /api/shop/bild/:projectId/<id>-s.webp, GET /api/shop/einstellungen (Kleinunternehmer). Der Server prueft alles; der Editor
gibt Feldfehler (auch mit Punkt-Pfad wie 'varianten.werte.2.preisCent') am Feld aus (data-fehler = Pfad). Fremdtext kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
import { h } from './shop-dom.js'
import { abschnitt, centZuEuro, euroEingabe, euroText, euroZuCent, feld, fehlerKarte, frage, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export function render(panel) {
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …'))
const MAX_WERTE = 30
const BILD_MAX_BYTES = 8 * 1024 * 1024
const BILD_TYPEN = ['image/jpeg', 'image/png', 'image/webp']
const EINHEITEN = [['g', 'g'], ['kg', 'kg'], ['ml', 'ml'], ['l', 'l'], ['m', 'm'], ['m2', 'm²']]
const STEUERSAETZE = [19, 7, 0]
const LIEFERARTEN = [['beides', 'Versand und Abholung'], ['abholung', 'Nur Abholung']]
const WIDERRUFE = [
['gesetzlich', 'Gesetzliches Widerrufsrecht (Standard)'],
['verderblich', 'Ausgeschlossen: schnell verderbliche Ware'],
['individuell', 'Ausgeschlossen: nach Kundenwunsch angefertigt'],
['hygiene', 'Ausgeschlossen: versiegelt, aus Hygienegründen'],
]
const BILD_FEHLER = {
413: 'Das Bild ist größer als 8 MB.',
415: 'Dieses Bild kann nicht verwendet werden. Bitte nimm eine JPEG-, PNG- oder WebP-Datei.',
}
const text = (wert) => (typeof wert === 'string' ? wert : '')
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
let zaehler = 0
/** Ganze Zahl aus dem Feld: leer = null, nur Ziffern = Zahl, sonst der Text (der Server meldet den Fehler). */
const ganzeZahl = (eingabe) => {
const t = String(eingabe ?? '').trim()
if (t === '') return null
return /^\d{1,12}$/.test(t) ? Number(t) : t
}
const waehle = (paare, aktuell) => {
const sel = h('select', null, paare.map(([wert, label]) => h('option', { value: String(wert) }, label)))
sel.value = String(aktuell)
return sel
}
const kurzesFeld = (wert, extra) => {
const el = h('input', { type: 'text', autocomplete: 'off', ...extra })
el.value = wert
return el
}
const langesFeld = (wert, zeilen = 4) => {
const el = h('textarea', { rows: zeilen })
el.value = wert
return el
}
/** Einklappbarer Abschnitt (details); offen = beim Zeichnen aufgeklappt. */
const einklappbar = (titel, hinweis, offen, ...inhalt) => h('details', { class: 'kb-panel sh-abschnitt sh-einklappbar', open: offen },
h('summary', null, h('strong', null, titel)),
hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null,
...inhalt)
/** Rohe Bytes hochladen (POST /api/shop/bild); Fehler tragen status und einen deutschen Text. */
async function ladeBildHoch(projectId, datei) {
let response
try {
response = await fetch(`/api/shop/bild?projectId=${encodeURIComponent(projectId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/octet-stream' },
body: datei,
})
} catch {
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0 })
}
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw Object.assign(new Error(text(data?.error) || BILD_FEHLER[response.status] || `Fehler ${response.status}`), { status: response.status })
}
return data
}
/* ------------------------------------------------------------------ Anzeige --- */
/** Preis fuer die Liste: bei Varianten „ab …“ (oder der eine Preis), sonst der Preis des Artikels. */
function preisAnzeige(p) {
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
if (werte.length > 0) {
const preise = werte.map((w) => w.preisCent).filter(Number.isInteger)
if (preise.length === 0) return ''
const min = Math.min(...preise)
return min === Math.max(...preise) ? euroText(min) : `ab ${euroText(min)}`
}
return euroText(p?.preisCent)
}
/** Bestand fuer die Liste: „unbegrenzt“, „n Stück“ oder „ausverkauft“ (bei Varianten ueber alle Werte). */
function bestandAnzeige(p) {
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
const stueck = (n) => (n === 0 ? 'ausverkauft' : `${n} Stück`)
if (werte.length > 0) {
if (werte.every((w) => w.bestand === 0)) return 'ausverkauft'
if (werte.some((w) => !Number.isInteger(w.bestand))) return 'unbegrenzt'
return stueck(werte.reduce((summe, w) => summe + w.bestand, 0))
}
return Number.isInteger(p?.bestand) ? stueck(p.bestand) : 'unbegrenzt'
}
/** Der Artikel in der Form, die der Server beim Speichern erwartet (aus der Listenansicht). */
function produktKoerper(p, ueberschreibe = {}) {
return {
name: p.name,
kategorie: p.kategorie,
kurz: p.kurz,
beschreibung: p.beschreibung,
preisCent: p.preisCent ?? null,
steuersatz: p.steuersatz,
grundpreis: p.grundpreis ?? null,
bestand: p.bestand ?? null,
aktiv: p.aktiv,
varianten: p.varianten ?? null,
bilder: Array.isArray(p.bilder) ? p.bilder : [],
lieferart: p.lieferart,
widerruf: p.widerruf,
sicherheit: p.sicherheit,
lebensmittel: p.lebensmittel,
...ueberschreibe,
}
}
const leererArtikel = () => ({
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
})
/* ---------------------------------------------------------------------- Reiter --- */
export async function render(panel, ctx) {
const api = ctx.request || request
const nurLesen = ctx.nurLesen === true
const projectId = ctx.projectId
const q = `projectId=${encodeURIComponent(projectId)}`
const noch = () => (typeof ctx.aktuell === 'function' ? ctx.aktuell() !== false : true)
const meldung = (t, art = 'ok') => ctx.zeigeMeldung?.(t, art)
const stand = { produkte: [], maxArtikel: 500, maxBilder: 4, kleinunternehmer: false }
let beschaeftigt = false
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Artikel werden geladen …'))
try {
const [liste, einstellungen] = await Promise.all([api(`/api/shop/produkte?${q}`), api(`/api/shop/einstellungen?${q}`).catch(() => null)])
if (!noch()) return
stand.produkte = Array.isArray(liste?.produkte) ? liste.produkte : []
if (Number.isInteger(liste?.maxArtikel)) stand.maxArtikel = liste.maxArtikel
if (Number.isInteger(liste?.maxBilder)) stand.maxBilder = liste.maxBilder
stand.kleinunternehmer = einstellungen?.einstellungen?.kleinunternehmer === true
} catch (err) {
if (noch()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
return
}
const vorschauUrl = (id) => `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`
/* ------------------------------------------------------------------ Liste --- */
function zeigeListe() {
if (!noch()) return
const anzahl = stand.produkte.length
const neuKnopf = nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'artikel-neu', onclick: () => zeigeEditor(null) }, 'Artikel anlegen')
if (anzahl === 0) {
panel.replaceChildren(abschnitt('Artikel', null,
h('p', { class: 'sh-leer' }, nurLesen ? 'Es gibt noch keine Artikel.' : 'Du hast noch keine Artikel. Lege deinen ersten Artikel an, dann kann er in deinem Shop erscheinen.'),
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
return
}
const zeilen = stand.produkte.map((p, i) => artikelZeile(p, i, anzahl))
panel.replaceChildren(abschnitt('Artikel', `${anzahl} von höchstens ${stand.maxArtikel} Artikeln. Mit „Hoch“ und „Runter“ bestimmst du die Reihenfolge im Shop.`,
h('ul', { class: 'sh-artikelliste' }, zeilen),
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
}
function artikelZeile(p, index, gesamt) {
const bild = Array.isArray(p.bildUrls) && p.bildUrls[0]?.klein ? p.bildUrls[0].klein : ''
const bestand = bestandAnzeige(p)
const info = h('div', { class: 'sh-artikel-info' },
h('strong', { class: 'sh-artikel-name' }, p.name || '(ohne Namen)'),
p.kategorie ? h('span', { class: 'sh-hinweis' }, p.kategorie) : null,
h('span', { class: 'sh-artikel-preis' }, preisAnzeige(p)),
h('span', { class: `sh-artikel-bestand${bestand === 'ausverkauft' ? ' sh-artikel-aus' : ''}`, 'data-bestand': '' }, bestand))
const li = h('li', { class: 'sh-artikel', 'data-artikel': p.id },
bild ? h('img', { class: 'sh-artikel-bild', src: bild, alt: '', width: 56, height: 56, loading: 'lazy' }) : h('span', { class: 'sh-artikel-bild sh-artikel-bild-leer', 'aria-hidden': 'true' }),
info)
if (nurLesen) {
li.append(h('span', { class: `sh-pille ${p.aktiv ? 'sh-pille-ok' : 'sh-pille-off'}` }, p.aktiv ? 'aktiv' : 'inaktiv'))
return li
}
const schalter = h('input', { type: 'checkbox', role: 'switch', 'aria-label': `${p.name} ist aktiv`, 'data-aktion': 'aktiv', checked: p.aktiv })
schalter.addEventListener('change', () => wechsleAktiv(p, schalter))
li.append(h('div', { class: 'sh-artikel-aktionen' },
h('label', { class: 'sh-schalter' }, schalter, h('span', null, 'aktiv')),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${p.name} nach oben`, disabled: index === 0, onclick: () => verschiebe(index, -1) }, 'Hoch'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${p.name} nach unten`, disabled: index === gesamt - 1, onclick: () => verschiebe(index, 1) }, 'Runter'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bearbeiten', 'aria-label': `${p.name} bearbeiten`, onclick: () => zeigeEditor(p) }, 'Bearbeiten'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen', 'aria-label': `${p.name} löschen`, onclick: () => frageLoeschen(p, li) }, 'Löschen')))
return li
}
/** Fuehrt eine Aktion der Liste aus; waehrenddessen keine zweite. */
async function aktion(fn) {
if (beschaeftigt) return false
beschaeftigt = true
try {
await fn()
return true
} catch (err) {
meldung(err.message, 'fehler')
return false
} finally {
beschaeftigt = false
}
}
async function wechsleAktiv(p, schalter) {
const soll = schalter.checked
schalter.disabled = true
const ok = await aktion(async () => {
const antwort = await api(`/api/shop/produkte/${encodeURIComponent(p.id)}`, { method: 'PUT', body: { projectId, produkt: produktKoerper(p, { aktiv: soll }) } })
if (!noch()) return
stand.produkte = stand.produkte.map((x) => (x.id === p.id ? antwort.produkt : x))
meldung(soll ? `„${p.name}“ ist jetzt aktiv.` : `„${p.name}“ ist jetzt inaktiv.`)
})
if (!noch()) return
if (ok) zeigeListe()
else {
schalter.checked = !soll
schalter.disabled = false
}
}
async function verschiebe(index, richtung) {
const ids = stand.produkte.map((p) => p.id)
const ziel = index + richtung
if (ziel < 0 || ziel >= ids.length) return
;[ids[index], ids[ziel]] = [ids[ziel], ids[index]]
const ok = await aktion(async () => {
const antwort = await api('/api/shop/produkte/sortierung', { method: 'POST', body: { projectId, ids } })
if (noch()) stand.produkte = Array.isArray(antwort?.produkte) ? antwort.produkte : stand.produkte
})
if (ok && noch()) zeigeListe()
}
function frageLoeschen(p, li) {
li.querySelector('.sh-frage-box')?.remove()
const box = frage(`„${p.name}“ wirklich löschen?`,
h('p', { class: 'sh-hinweis' }, 'Der Artikel und seine Bilder werden gelöscht. Das kannst du nicht rückgängig machen. Bereits aufgegebene Bestellungen bleiben unverändert.'),
h('div', { class: 'sh-aktionen' },
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'loeschen-ja', onclick: () => loesche(p) }, 'Ja, löschen'),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen-nein', onclick: () => box.remove() }, 'Abbrechen')))
li.append(box)
box.focus()
}
async function loesche(p) {
const ok = await aktion(async () => {
await api(`/api/shop/produkte/${encodeURIComponent(p.id)}?${q}`, { method: 'DELETE' })
if (!noch()) return
stand.produkte = stand.produkte.filter((x) => x.id !== p.id)
meldung(`„${p.name}“ wurde gelöscht.`)
})
if (ok && noch()) zeigeListe()
}
/* ----------------------------------------------------------------- Editor --- */
function zeigeEditor(vorhanden) {
if (!noch()) return
const id = vorhanden?.id || null
const p = vorhanden ? { ...leererArtikel(), ...vorhanden } : leererArtikel()
const si = istObjekt(p.sicherheit) ? p.sicherheit : {}
const andererHersteller = istObjekt(si.hersteller) ? si.hersteller : null
const eu = istObjekt(si.euVerantwortlicher) ? si.euVerantwortlicher : null
const lm = istObjekt(p.lebensmittel) ? p.lebensmittel : {}
const gp = istObjekt(p.grundpreis) ? p.grundpreis : null
zaehler += 1
/* Grunddaten */
const name = kurzesFeld(text(p.name))
const kategorienListe = `sh-kategorien-${zaehler}`
const kategorien = [...new Set(stand.produkte.map((x) => text(x.kategorie).trim()).filter(Boolean))]
const kategorie = kurzesFeld(text(p.kategorie), { list: kategorienListe })
const kategorieFeld = feld('kategorie', 'Kategorie', kategorie, 'Frei wählbar; im Shop werden die Artikel nach Kategorie gruppiert.')
kategorieFeld.append(h('datalist', { id: kategorienListe }, kategorien.map((k) => h('option', { value: k }))))
const kurz = kurzesFeld(text(p.kurz))
const beschreibung = langesFeld(text(p.beschreibung), 6)
/* Preis, Bestand */
const preis = euroEingabe({ cent: Number.isInteger(p.preisCent) ? p.preisCent : null })
const steuer = waehle(STEUERSAETZE.map((s) => [s, `${s} %`]), STEUERSAETZE.includes(p.steuersatz) ? p.steuersatz : 19)
const gpMenge = kurzesFeld(gp ? String(gp.menge).replace('.', ',') : '', { inputmode: 'decimal' })
const gpEinheit = waehle([['', 'kein Grundpreis'], ...EINHEITEN], gp ? gp.einheit : '')
const bestand = kurzesFeld(Number.isInteger(p.bestand) ? String(p.bestand) : '', { inputmode: 'numeric' })
/* Varianten */
const hatVarianten = istObjekt(p.varianten) && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0
const variantenAn = h('input', { type: 'checkbox', checked: hatVarianten })
const variantenName = kurzesFeld(hatVarianten ? text(p.varianten.name) : '')
const werte = hatVarianten
? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '' }))
: []
const werteBox = h('div', { class: 'sh-werte' })
const wertNeuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-neu', onclick: () => { werte.push({ id: undefined, name: '', preis: '', bestand: '' }); zeichneWerte() } }, 'Wert hinzufügen')
function zeichneWerte() {
werteBox.replaceChildren(...werte.map((w, i) => {
const wName = kurzesFeld(w.name)
const wPreis = euroEingabe({ cent: null })
wPreis.value = w.preis
const wBestand = kurzesFeld(w.bestand, { inputmode: 'numeric' })
for (const typ of ['input', 'change']) {
wName.addEventListener(typ, () => { w.name = wName.value })
wPreis.addEventListener(typ, () => { w.preis = wPreis.value })
wBestand.addEventListener(typ, () => { w.bestand = wBestand.value })
}
return h('div', { class: 'sh-wertzeile', 'data-wert': String(i) },
feld(`varianten.werte.${i}.name`, `Wert ${i + 1}`, wName),
feld(`varianten.werte.${i}.preisCent`, `Preis ${i + 1} in €`, wPreis),
feld(`varianten.werte.${i}.bestand`, `Bestand ${i + 1}`, wBestand, i === 0 ? 'Leer = unbegrenzt.' : undefined),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-weg', 'aria-label': `Wert ${i + 1} entfernen`, onclick: () => { werte.splice(i, 1); zeichneWerte() } }, 'Entfernen'))
}))
wertNeuKnopf.disabled = werte.length >= MAX_WERTE
}
zeichneWerte()
/* Bilder */
const bilder = (Array.isArray(p.bilder) ? p.bilder : []).map((bid) => ({ id: bid, klein: p.bildUrls?.find((b) => b.id === bid)?.klein || vorschauUrl(bid) }))
const bilderListe = h('ul', { class: 'sh-bilder' })
const bildFehler = h('p', { class: 'sh-fehler', 'data-fehler': 'bilder', role: 'alert' })
const bildStatus = h('p', { class: 'sh-hinweis', role: 'status', 'data-bild-status': '' })
const bildInput = h('input', { type: 'file', accept: BILD_TYPEN.join(','), 'aria-label': 'Bild hinzufügen' })
const bildFeld = h('div', { class: 'sh-feld' }, h('label', null, 'Bild hinzufügen'), bildInput)
let hochladen = false
let speichernKnopf = null
function zeichneBilder() {
bilderListe.replaceChildren(...bilder.map((b, i) => h('li', { class: 'sh-bild', 'data-bild': b.id },
h('img', { src: b.klein, alt: i === 0 ? 'Titelbild' : `Bild ${i + 1}`, width: 96, height: 96 }),
i === 0 ? h('span', { class: 'sh-pille sh-pille-ok' }, 'Titelbild') : null,
h('div', { class: 'sh-aktionen' },
i > 0 ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-titel', onclick: () => { bilder.unshift(...bilder.splice(i, 1)); zeichneBilder() } }, 'Als Titelbild') : null,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-weg', 'aria-label': `Bild ${i + 1} entfernen`, onclick: () => { bilder.splice(i, 1); zeichneBilder() } }, 'Entfernen')))))
bildFeld.hidden = bilder.length >= stand.maxBilder
bildInput.disabled = hochladen
bildStatus.textContent = hochladen ? 'Bild wird hochgeladen …' : ''
if (speichernKnopf) speichernKnopf.disabled = hochladen
}
bildInput.addEventListener('change', async () => {
const datei = bildInput.files?.[0]
bildInput.value = ''
if (!datei || hochladen) return
bildFehler.textContent = ''
if (!BILD_TYPEN.includes(datei.type)) {
bildFehler.textContent = BILD_FEHLER[415]
return
}
if (datei.size > BILD_MAX_BYTES) {
bildFehler.textContent = BILD_FEHLER[413]
return
}
hochladen = true
zeichneBilder()
try {
const antwort = await ladeBildHoch(projectId, datei)
if (typeof antwort?.imageId === 'string') bilder.push({ id: antwort.imageId, klein: vorschauUrl(antwort.imageId) })
} catch (err) {
bildFehler.textContent = err.message
} finally {
hochladen = false
zeichneBilder()
}
})
/* Lieferung, Widerruf */
const lieferart = waehle(LIEFERARTEN, LIEFERARTEN.some(([w]) => w === p.lieferart) ? p.lieferart : 'beides')
const widerruf = waehle(WIDERRUFE, WIDERRUFE.some(([w]) => w === p.widerruf) ? p.widerruf : 'gesetzlich')
/* Produktsicherheit */
const hersteller = waehle([['selbst', 'Ich bin der Hersteller'], ['andere', 'Ein anderer Hersteller']], andererHersteller ? 'andere' : 'selbst')
const hName = kurzesFeld(text(andererHersteller?.name))
const hAnschrift = kurzesFeld(text(andererHersteller?.anschrift))
const hKontakt = kurzesFeld(text(andererHersteller?.kontakt))
const herstellerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'hersteller' },
feld('sicherheit.hersteller.name', 'Name des Herstellers', hName),
feld('sicherheit.hersteller.anschrift', 'Anschrift des Herstellers', hAnschrift),
feld('sicherheit.hersteller.kontakt', 'Kontakt des Herstellers (E-Mail oder Telefon)', hKontakt),
h('p', { class: 'sh-fehler', 'data-fehler': 'sicherheit.hersteller', role: 'alert' }))
const euAn = h('input', { type: 'checkbox', checked: eu !== null })
const euName = kurzesFeld(text(eu?.name))
const euAnschrift = kurzesFeld(text(eu?.anschrift))
const euKontakt = kurzesFeld(text(eu?.kontakt))
const euBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'eu' },
feld('sicherheit.euVerantwortlicher.name', 'Name der verantwortlichen Person in der EU', euName),
feld('sicherheit.euVerantwortlicher.anschrift', 'Anschrift in der EU', euAnschrift),
feld('sicherheit.euVerantwortlicher.kontakt', 'Kontakt in der EU (E-Mail oder Telefon)', euKontakt))
const hinweise = langesFeld(text(si.hinweise), 3)
/* Lebensmittel */
const lmAn = h('input', { type: 'checkbox', checked: lm.aktiv === true })
const pflicht = langesFeld(text(lm.pflichtangaben), 6)
const lmBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'lebensmittel' },
feld('lebensmittel.pflichtangaben', 'Pflichtangaben', pflicht, 'Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf. Hersteller'))
const aktivHaken = h('input', { type: 'checkbox', checked: p.aktiv !== false })
/* Bereiche ein- und ausblenden */
const einzelPreis = h('div', { class: 'sh-untergruppe', 'data-bereich': 'einzelpreis' }, feld('preisCent', 'Preis in € (brutto)', preis, 'Der Gesamtpreis, den deine Kundschaft bezahlt, inklusive Umsatzsteuer.'))
const steuerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'steuersatz' }, feld('steuersatz', 'Steuersatz', steuer, 'Der Umsatzsteuersatz, der in diesem Preis enthalten ist.'))
steuerBox.hidden = stand.kleinunternehmer
const bestandAbschnitt = abschnitt('Bestand', null, feld('bestand', 'Bestand (Stück)', bestand, 'Leer lassen = unbegrenzt verfügbar. Bei 0 zeigt der Shop den Artikel als ausverkauft.'))
bestandAbschnitt.dataset.bereich = 'einzelbestand'
const variantenBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'varianten' },
feld('varianten.name', 'Name der Auswahl', variantenName, 'Zum Beispiel „Größe“ oder „Farbe“.'),
werteBox,
h('p', { class: 'sh-fehler', 'data-fehler': 'varianten.werte', role: 'alert' }),
h('div', { class: 'sh-aktionen' }, wertNeuKnopf),
h('p', { class: 'sh-hinweis' }, `Bis zu ${MAX_WERTE} Werte. Mit Varianten zählen nur der Preis und der Bestand der einzelnen Werte.`))
const zeigeBereiche = () => {
const an = variantenAn.checked
variantenBox.hidden = !an
einzelPreis.hidden = an
bestandAbschnitt.hidden = an
herstellerBox.hidden = hersteller.value !== 'andere'
euBox.hidden = !euAn.checked
lmBox.hidden = !lmAn.checked
}
variantenAn.addEventListener('change', () => {
if (variantenAn.checked && werte.length === 0) {
werte.push({ id: undefined, name: '', preis: '', bestand: '' })
zeichneWerte()
}
zeigeBereiche()
})
hersteller.addEventListener('change', zeigeBereiche)
euAn.addEventListener('change', zeigeBereiche)
lmAn.addEventListener('change', zeigeBereiche)
/* Artikel aus den Feldern lesen (der Server prueft; hier wird nichts vorab verworfen) */
function sammle() {
const mitVarianten = variantenAn.checked
const menge = gpMenge.value.trim()
const gpAn = menge !== '' || gpEinheit.value !== ''
return {
name: name.value,
kategorie: kategorie.value,
kurz: kurz.value,
beschreibung: beschreibung.value,
preisCent: mitVarianten ? null : preis.inCent(),
steuersatz: stand.kleinunternehmer ? 0 : Number(steuer.value),
grundpreis: gpAn ? { menge: /^\d+([.,]\d+)?$/.test(menge) ? Number(menge.replace(',', '.')) : menge, einheit: gpEinheit.value } : null,
bestand: mitVarianten ? null : ganzeZahl(bestand.value),
aktiv: aktivHaken.checked,
varianten: mitVarianten
? { name: variantenName.value, werte: werte.map((w) => ({ id: w.id, name: w.name, preisCent: euroZuCent(String(w.preis)), bestand: ganzeZahl(w.bestand) })) }
: null,
bilder: bilder.map((b) => b.id),
lieferart: lieferart.value,
widerruf: widerruf.value,
sicherheit: {
hersteller: hersteller.value === 'andere' ? { name: hName.value, anschrift: hAnschrift.value, kontakt: hKontakt.value } : 'selbst',
euVerantwortlicher: euAn.checked ? { name: euName.value, anschrift: euAnschrift.value, kontakt: euKontakt.value } : null,
hinweise: hinweise.value,
},
lebensmittel: { aktiv: lmAn.checked, pflichtangaben: pflicht.value },
}
}
const titel = h('h3', { tabindex: '-1' }, id ? 'Artikel bearbeiten' : 'Artikel anlegen')
speichernKnopf = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
const form = h('form', { class: 'sh-artikel-editor', novalidate: true, 'aria-label': id ? 'Artikel bearbeiten' : 'Artikel anlegen' },
h('div', { class: 'kb-panel sh-abschnitt' }, titel,
h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' }),
h('p', { class: 'sh-fehler', 'data-fehler': 'produkt', role: 'alert' })),
abschnitt('Grunddaten', null,
feld('name', 'Name', name),
kategorieFeld,
feld('kurz', 'Kurzbeschreibung', kurz, 'Höchstens 300 Zeichen, erscheint in der Artikelliste des Shops.'),
feld('beschreibung', 'Beschreibung', beschreibung, 'Höchstens 5000 Zeichen. Absätze sind erlaubt, HTML nicht.')),
abschnitt('Preis', null,
einzelPreis,
steuerBox,
h('div', { class: 'sh-grundpreis' },
feld('grundpreis.menge', 'Grundpreis: Menge', gpMenge, 'Nur bei Ware, die nach Gewicht, Volumen oder Länge verkauft wird, zum Beispiel 500 bei 500 g. Der Shop zeigt dann den Preis je 1 kg, 1 l, 1 m oder 1 m².'),
feld('grundpreis.einheit', 'Grundpreis: Einheit', gpEinheit))),
bestandAbschnitt,
abschnitt('Varianten', 'Optional: Wenn dein Artikel zum Beispiel in mehreren Größen mit eigenem Preis erhältlich ist.',
hakenFeld('variantenAn', variantenAn, 'Dieser Artikel hat Varianten'),
variantenBox),
abschnitt('Bilder', `Bis zu ${stand.maxBilder} Bilder (JPEG, PNG oder WebP, je höchstens 8 MB). Das erste Bild ist das Titelbild.`,
bilderListe,
bildFeld,
bildStatus,
bildFehler),
abschnitt('Lieferung', null, feld('lieferart', 'Lieferart', lieferart)),
abschnitt('Widerrufsrecht', null,
feld('widerruf', 'Widerrufsrecht für diesen Artikel', widerruf,
'Das ist deine eigene rechtliche Einschätzung als Verkäufer. Das gesetzliche Widerrufsrecht gilt, solange kein Ausnahmegrund (§ 312g Abs. 2 BGB) auf deine Ware zutrifft. WEBklar berät nicht rechtlich.')),
einklappbar('Produktsicherheit', 'Pflichtangaben nach der EU-Produktsicherheitsverordnung (GPSR): Wer stellt die Ware her, und worauf muss man achten?',
andererHersteller !== null || eu !== null || text(si.hinweise) !== '',
feld('sicherheit.herstellerArt', 'Hersteller', hersteller, 'Wenn du die Ware selbst herstellst, gelten deine Angaben aus den Einstellungen.'),
herstellerBox,
hakenFeld('sicherheit.ausserhalbEu', euAn, 'Der Hersteller sitzt außerhalb der EU', 'Dann brauchst du eine verantwortliche Person in der EU.'),
euBox,
feld('sicherheit.hinweise', 'Warn- und Sicherheitshinweise', hinweise, 'Zum Beispiel „Nicht für Kinder unter 3 Jahren geeignet“. Höchstens 2000 Zeichen.')),
einklappbar('Lebensmittel', 'Für Lebensmittel muss der Shop vor dem Kauf bestimmte Angaben zeigen (Lebensmittelinformationsverordnung).', lm.aktiv === true,
hakenFeld('lebensmittel.aktiv', lmAn, 'Dieser Artikel ist ein Lebensmittel'),
lmBox),
abschnitt('Sichtbarkeit', null, hakenFeld('aktiv', aktivHaken, 'Artikel ist aktiv', 'Nur aktive Artikel erscheinen im Shop.')),
h('div', { class: 'sh-aktionen' },
speichernKnopf,
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => zeigeListe() }, 'Abbrechen')))
// Fehlerhinweise in zugeklappten Bereichen sichtbar machen
const oeffneFehlerBereiche = () => {
for (const el of form.querySelectorAll('[data-fehler]')) if (el.textContent !== '') el.closest('details')?.setAttribute('open', '')
}
let sendet = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (sendet || hochladen) return
setzeFehler(form, {})
bildFehler.textContent = ''
sendet = true
speichernKnopf.disabled = true
try {
const antwort = await api(id ? `/api/shop/produkte/${encodeURIComponent(id)}` : '/api/shop/produkte', { method: id ? 'PUT' : 'POST', body: { projectId, produkt: sammle() } })
if (!noch()) return
const gespeichert = antwort.produkt
stand.produkte = id ? stand.produkte.map((x) => (x.id === id ? gespeichert : x)) : [...stand.produkte, gespeichert]
meldung(id ? 'Der Artikel wurde gespeichert.' : 'Der Artikel wurde angelegt.')
zeigeListe()
} catch (err) {
if (!noch()) return
zeigeFehler(form, err)
oeffneFehlerBereiche()
} finally {
sendet = false
speichernKnopf.disabled = hochladen
}
})
zeichneBilder()
zeigeBereiche()
panel.replaceChildren(form)
titel.focus()
}
zeigeListe()
}
+32
View File
@@ -113,3 +113,35 @@ a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: n
/* Reiter Rechtliches (shop-tab-rechtliches.js) */
.sh-gewaehrleistung-bild { display: block; max-width: 100%; height: auto; margin: 0 0 12px; border-radius: 8px; background: #fff; }
.sh-rechtliches textarea { font-family: inherit; line-height: 1.5; }
/* ----------------------------------------------------- Artikel (shop-tab-artikel.js) --- */
.sh-artikelliste { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; }
.sh-artikel { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 8px 14px; align-items: center; padding: 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); }
.sh-artikel-bild { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: rgba(255, 255, 255, 0.06); display: block; }
.sh-artikel-info { display: grid; gap: 2px; min-width: 0; }
.sh-artikel-name { overflow-wrap: anywhere; }
.sh-artikel-preis { font-variant-numeric: tabular-nums; }
.sh-artikel-bestand { font-size: 13px; color: var(--kb-muted); }
.sh-artikel-aus { color: #fca5a5; }
.sh-artikel-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; justify-content: flex-end; }
.sh-schalter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
.sh-schalter input { width: 18px; height: 18px; accent-color: var(--kb-purple); }
.sh-artikel .sh-frage-box { grid-column: 1 / -1; margin: 4px 0 0; }
.sh-artikel-editor .sh-abschnitt { margin-bottom: 16px; }
.sh-einklappbar > summary { cursor: pointer; padding: 2px 0; }
.sh-einklappbar > summary strong { font-size: 17px; }
.sh-einklappbar[open] > summary { margin-bottom: 6px; }
.sh-untergruppe[hidden], .sh-feld[hidden], .sh-abschnitt[hidden] { display: none; }
.sh-grundpreis { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 14px; }
.sh-wertzeile { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) auto; gap: 0 12px; align-items: end; margin-bottom: 4px; }
.sh-wertzeile > button { margin-bottom: 2px; }
.sh-bilder { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 12px; }
.sh-bild { display: grid; gap: 6px; justify-items: start; padding: 10px; border-radius: 12px; border: 1px solid var(--kb-border); }
.sh-bild img { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; }
.sh-bild .sh-pille { margin-bottom: 0; }
.sh-bild .sh-aktionen { margin-top: 0; }
@media (max-width: 600px) {
.sh-artikel { grid-template-columns: 56px minmax(0, 1fr); }
.sh-artikel-aktionen { grid-column: 1 / -1; justify-content: flex-start; }
.sh-grundpreis, .sh-wertzeile { grid-template-columns: minmax(0, 1fr); }
}
+578
View File
@@ -0,0 +1,578 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { euroText, h, request } from '../public/shop-dom.js'
import { render } from '../public/shop-tab-artikel.js'
// Task G4: Reiter „Artikel“ (public/shop-tab-artikel.js) mit Fake-DOM und Fake-fetch: Liste, Aktiv-Schalter, Sortierung, Editor (Anlegen, Varianten,
// Bilder mit rohem Upload, Feldfehler am richtigen Feld), Loeschen mit Rueckfrage, nurLesen. Die Server-Antworten sind von Hand gebaut
// (Form: server/routes/shop.js produktAnsicht, server/services/shop/produkte.js).
const P = 'p1'
const vorschau = (id) => ({ id, klein: `/api/shop/bild/${P}/${id}-s.webp`, gross: `/api/shop/bild/${P}/${id}-l.webp` })
/** Artikel wie ihn GET /api/shop/produkte liefert (alle gespeicherten Felder plus id, sort, bildUrls). */
const artikel = (id, name, extra = {}) => {
const a = {
id,
name,
kategorie: '',
kurz: '',
beschreibung: '',
preisCent: 1250,
steuersatz: 19,
grundpreis: null,
bestand: null,
aktiv: true,
sort: 0,
varianten: null,
bilder: [],
lieferart: 'beides',
widerruf: 'gesetzlich',
sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' },
lebensmittel: { aktiv: false, pflichtangaben: '' },
...extra,
}
a.bildUrls = a.bilder.map(vorschau)
return a
}
/** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */
const koerper = (a, extra = {}) => {
const { id, sort, bildUrls, ...rest } = a
void id; void sort; void bildUrls
return { ...rest, ...extra }
}
const LEER_KOERPER = {
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
}
/**
* Fake-Server: GET produkte und einstellungen aus den Parametern, alles andere aus sonst(aufruf) (Standard 200 {}).
* Der Upload (POST /api/shop/bild, rohe Bytes) laeuft am Fake-fetch vorbei: Aufrufe stehen in uploads, die Antwort kommt aus upload(aufruf)
* (Objekt oder Promise davon; Standard 201 { imageId: 'neu1' }), weil fakeDom nur JSON-Koerper kennt.
*/
function starte({ produkte = [], einstellungen = { kleinunternehmer: false }, nurLesen = false, sonst = () => null, upload, maxBilder = 4 } = {}) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/produkte?projectId=${P}`) return { body: { produkte, maxArtikel: 500, maxBilder } }
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/einstellungen?projectId=${P}`) return { body: { einstellungen, version: 1, vollstaendig: true } }
return sonst(aufruf) || { body: {} }
}
const fx = installFakeDom({ antworten: Array.from({ length: 80 }, () => route) })
const uploads = []
const echterFetch = globalThis.fetch
let zaehler = 0
globalThis.fetch = async (url, optionen = {}) => {
if (optionen.headers?.['Content-Type'] !== 'application/octet-stream') return echterFetch(url, optionen)
const aufruf = { method: optionen.method, url: String(url), datei: optionen.body, credentials: optionen.credentials }
uploads.push(aufruf)
zaehler += 1
const antwort = await (upload ? upload(aufruf, zaehler) : { status: 201, body: { imageId: `neu${zaehler}` } })
const { status = 201, body = {} } = antwort
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
const meldungen = []
const panel = h('div', { class: 'sh-panel' })
fx.document.body.append(panel)
const ctx = { projectId: P, nurLesen, aktuell: () => true, request, zeigeMeldung: (text, art) => meldungen.push({ text, art: art || 'ok' }) }
return { fx, panel, ctx, uploads, meldungen }
}
const mit = (optionen, fn) => async () => {
const t = starte(optionen)
try {
await render(t.panel, t.ctx)
await t.fx.ruhe()
await fn(t)
} finally {
t.fx.restore()
}
}
const aenderungen = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
const knopf = (fx, wurzel, aktion) => fx.finde(wurzel, `[data-aktion="${aktion}"]`)
const feldVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-feld="${name}"]`)
const fehlerVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-fehler="${name}"]`)
const zeile = (fx, wurzel, id) => fx.finde(wurzel, `[data-artikel="${id}"]`)
const bereich = (fx, wurzel, name) => fx.finde(wurzel, `[data-bereich="${name}"]`)
const datei = (name, typ, groesse) => ({ name, type: typ, size: groesse })
const DREI = () => [
artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'] }),
artikel('a2', 'Schal', { kategorie: 'Winter', sort: 1, aktiv: false, bestand: 0 }),
artikel('a3', 'Handschuhe', { kategorie: 'Sommer', sort: 2, preisCent: null, varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'L', preisCent: 1000, bestand: null }] } }),
]
/* ------------------------------------------------------------------ Liste --- */
test('Liste: Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten und Löschen je Artikel', mit({ produkte: DREI() }, async ({ fx, panel }) => {
assert.deepEqual(fx.fetchLog.map((a) => a.url).sort(), [`/api/shop/einstellungen?projectId=${P}`, `/api/shop/produkte?projectId=${P}`])
const reihenfolge = fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel'))
assert.deepEqual(reihenfolge, ['a1', 'a2', 'a3'])
const a1 = zeile(fx, panel, 'a1')
assert.match(fx.text(a1), /Mütze/)
assert.equal(fx.text(a1).includes(euroText(1250)), true)
assert.equal(fx.text(fx.finde(a1, '[data-bestand]')), '5 Stück')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a2'), '[data-bestand]')), 'ausverkauft')
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a1'), '.sh-artikel-preis')), euroText(1250))
const a3 = zeile(fx, panel, 'a3')
assert.equal(fx.text(fx.finde(a3, '.sh-artikel-preis')), `ab ${euroText(800)}`, 'Varianten: ab dem kleinsten Preis')
assert.equal(fx.text(fx.finde(a3, '[data-bestand]')), 'unbegrenzt', 'ein Wert ohne Bestandsgrenze ⇒ unbegrenzt')
assert.equal(knopf(fx, a1, 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a2'), 'aktiv').checked, false)
assert.equal(knopf(fx, a1, 'hoch').disabled, true, 'der erste kann nicht höher')
assert.equal(knopf(fx, a3, 'runter').disabled, true, 'der letzte kann nicht tiefer')
assert.equal(knopf(fx, a1, 'runter').disabled, false)
for (const a of ['bearbeiten', 'loeschen']) assert.ok(knopf(fx, a1, a), a)
assert.equal(fx.finde(a1, 'img').getAttribute('src'), `/api/shop/bild/${P}/b1-s.webp`, 'Vorschau des Titelbilds')
assert.ok(knopf(fx, panel, 'artikel-neu'))
assert.match(fx.text(panel), /3 von höchstens 500 Artikeln/)
assert.deepEqual(aenderungen(fx), [])
}))
test('Liste ohne Artikel: Hinweis und Knopf „Artikel anlegen“, der den Editor öffnet', mit({ produkte: [] }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Du hast noch keine Artikel/)
assert.equal(fx.text(knopf(fx, panel, 'artikel-neu')), 'Artikel anlegen')
assert.equal(fx.finde(panel, '[data-artikel]'), null)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
assert.ok(fx.finde(panel, 'form'), 'Editor statt Liste')
assert.equal(knopf(fx, panel, 'artikel-neu'), null)
assert.match(fx.text(panel), /Artikel anlegen/)
await fx.klick(knopf(fx, panel, 'abbrechen'))
assert.equal(fx.finde(panel, 'form'), null)
assert.ok(knopf(fx, panel, 'artikel-neu'))
}))
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { status: 500, body: { error: 'Interner Serverfehler' } }] })
try {
const panel = h('div')
fx.document.body.append(panel)
await render(panel, { projectId: P, nurLesen: false, request, zeigeMeldung() {} })
assert.match(fx.text(panel), /Interner Serverfehler/)
assert.match(fx.text(panel), /Noch einmal versuchen/)
} finally {
fx.restore()
}
})
/* ------------------------------------------------------------------ Schalter --- */
test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, danach zeigt die Liste den neuen Stand', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'], aktiv: false }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv'))
const [put, ...rest] = aenderungen(fx)
assert.equal(rest.length, 0)
assert.equal(put.method, 'PUT')
assert.equal(put.url, '/api/shop/produkte/a1')
assert.deepEqual(put.body, { projectId: P, produkt: koerper(DREI()[0], { aktiv: false }) })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false)
assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/)
}))
test('Schalter „aktiv“ schlägt fehl: Fehlermeldung, Schalter springt zurück', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { status: 500, body: { error: 'Interner Serverfehler' } } : null),
}, async ({ fx, panel, meldungen }) => {
const schalter = knopf(fx, zeile(fx, panel, 'a1'), 'aktiv')
await fx.klick(schalter)
assert.deepEqual(meldungen.at(-1), { text: 'Interner Serverfehler', art: 'fehler' })
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, true)
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').disabled, false)
}))
/* ---------------------------------------------------------------- Sortierung --- */
test('Runter und Hoch: POST sortierung mit der neuen Reihenfolge, die Liste folgt der Antwort', mit({
produkte: DREI(),
sonst: (a) => {
if (a.url !== '/api/shop/produkte/sortierung') return null
const nach = (id) => DREI().find((x) => x.id === id)
return { body: { produkte: a.body.ids.map(nach) } }
},
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'runter'))
assert.deepEqual(aenderungen(fx).at(-1), { method: 'POST', url: '/api/shop/produkte/sortierung', body: { projectId: P, ids: ['a2', 'a1', 'a3'] } })
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a1', 'a3'])
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'hoch'))
assert.deepEqual(aenderungen(fx).at(-1).body.ids, ['a2', 'a3', 'a1'])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a3', 'a1'])
}))
/* -------------------------------------------------------------------- Anlegen --- */
test('Anlegen: Felder, Preis aus „12,5“, Grundpreis, Datalist mit vorhandenen Kategorien, POST mit allem', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Kerze', { sort: 3, preisCent: 1250 }) } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const kategorienOptionen = fx.alle(form, 'datalist option').map((o) => o.getAttribute('value'))
assert.deepEqual(kategorienOptionen, ['Winter', 'Sommer'])
assert.equal(feldVon(fx, form, 'kategorie').getAttribute('list'), fx.finde(form, 'datalist').id)
assert.equal(bereich(fx, form, 'steuersatz').hidden, false, 'kein Kleinunternehmer ⇒ Steuersatz sichtbar')
assert.equal(bereich(fx, form, 'varianten').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'Kerze')
await fx.setze(feldVon(fx, form, 'kategorie'), 'Deko')
await fx.setze(feldVon(fx, form, 'kurz'), 'Duftet gut')
await fx.setze(feldVon(fx, form, 'beschreibung'), 'Erster Absatz\n\nZweiter Absatz')
await fx.setze(feldVon(fx, form, 'preisCent'), '12,5')
assert.equal(feldVon(fx, form, 'preisCent').value, '12,50')
await fx.setze(feldVon(fx, form, 'steuersatz'), '7')
await fx.setze(feldVon(fx, form, 'grundpreis.menge'), '500')
await fx.setze(feldVon(fx, form, 'grundpreis.einheit'), 'g')
await fx.setze(feldVon(fx, form, 'bestand'), '8')
await fx.setze(feldVon(fx, form, 'lieferart'), 'abholung')
await fx.setze(feldVon(fx, form, 'widerruf'), 'verderblich')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx), [{
method: 'POST',
url: '/api/shop/produkte',
body: {
projectId: P,
produkt: {
...LEER_KOERPER,
name: 'Kerze',
kategorie: 'Deko',
kurz: 'Duftet gut',
beschreibung: 'Erster Absatz\n\nZweiter Absatz',
preisCent: 1250,
steuersatz: 7,
grundpreis: { menge: 500, einheit: 'g' },
bestand: 8,
lieferart: 'abholung',
widerruf: 'verderblich',
},
},
}])
assert.equal(fx.finde(panel, 'form'), null, 'zurück zur Liste')
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a2', 'a3', 'neu'])
assert.deepEqual(meldungen.at(-1), { text: 'Der Artikel wurde angelegt.', art: 'ok' })
}))
test('Kleinunternehmer: Steuersatz ausgeblendet, gesendet wird 0; Bestand leer ⇒ unbegrenzt (null)', mit({
einstellungen: { kleinunternehmer: true },
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'X') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'steuersatz').hidden, true)
await fx.setze(feldVon(fx, form, 'name'), 'X')
await fx.setze(feldVon(fx, form, 'preisCent'), '3')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.equal(produkt.steuersatz, 0)
assert.equal(produkt.bestand, null)
assert.equal(produkt.preisCent, 300)
}))
test('Produktsicherheit und Lebensmittel: Hersteller „ein anderer“, EU-Verantwortlicher nur mit Haken, Pflichtangaben nur für Lebensmittel', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Honig') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
assert.equal(bereich(fx, form, 'hersteller').hidden, true)
assert.equal(bereich(fx, form, 'eu').hidden, true)
assert.equal(bereich(fx, form, 'lebensmittel').hidden, true)
await fx.setze(feldVon(fx, form, 'sicherheit.herstellerArt'), 'andere')
assert.equal(bereich(fx, form, 'hersteller').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.name'), 'Imkerei Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.anschrift'), 'Dorfstr. 1, 12345 Berg')
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.kontakt'), 'berg@example.test')
await fx.klick(feldVon(fx, form, 'sicherheit.ausserhalbEu'))
assert.equal(bereich(fx, form, 'eu').hidden, false)
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.name'), 'EU Vertretung GmbH')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.anschrift'), 'Hauptstr. 2, 10115 Berlin')
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.kontakt'), 'eu@example.test')
await fx.setze(feldVon(fx, form, 'sicherheit.hinweise'), 'Nicht für Kinder unter 1 Jahr.')
await fx.klick(feldVon(fx, form, 'lebensmittel.aktiv'))
assert.equal(bereich(fx, form, 'lebensmittel').hidden, false)
assert.match(fx.text(form), /Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf\. Hersteller/)
await fx.setze(feldVon(fx, form, 'lebensmittel.pflichtangaben'), 'Honig, 250 g, enthält keine Allergene')
await fx.setze(feldVon(fx, form, 'name'), 'Honig')
await fx.setze(feldVon(fx, form, 'preisCent'), '6,90')
await fx.klick(knopf(fx, form, 'speichern'))
const { produkt } = aenderungen(fx)[0].body
assert.deepEqual(produkt.sicherheit, {
hersteller: { name: 'Imkerei Berg', anschrift: 'Dorfstr. 1, 12345 Berg', kontakt: 'berg@example.test' },
euVerantwortlicher: { name: 'EU Vertretung GmbH', anschrift: 'Hauptstr. 2, 10115 Berlin', kontakt: 'eu@example.test' },
hinweise: 'Nicht für Kinder unter 1 Jahr.',
})
assert.deepEqual(produkt.lebensmittel, { aktiv: true, pflichtangaben: 'Honig, 250 g, enthält keine Allergene' })
}))
/* ------------------------------------------------------------------ Varianten --- */
test('Varianten: Schalter blendet Preis und Bestand des Artikels aus, Zeilen hinzufügen und entfernen, Körper mit Werten', mit({
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Shirt') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
await fx.setze(feldVon(fx, form, 'preisCent'), '99')
await fx.setze(feldVon(fx, form, 'bestand'), '4')
await fx.klick(feldVon(fx, form, 'variantenAn'))
assert.equal(bereich(fx, form, 'varianten').hidden, false)
assert.equal(bereich(fx, form, 'einzelpreis').hidden, true)
assert.equal(bereich(fx, form, 'einzelbestand').hidden, true)
assert.equal(fx.alle(form, '[data-wert]').length, 1, 'eine leere Zeile zum Start')
await fx.setze(feldVon(fx, form, 'varianten.name'), 'Größe')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.name'), 'S')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.preisCent'), '10')
await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '5')
await fx.klick(knopf(fx, form, 'wert-neu'))
await fx.klick(knopf(fx, form, 'wert-neu'))
assert.equal(fx.alle(form, '[data-wert]').length, 3)
await fx.setze(feldVon(fx, form, 'varianten.werte.1.name'), 'M')
await fx.setze(feldVon(fx, form, 'varianten.werte.1.preisCent'), '12,5')
await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'weg')
await fx.klick(fx.alle(form, '[data-aktion="wert-weg"]')[2])
assert.equal(fx.alle(form, '[data-wert]').length, 2)
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'M', 'Eingaben bleiben beim Neuzeichnen erhalten')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt, {
...LEER_KOERPER,
name: 'Shirt',
preisCent: null,
bestand: null,
varianten: { name: 'Größe', werte: [{ name: 'S', preisCent: 1000, bestand: 5 }, { name: 'M', preisCent: 1250, bestand: null }] },
})
}))
test('Varianten ausschalten: der Körper trägt wieder Preis und Bestand des Artikels, varianten = null', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
assert.equal(feldVon(fx, form, 'variantenAn').checked, true)
assert.equal(feldVon(fx, form, 'varianten.name').value, 'Größe')
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'L')
assert.equal(feldVon(fx, form, 'varianten.werte.0.preisCent').value, '8,00')
await fx.klick(feldVon(fx, form, 'variantenAn'))
await fx.setze(feldVon(fx, form, 'preisCent'), '9,99')
await fx.klick(knopf(fx, form, 'speichern'))
const put = aenderungen(fx)[0]
assert.equal(put.url, '/api/shop/produkte/a3')
assert.equal(put.body.produkt.varianten, null)
assert.equal(put.body.produkt.preisCent, 999)
}))
test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb'])
assert.deepEqual(aenderungen(fx)[0].body.produkt, koerper(DREI()[2]))
}))
/* --------------------------------------------------------------------- Bilder --- */
test('Bilder: roher Upload, Vorschau, „wird hochgeladen“, Titelbild, Entfernen, Bild-IDs im Körper', async () => {
let freigabe
const t = starte({
produkte: [],
upload: (aufruf, n) => (n === 1 ? new Promise((ok) => { freigabe = () => ok({ status: 201, body: { imageId: 'bild1', w: 800, h: 600 } }) }) : { status: 201, body: { imageId: `bild${n}` } }),
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Tasche') } } : null),
})
const { fx, panel, uploads } = t
try {
await render(panel, t.ctx)
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
assert.match(eingabe.getAttribute('accept'), /image\/jpeg,image\/png,image\/webp/)
const erste = datei('a.jpg', 'image/jpeg', 1000)
eingabe.files = [erste]
const lauf = fx.sende(eingabe, 'change')
await fx.ruhe()
assert.match(fx.text(fx.finde(form, '[data-bild-status]')), /wird hochgeladen/)
assert.equal(eingabe.disabled, true)
assert.equal(knopf(fx, form, 'speichern').disabled, true, 'während des Uploads nicht speichern')
freigabe()
await lauf
assert.equal(uploads.length, 1)
assert.deepEqual(uploads[0], { method: 'POST', url: `/api/shop/bild?projectId=${P}`, datei: erste, credentials: 'same-origin' }, 'rohe Datei, kein JSON')
assert.equal(fx.text(fx.finde(form, '[data-bild-status]')), '')
assert.equal(eingabe.disabled, false)
assert.equal(knopf(fx, form, 'speichern').disabled, false)
assert.equal(fx.finde(form, '[data-bild="bild1"] img').getAttribute('src'), `/api/shop/bild/${P}/bild1-s.webp`)
assert.match(fx.text(fx.finde(form, '[data-bild="bild1"]')), /Titelbild/)
for (const name of ['b.png', 'c.webp']) {
eingabe.files = [datei(name, name.endsWith('png') ? 'image/png' : 'image/webp', 2000)]
await fx.sende(eingabe, 'change')
}
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild1', 'bild2', 'bild3'])
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild1', 'bild2'], 'erstes = Titelbild')
assert.equal(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'), null, 'das Titelbild hat den Knopf nicht')
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild1"]'), 'bild-weg'))
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild2'])
await fx.setze(feldVon(fx, form, 'name'), 'Tasche')
await fx.setze(feldVon(fx, form, 'preisCent'), '20')
await fx.klick(knopf(fx, form, 'speichern'))
assert.deepEqual(aenderungen(fx)[0].body.produkt.bilder, ['bild3', 'bild2'])
} finally {
fx.restore()
}
})
test('Bilder: höchstens vier, danach verschwindet das Feld; zu großes oder falsches Bild wird nicht gesendet', mit({ maxBilder: 2 }, async ({ fx, panel, uploads }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('riesig.jpg', 'image/jpeg', 9 * 1024 * 1024)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('x.gif', 'image/gif', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(uploads.length, 0)
for (const n of [1, 2]) {
eingabe.files = [datei(`${n}.jpg`, 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
}
assert.equal(fx.alle(form, '[data-bild]').length, 2)
assert.equal(eingabe.parentNode.hidden, true, 'Limit erreicht ⇒ kein weiteres Bild')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, '', 'ein neuer Versuch löscht den alten Fehler')
}))
test('Bilder: Serverfehler 413 und 415 stehen am Bilder-Abschnitt, kein Bild kommt dazu', async () => {
const antworten = [{ status: 413, body: { error: 'Das Bild ist größer als 8 MB.' } }, { status: 415, body: {} }]
const t = starte({ upload: () => antworten.shift() })
try {
await render(t.panel, t.ctx)
const { fx, panel } = t
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
const eingabe = fx.finde(form, 'input[type="file"]')
eingabe.files = [datei('a.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
eingabe.files = [datei('b.jpg', 'image/jpeg', 100)]
await fx.sende(eingabe, 'change')
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
assert.equal(fx.alle(form, '[data-bild]').length, 0)
assert.equal(eingabe.disabled, false)
} finally {
t.fx.restore()
}
})
/* ----------------------------------------------------------------- Feldfehler --- */
test('Feldfehler des Servers: Punkt-Pfade stehen am richtigen Feld, zugeklappte Bereiche gehen auf, der Fokus geht zum ersten Fehler', mit({
produkte: [artikel('a1', 'Shirt', {
preisCent: null,
grundpreis: { menge: 500, einheit: 'g' },
varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: null }, { id: 'cccccccc', name: 'L', preisCent: 1000, bestand: null }] },
sicherheit: { hersteller: { name: 'Fabrik', anschrift: '', kontakt: 'f@example.test' }, euVerantwortlicher: null, hinweise: '' },
})],
sonst: (a) => (a.method === 'PUT'
? {
status: 400,
body: {
error: 'Bitte prüfe deine Eingaben.',
fehler: {
name: 'Bitte gib einen Namen für den Artikel an.',
'grundpreis.menge': 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.',
'varianten.werte.2.preisCent': 'Bitte gib den Preis des Werts als Betrag über 0 € an.',
'sicherheit.hersteller.anschrift': 'Bitte gib die Anschrift bei den Hersteller an.',
bilder: 'Eines der Bilder gibt es nicht (mehr).',
unbekannt: 'Irgendetwas stimmt nicht.',
},
},
}
: null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten'))
const form = fx.finde(panel, 'form')
const sicherheit = fx.finde(form, 'details')
assert.equal(sicherheit.hasAttribute('open'), true, 'Hersteller gesetzt ⇒ Produktsicherheit offen')
const lebensmittel = fx.alle(form, 'details')[1]
assert.equal(lebensmittel.hasAttribute('open'), false)
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fehlerVon(fx, form, 'name').textContent, 'Bitte gib einen Namen für den Artikel an.')
assert.equal(fehlerVon(fx, form, 'grundpreis.menge').textContent, 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.')
assert.equal(fehlerVon(fx, form, 'varianten.werte.2.preisCent').textContent, 'Bitte gib den Preis des Werts als Betrag über 0 € an.')
assert.equal(fehlerVon(fx, form, 'sicherheit.hersteller.anschrift').textContent, 'Bitte gib die Anschrift bei den Hersteller an.')
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Eines der Bilder gibt es nicht (mehr).')
assert.equal(fehlerVon(fx, form, '_').textContent, 'Irgendetwas stimmt nicht.', 'Fehler ohne Feld kommen allgemein oben')
for (const n of ['name', 'grundpreis.menge', 'varianten.werte.2.preisCent', 'sicherheit.hersteller.anschrift']) {
assert.equal(feldVon(fx, form, n).getAttribute('aria-invalid'), 'true', n)
}
assert.equal(feldVon(fx, form, 'varianten.werte.0.name').getAttribute('aria-invalid'), null)
assert.equal(fx.document.activeElement, feldVon(fx, form, 'name'), 'Fokus auf dem ersten Fehler')
assert.ok(fx.finde(panel, 'form'), 'der Editor bleibt offen, die Eingaben auch')
assert.equal(knopf(fx, form, 'speichern').disabled, false)
// ein neuer Versuch räumt die alten Fehler weg
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
fx.antworten.unshift(() => ({ body: { produkt: artikel('a1', 'Shirt') } }))
await fx.klick(knopf(fx, form, 'speichern'))
assert.equal(fx.finde(panel, 'form'), null)
}))
test('Höchstzahl der Artikel: die Meldung des Servers steht im Editor', mit({
sonst: (a) => (a.method === 'POST' ? { status: 409, body: { error: 'Du hast die Höchstzahl von 500 Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.' } } : null),
}, async ({ fx, panel }) => {
await fx.klick(knopf(fx, panel, 'artikel-neu'))
const form = fx.finde(panel, 'form')
await fx.setze(feldVon(fx, form, 'name'), 'Noch einer')
await fx.setze(feldVon(fx, form, 'preisCent'), '1')
await fx.klick(knopf(fx, form, 'speichern'))
assert.match(fehlerVon(fx, form, '_').textContent, /Höchstzahl von 500 Artikeln/)
}))
/* -------------------------------------------------------------------- Löschen --- */
test('Löschen: Rückfrage in der Seite, Abbrechen tut nichts, „Ja, löschen“ schickt DELETE und entfernt die Zeile', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { body: { ok: true } } : null),
}, async ({ fx, panel, meldungen }) => {
const a2 = zeile(fx, panel, 'a2')
await fx.klick(knopf(fx, a2, 'loeschen'))
assert.match(fx.text(a2), /„Schal“ wirklich löschen\?/)
assert.deepEqual(aenderungen(fx), [], 'noch nichts gelöscht')
await fx.klick(knopf(fx, a2, 'loeschen-nein'))
assert.equal(fx.finde(a2, '.sh-frage-box'), null)
assert.deepEqual(aenderungen(fx), [])
await fx.klick(knopf(fx, a2, 'loeschen'))
await fx.klick(knopf(fx, a2, 'loeschen-ja'))
assert.deepEqual(aenderungen(fx), [{ method: 'DELETE', url: `/api/shop/produkte/a2?projectId=${P}`, body: undefined }])
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a3'])
assert.match(meldungen.at(-1).text, /Schal.*gelöscht/)
}))
test('Löschen schlägt fehl: Meldung, Zeile bleibt', mit({
produkte: DREI(),
sonst: (a) => (a.method === 'DELETE' ? { status: 404, body: { error: 'Diesen Artikel gibt es nicht (mehr).' } } : null),
}, async ({ fx, panel, meldungen }) => {
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen'))
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen-ja'))
assert.deepEqual(meldungen.at(-1), { text: 'Diesen Artikel gibt es nicht (mehr).', art: 'fehler' })
assert.ok(zeile(fx, panel, 'a2'))
}))
/* ------------------------------------------------------------------- nurLesen --- */
test('nurLesen: nur die Liste, keine Knöpfe und keine Schalter', mit({ produkte: DREI(), nurLesen: true }, async ({ fx, panel }) => {
assert.equal(fx.finde(panel, 'button'), null)
assert.equal(fx.finde(panel, 'input'), null)
assert.match(fx.text(zeile(fx, panel, 'a1')), /Mütze/)
assert.match(fx.text(zeile(fx, panel, 'a1')), /aktiv/)
assert.match(fx.text(zeile(fx, panel, 'a2')), /inaktiv/)
}))
test('nurLesen ohne Artikel: Hinweis ohne „Artikel anlegen“', mit({ produkte: [], nurLesen: true }, async ({ fx, panel }) => {
assert.match(fx.text(panel), /Es gibt noch keine Artikel\./)
assert.equal(fx.finde(panel, 'button'), null)
}))
/* --------------------------------------------------------------------- Statik --- */
test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; Fremdtext nur über h()', () => {
const quelle = readFileSync(new URL('../public/shop-tab-artikel.js', import.meta.url), 'utf8')
const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((z) => !z.trim().startsWith('//')).join('\n')
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) {
assert.equal(code.includes(verboten), false, verboten)
}
assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js')
})