Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen
- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
{ nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0090332c70
commit
747be432c5
7 files changed
+1032
-59
No files matched your search
@@ -112,8 +112,9 @@ export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
|
||||
try {
|
||||
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
|
||||
if (!ctx.aktuell()) return false
|
||||
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
|
||||
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
|
||||
// fehlt (Pflichtangaben fuer Gaeste) nennt der Server in der Antwort: in aktiver Funktion ist es nach einem Erfolg leer, im Entwurf
|
||||
// des Fragebogens koennen Telefon und Anschrift noch fehlen
|
||||
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: antwort.fehlt ?? [] })
|
||||
ctx.hinweise()
|
||||
try {
|
||||
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
|
||||
|
||||
@@ -69,48 +69,79 @@ export function render(panel, ctx) {
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Betrieb --- */
|
||||
function betrieb(ctx, e) {
|
||||
const textFeld = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
|
||||
const urlFeld = (wert) => textFeld(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
|
||||
|
||||
/** Die Angaben zum Betrieb in der Reihenfolge der Seite: Name, Mailadresse des Betriebs, Telefon, Anschrift, Kleinunternehmer, vier Links. */
|
||||
const BETRIEB_FELDER = {
|
||||
name: { eingabe: (b) => textFeld(b.name, MAX.betriebName), zeige: (i) => feld('betrieb.name', 'Name des Betriebs', i) },
|
||||
email: {
|
||||
eingabe: (b) => h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }),
|
||||
zeige: (i) => feld('betrieb.email', 'E-Mail des Betriebs', i, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.'),
|
||||
},
|
||||
telefon: {
|
||||
eingabe: (b) => textFeld(b.telefon, MAX.telefon),
|
||||
zeige: (i) => feld('betrieb.telefon', 'Telefon (Pflicht)', i, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
|
||||
},
|
||||
adresse: {
|
||||
eingabe: (b) => textFeld(b.adresse, MAX.adresse),
|
||||
zeige: (i) => feld('betrieb.adresse', 'Anschrift', i, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.'),
|
||||
},
|
||||
kleinunternehmer: {
|
||||
eingabe: (b) => h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
|
||||
zeige: (i) => hakenFeld('betrieb.kleinunternehmer', i, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
|
||||
},
|
||||
impressumUrl: { eingabe: (b) => urlFeld(b.impressumUrl), zeige: (i) => feld('betrieb.impressumUrl', 'Link zum Impressum', i, 'Vollständige Adresse mit https://') },
|
||||
datenschutzUrl: { eingabe: (b) => urlFeld(b.datenschutzUrl), zeige: (i) => feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', i, 'Vollständige Adresse mit https://') },
|
||||
agbUrl: { eingabe: (b) => urlFeld(b.agbUrl), zeige: (i) => feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', i, 'Vollständige Adresse mit https://') },
|
||||
websiteUrl: { eingabe: (b) => urlFeld(b.websiteUrl), zeige: (i) => feld('betrieb.websiteUrl', 'Link zu deiner Website', i, 'Vollständige Adresse mit https://') },
|
||||
}
|
||||
/** Wie die Felder auf der Seite zusammenstehen: Zeilen im Raster, das Ankreuzfeld steht allein. */
|
||||
const BETRIEB_ZEILEN = [
|
||||
{ felder: ['name', 'email'], raster: true },
|
||||
{ felder: ['telefon', 'adresse'], raster: true },
|
||||
{ felder: ['kleinunternehmer'], raster: false },
|
||||
{ felder: ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'], raster: true },
|
||||
]
|
||||
const BETRIEB_TITEL = 'Dein Betrieb'
|
||||
const BETRIEB_HINWEIS = 'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.'
|
||||
|
||||
/**
|
||||
* Abschnitt „Dein Betrieb“. Ohne Optionen alle neun Felder wie bisher. Der Fragebogen zeigt in einem Schritt nur einen Teil:
|
||||
* nur = Teilmenge der Felder (name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl); es entstehen nur
|
||||
* diese Felder (in der Reihenfolge der Seite) und lese schreibt nur diese in den Entwurf. titel und hinweis ersetzen Ueberschrift und Erklaersatz
|
||||
* (hinweis: null laesst ihn weg). lese(entwurf) schreibt in entwurf.betrieb und gibt die gelesenen Felder zurueck (nur die gezeigten).
|
||||
* Der Hinweis auf fehlende Pflichtangaben nennt mit nur nur Angaben, die dabei zu sehen sind.
|
||||
*/
|
||||
export function betrieb(ctx, e, { nur, titel = BETRIEB_TITEL, hinweis = BETRIEB_HINWEIS } = {}) {
|
||||
const b = e.betrieb || {}
|
||||
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
|
||||
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
|
||||
const f = {
|
||||
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
|
||||
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
|
||||
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
|
||||
}
|
||||
const fehlt = ctx.daten.fehlt || []
|
||||
for (const name of nur || []) if (!Object.hasOwn(BETRIEB_FELDER, name)) throw new Error(`Unbekanntes Feld im Betrieb: ${name}`)
|
||||
const gezeigt = Object.keys(BETRIEB_FELDER).filter((name) => !nur || nur.includes(name))
|
||||
const f = Object.fromEntries(gezeigt.map((name) => [name, BETRIEB_FELDER[name].eingabe(b)]))
|
||||
const fehlt = (ctx.daten.fehlt || []).filter((pfad) => !nur || gezeigt.includes(pfad.replace(/^betrieb\./, '')))
|
||||
const warnung = fehlt.length
|
||||
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
|
||||
: null
|
||||
const el = abschnitt('Dein Betrieb',
|
||||
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
|
||||
warnung,
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('betrieb.name', 'Name des Betriebs', f.name),
|
||||
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
|
||||
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.')),
|
||||
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
|
||||
h('div', { class: 'tm-raster' },
|
||||
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
|
||||
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
|
||||
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
|
||||
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
|
||||
const zeilen = BETRIEB_ZEILEN
|
||||
.map(({ felder, raster }) => {
|
||||
const els = felder.filter((name) => f[name]).map((name) => BETRIEB_FELDER[name].zeige(f[name]))
|
||||
if (!els.length) return null
|
||||
return raster ? h('div', { class: 'tm-raster' }, els) : els
|
||||
})
|
||||
.filter(Boolean)
|
||||
const el = abschnitt(titel, hinweis, warnung, ...zeilen)
|
||||
return {
|
||||
el,
|
||||
lese(entwurf) {
|
||||
entwurf.betrieb = {
|
||||
...entwurf.betrieb,
|
||||
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
|
||||
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
|
||||
}
|
||||
lese(entwurf = {}) {
|
||||
const gelesen = Object.fromEntries(gezeigt.map((name) => [name, name === 'kleinunternehmer' ? f[name].checked : f[name].value]))
|
||||
entwurf.betrieb = { ...entwurf.betrieb, ...gelesen }
|
||||
return gelesen
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- Anrede und Farbe --- */
|
||||
function darstellung(e) {
|
||||
export function darstellung(e) {
|
||||
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
|
||||
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
|
||||
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
|
||||
@@ -121,7 +152,7 @@ function darstellung(e) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ Regeln --- */
|
||||
function regeln(e) {
|
||||
export function regeln(e) {
|
||||
const r = e.regeln || {}
|
||||
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
|
||||
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
|
||||
@@ -146,7 +177,7 @@ function regeln(e) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Angaben von Gaesten --- */
|
||||
function gastFelder(ctx, e) {
|
||||
export function gastFelder(ctx, e) {
|
||||
const g = e.gastFelder || {}
|
||||
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
|
||||
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
|
||||
@@ -210,7 +241,7 @@ function gastFelder(ctx, e) {
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Erinnerung --- */
|
||||
function erinnerung(e) {
|
||||
export function erinnerung(e) {
|
||||
const r = e.erinnerung || {}
|
||||
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
|
||||
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
|
||||
@@ -221,7 +252,7 @@ function erinnerung(e) {
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Stornobedingungen --- */
|
||||
function storno(ctx, e, fristFeld) {
|
||||
export function storno(ctx, e, fristFeld) {
|
||||
const s = e.storno || {}
|
||||
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
|
||||
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
|
||||
@@ -243,7 +274,7 @@ function storno(ctx, e, fristFeld) {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Rechtliches --- */
|
||||
function rechtliches(e) {
|
||||
export function rechtliches(e) {
|
||||
const r = e.recht || {}
|
||||
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
|
||||
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
|
||||
@@ -255,7 +286,7 @@ function rechtliches(e) {
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Bezahlung --- */
|
||||
function zahlung(e) {
|
||||
export function zahlung(e) {
|
||||
const z = e.zahlung || {}
|
||||
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
|
||||
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
|
||||
|
||||
+32
-12
@@ -60,6 +60,34 @@ function personen(t) {
|
||||
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Wochenplan einer Person/eines Platzes: je Wochentag ein Textfeld mit den Zeitfenstern (Text wie „09:00–12:30, 13:30–18:00“, leer = geschlossen),
|
||||
* vorbelegt aus r.plan (r darf fehlen). pfad ist der Pfad der Person in der Einstellung ('ressourcen.1'); die Felder heissen `${pfad}.plan.${nr}`,
|
||||
* so finden die Fehler des Servers (ressourcen.1.plan.3) ihr Feld. Der Editor einer Person und der Fragebogen (Schritt „Zeiten“) bauen ihn hiermit.
|
||||
* => { el, lese }: el ist die Gruppe (Beschriftung, Hinweis, sieben Felder), lese() liefert { plan } (geschlossene Tage fehlen im Plan) oder, bei
|
||||
* einem Fenster, das nicht zu lesen ist, { fehler } (Feldname ⇒ Text; dann gibt es keinen Teilplan). Pruefen im Einzelnen bleibt Sache des Servers.
|
||||
*/
|
||||
export function wochenplanFeld(r, pfad) {
|
||||
const texte = planTexte(r?.plan)
|
||||
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
|
||||
nr, name: `${pfad}.plan.${nr}`,
|
||||
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
|
||||
tag,
|
||||
}))
|
||||
const el = h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
|
||||
h('legend', { class: 'tm-label' }, 'Wochenplan'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
|
||||
h('div', { class: 'tm-raster' }, tage.map(({ name, input, tag }) => feld(name, tag, input))))
|
||||
return {
|
||||
el,
|
||||
lese() {
|
||||
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
|
||||
if (gelesen.fehler) return { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${pfad}.plan.${nr}`, text])) }
|
||||
return { plan: gelesen.plan }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
|
||||
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
|
||||
@@ -78,12 +106,7 @@ function formular(t, { r, schliessen }) {
|
||||
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
|
||||
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
|
||||
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
|
||||
const texte = planTexte(alt.plan)
|
||||
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
|
||||
nr, name: `${p}.plan.${nr}`,
|
||||
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
|
||||
tag,
|
||||
}))
|
||||
const wochenplan = wochenplanFeld(alt, p)
|
||||
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
|
||||
|
||||
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
|
||||
@@ -105,10 +128,7 @@ function formular(t, { r, schliessen }) {
|
||||
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
|
||||
feld(`${p}.typ`, 'Art', typ)),
|
||||
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
|
||||
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
|
||||
h('legend', { class: 'tm-label' }, 'Wochenplan'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
|
||||
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
|
||||
wochenplan.el,
|
||||
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
|
||||
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
|
||||
zuordnung.length
|
||||
@@ -124,9 +144,9 @@ function formular(t, { r, schliessen }) {
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
const ok = await sp.speichere((entwurf) => {
|
||||
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
|
||||
const gelesen = wochenplan.lese()
|
||||
if (gelesen.fehler) {
|
||||
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
|
||||
zeigeFehler(form, { fehler: gelesen.fehler })
|
||||
return false
|
||||
}
|
||||
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
|
||||
|
||||
+19
-8
@@ -2,7 +2,7 @@
|
||||
initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern.
|
||||
Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js,
|
||||
termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js).
|
||||
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden }: Zustand und Pruefung "bin ich noch
|
||||
ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch
|
||||
aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen
|
||||
(die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen,
|
||||
hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT';
|
||||
@@ -164,15 +164,18 @@ function zeigeMeldung(s, text, art = 'ok') {
|
||||
el.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
function zeigeReiter(s, { projekt, aktuell }) {
|
||||
/**
|
||||
* Kontext fuer die Module, die in der Huelle laufen (die Reiter, der Fragebogen): Zustand und die Pruefung "bin ich noch aktuell?" kommen nur hierher.
|
||||
* hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (der Fragebogen hat keine), zumReiter(key) wechselt den Reiter; beide duerfen fehlen.
|
||||
* nurLesen und daten sind die der Huelle im Moment des Aufrufs; neuLaden() holt die Daten neu und ersetzt sie in s und im Kontext.
|
||||
*/
|
||||
export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = () => {} }) {
|
||||
const projectId = projekt.projectId
|
||||
const nurLesen = s.nurLesen
|
||||
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
||||
const ctx = {
|
||||
s,
|
||||
projectId,
|
||||
daten: s.daten,
|
||||
nurLesen,
|
||||
nurLesen: s.nurLesen,
|
||||
auswahl,
|
||||
aktuell,
|
||||
api: {
|
||||
@@ -183,18 +186,26 @@ function zeigeReiter(s, { projekt, aktuell }) {
|
||||
},
|
||||
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
|
||||
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
|
||||
hinweise: () => { if (aktuell()) zeichneHinweise() },
|
||||
zumReiter: (key) => waehleReiter(key),
|
||||
hinweise: () => { if (aktuell()) hinweise() },
|
||||
zumReiter: (key) => zumReiter(key),
|
||||
neuLaden: async () => {
|
||||
const daten = await ladeDaten(projectId)
|
||||
if (aktuell()) {
|
||||
s.daten = daten
|
||||
ctx.daten = daten
|
||||
zeichneHinweise()
|
||||
hinweise()
|
||||
}
|
||||
return daten
|
||||
},
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
function zeigeReiter(s, { projekt, aktuell }) {
|
||||
const projectId = projekt.projectId
|
||||
const nurLesen = s.nurLesen
|
||||
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
|
||||
const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter })
|
||||
|
||||
// Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten.
|
||||
const hinweisBox = h('div', { class: 'tm-hinweise' })
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
// Minimal-DOM und Fake-fetch fuer Tests des Portal-Clients (public/termine*.js, public/funktion-bestellen.js) ohne Browser. Nicht Teil von npm test.
|
||||
//
|
||||
// const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] })
|
||||
// try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() }
|
||||
//
|
||||
// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den
|
||||
// alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte.
|
||||
// Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`.
|
||||
//
|
||||
// Elemente: createElement, setAttribute/getAttribute/hasAttribute/removeAttribute, append/prepend/appendChild/insertBefore/replaceChildren/remove,
|
||||
// children/childNodes/parentNode/parentElement/firstChild/lastChild/firstElementChild/lastElementChild, className/classList/id/dataset/hidden/
|
||||
// disabled/tabIndex/type/name, value/checked/selected (Eigenschaft ueberdeckt das Attribut wie im Browser; select.value ist die gewaehlte
|
||||
// Option, ohne Wahl die erste), textContent, isConnected, closest/matches/querySelector/querySelectorAll, focus (document.activeElement)/blur/
|
||||
// select/scrollIntoView, addEventListener/dispatchEvent (Ereignisse steigen zu den Eltern auf, preventDefault/stopPropagation).
|
||||
// Selektoren: Tag, *, #id, .klasse, [attr], [attr="wert"], :not(…), :disabled, :enabled, :checked, Komma, Leerzeichen und >.
|
||||
//
|
||||
// Der Fake-fetch schreibt jeden Aufruf als { method, url, body } (body = geparstes JSON oder undefined) in fetchLog und antwortet mit dem naechsten
|
||||
// Eintrag aus `antworten` ({ status, body } oder eine Funktion (aufruf) => { status, body }); ohne Eintrag 200 mit {}.
|
||||
// klick, setze, sende und ruhe liefern ein Promise: die Wirkung der Ereignisse (auch von async-Handlern) ist erst danach da, also `await fx.klick(…)`.
|
||||
// klick wirkt wie ein Klick im Browser (Haken und Radio schalten, „submit“-Knopf sendet das Formular, gesperrte Elemente tun nichts); ein Klick auf ein
|
||||
// <label> schaltet seinen Haken NICHT: der Test klickt den Haken selbst. sende(el, typ, init) schickt ein beliebiges Ereignis (z. B. 'keydown', { key }).
|
||||
|
||||
import { setImmediate as naechsterTakt } from 'node:timers/promises'
|
||||
|
||||
/* --------------------------------------------------------------- CSS.escape --- */
|
||||
/** CSS.escape nach der Spezifikation (die Client-Dateien bauen damit Attribut-Selektoren aus Feldnamen wie 'leistungen.2.dauer'). */
|
||||
export function cssEscape(wert) {
|
||||
const s = String(wert)
|
||||
let aus = ''
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
const c = s[i]
|
||||
const code = s.charCodeAt(i)
|
||||
if (code === 0) aus += '\uFFFD'
|
||||
else if ((code >= 1 && code <= 31) || code === 127 || (i === 0 && c >= '0' && c <= '9') || (i === 1 && c >= '0' && c <= '9' && s[0] === '-')) aus += `\\${code.toString(16)} `
|
||||
else if (i === 0 && c === '-' && s.length === 1) aus += `\\${c}`
|
||||
else if (code >= 128 || c === '-' || c === '_' || /[0-9A-Za-z]/.test(c)) aus += c
|
||||
else aus += `\\${c}`
|
||||
}
|
||||
return aus
|
||||
}
|
||||
const entescape = (text) => text.replace(/\\([0-9a-fA-F]{1,6})\s?|\\(.)/g, (_, hex, zeichen) => (hex ? String.fromCodePoint(parseInt(hex, 16)) : zeichen))
|
||||
|
||||
/* ---------------------------------------------------------------- Selektoren --- */
|
||||
const STEUERUNGEN = new Set(['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'FIELDSET', 'OPTION'])
|
||||
/** :disabled – eigenes Attribut oder ein gesperrtes fieldset darueber (wie im Browser). */
|
||||
function istGesperrt(el) {
|
||||
if (!STEUERUNGEN.has(el.tagName)) return false
|
||||
if (el.hasAttribute('disabled')) return true
|
||||
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (p.tagName === 'FIELDSET' && p.hasAttribute('disabled')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const TEIL_QUELLE = /(\*|[A-Za-z][\w-]*)|#((?:\\.|[\w-])+)|\.((?:\\.|[\w-])+)|\[\s*([\w:-]+)\s*(?:(=)\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\]\s]*)))?\s*\]|:not\(([^)]*)\)|:([a-z-]+)/
|
||||
|
||||
/** Ein einfacher Selektor (ohne Leerzeichen/Komma) als Pruefung el => boolean. */
|
||||
function einfach(text) {
|
||||
const tests = []
|
||||
const teil = new RegExp(TEIL_QUELLE.source, 'y') // je Aufruf eigener Zustand: :not(…) ruft einfach() verschachtelt auf
|
||||
while (teil.lastIndex < text.length) {
|
||||
const m = teil.exec(text)
|
||||
if (!m) throw new Error(`fakeDom: Selektor nicht unterstuetzt: ${text}`)
|
||||
const [, tag, id, klasse, attr, op, doppelt, einzeln, blank, nicht, pseudo] = m
|
||||
if (tag) tests.push(tag === '*' ? () => true : (el) => el.tagName === tag.toUpperCase())
|
||||
else if (id) tests.push((el) => el.id === entescape(id))
|
||||
else if (klasse) tests.push((el) => el.classList.contains(entescape(klasse)))
|
||||
else if (attr) {
|
||||
const soll = op ? entescape(doppelt ?? einzeln ?? blank ?? '') : null
|
||||
tests.push((el) => (op ? el.getAttribute(attr) === soll : el.hasAttribute(attr)))
|
||||
} else if (nicht !== undefined) {
|
||||
const liste = parse(nicht)
|
||||
tests.push((el) => !liste.some((komplex) => passt(el, komplex)))
|
||||
} else if (pseudo === 'disabled') tests.push((el) => istGesperrt(el))
|
||||
else if (pseudo === 'enabled') tests.push((el) => STEUERUNGEN.has(el.tagName) && !istGesperrt(el))
|
||||
else if (pseudo === 'checked') tests.push((el) => (el.tagName === 'OPTION' ? el.selected : el.tagName === 'INPUT' && el.checked))
|
||||
else throw new Error(`fakeDom: Pseudo-Klasse nicht unterstuetzt: :${pseudo}`)
|
||||
}
|
||||
return (el) => tests.every((test) => test(el))
|
||||
}
|
||||
|
||||
/** Selektorliste ⇒ Array von Ketten [{ komb, test }] (komb = Verbindung zum Vorgaenger: ' ' Nachfahre, '>' Kind). */
|
||||
function parse(quelle) {
|
||||
const liste = []
|
||||
let kette = []
|
||||
let stueck = ''
|
||||
let komb = ' '
|
||||
let tiefe = 0
|
||||
let quote = null
|
||||
const schliesse = () => {
|
||||
if (!stueck) return
|
||||
kette.push({ komb, test: einfach(stueck) })
|
||||
stueck = ''
|
||||
komb = ' '
|
||||
}
|
||||
for (let i = 0; i < quelle.length; i += 1) {
|
||||
const zeichen = quelle[i]
|
||||
if (zeichen === '\\') { stueck += zeichen + (quelle[i + 1] ?? ''); i += 1; continue }
|
||||
if (quote) { stueck += zeichen; if (zeichen === quote) quote = null; continue }
|
||||
if (zeichen === '"' || zeichen === "'") { quote = zeichen; stueck += zeichen; continue }
|
||||
if (zeichen === '[' || zeichen === '(') tiefe += 1
|
||||
if (zeichen === ']' || zeichen === ')') tiefe -= 1
|
||||
if (tiefe === 0 && zeichen === ',') { schliesse(); if (kette.length) liste.push(kette); kette = []; continue }
|
||||
if (tiefe === 0 && /\s/.test(zeichen)) { schliesse(); continue }
|
||||
if (tiefe === 0 && zeichen === '>') { schliesse(); komb = '>'; continue }
|
||||
stueck += zeichen
|
||||
}
|
||||
schliesse()
|
||||
if (kette.length) liste.push(kette)
|
||||
if (!liste.length) throw new Error(`fakeDom: leerer Selektor: "${quelle}"`)
|
||||
return liste
|
||||
}
|
||||
|
||||
function passt(el, kette, i = kette.length - 1) {
|
||||
if (!kette[i].test(el)) return false
|
||||
if (i === 0) return true
|
||||
if (kette[i].komb === '>') return el.parentNode?.nodeType === 1 && passt(el.parentNode, kette, i - 1)
|
||||
for (let p = el.parentNode; p && p.nodeType === 1; p = p.parentNode) if (passt(p, kette, i - 1)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const trifft = (el, selektor) => parse(selektor).some((kette) => passt(el, kette))
|
||||
|
||||
/* -------------------------------------------------------------------- Knoten --- */
|
||||
class FakeText {
|
||||
constructor(text) {
|
||||
this.nodeType = 3
|
||||
this.data = String(text)
|
||||
this.parentNode = null
|
||||
}
|
||||
|
||||
get textContent() { return this.data }
|
||||
|
||||
set textContent(wert) { this.data = String(wert) }
|
||||
}
|
||||
|
||||
const kebab = (name) => `data-${name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`
|
||||
|
||||
const alsKnoten = (doc, wert) => (typeof wert === 'object' && wert !== null ? wert : new FakeText(wert))
|
||||
|
||||
class FakeElement {
|
||||
constructor(doc, tag) {
|
||||
this.ownerDocument = doc
|
||||
this.nodeType = 1
|
||||
this.tagName = String(tag).toUpperCase()
|
||||
this.parentNode = null
|
||||
this.childNodes = []
|
||||
this._attrs = new Map()
|
||||
this._listeners = new Map()
|
||||
this._value = undefined
|
||||
this._checked = undefined
|
||||
this._selected = undefined
|
||||
const el = this
|
||||
this.dataset = new Proxy({}, {
|
||||
get: (_, name) => (typeof name === 'string' ? el._attrs.get(kebab(name)) : undefined),
|
||||
set: (_, name, wert) => { el.setAttribute(kebab(name), wert); return true },
|
||||
has: (_, name) => el._attrs.has(kebab(name)),
|
||||
deleteProperty: (_, name) => { el._attrs.delete(kebab(name)); return true },
|
||||
})
|
||||
const liste = () => (el._attrs.get('class') ?? '').split(/\s+/).filter(Boolean)
|
||||
this.classList = {
|
||||
contains: (name) => liste().includes(name),
|
||||
add: (...namen) => el.setAttribute('class', [...new Set([...liste(), ...namen])].join(' ')),
|
||||
remove: (...namen) => el.setAttribute('class', liste().filter((n) => !namen.includes(n)).join(' ')),
|
||||
toggle: (name, an) => {
|
||||
const soll = an ?? !liste().includes(name)
|
||||
if (soll) this.classList.add(name)
|
||||
else this.classList.remove(name)
|
||||
return soll
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* Attribute */
|
||||
setAttribute(name, wert) { this._attrs.set(name, String(wert)) }
|
||||
|
||||
getAttribute(name) { return this._attrs.has(name) ? this._attrs.get(name) : null }
|
||||
|
||||
hasAttribute(name) { return this._attrs.has(name) }
|
||||
|
||||
removeAttribute(name) { this._attrs.delete(name) }
|
||||
|
||||
get id() { return this._attrs.get('id') ?? '' }
|
||||
|
||||
set id(wert) { this.setAttribute('id', wert) }
|
||||
|
||||
get className() { return this._attrs.get('class') ?? '' }
|
||||
|
||||
set className(wert) { this.setAttribute('class', wert) }
|
||||
|
||||
get hidden() { return this._attrs.has('hidden') }
|
||||
|
||||
set hidden(an) { if (an) this.setAttribute('hidden', ''); else this.removeAttribute('hidden') }
|
||||
|
||||
get disabled() { return this._attrs.has('disabled') }
|
||||
|
||||
set disabled(an) { if (an) this.setAttribute('disabled', ''); else this.removeAttribute('disabled') }
|
||||
|
||||
get readOnly() { return this._attrs.has('readonly') }
|
||||
|
||||
set readOnly(an) { if (an) this.setAttribute('readonly', ''); else this.removeAttribute('readonly') }
|
||||
|
||||
get tabIndex() { return this._attrs.has('tabindex') ? Number(this._attrs.get('tabindex')) : -1 }
|
||||
|
||||
set tabIndex(wert) { this.setAttribute('tabindex', wert) }
|
||||
|
||||
get type() { return this._attrs.get('type') ?? (this.tagName === 'BUTTON' ? 'submit' : 'text') }
|
||||
|
||||
set type(wert) { this.setAttribute('type', wert) }
|
||||
|
||||
get name() { return this._attrs.get('name') ?? '' }
|
||||
|
||||
set name(wert) { this.setAttribute('name', wert) }
|
||||
|
||||
/* value, checked, selected: die Eigenschaft ueberdeckt das Attribut, sobald sie gesetzt ist */
|
||||
get value() {
|
||||
if (this.tagName === 'SELECT') {
|
||||
const optionen = this.querySelectorAll('option')
|
||||
return (optionen.find((o) => o.selected) ?? optionen.find((o) => !o.disabled))?.value ?? ''
|
||||
}
|
||||
if (this.tagName === 'OPTION') return this._attrs.get('value') ?? this.textContent
|
||||
if (this.tagName === 'TEXTAREA') return this._value ?? this.textContent
|
||||
return this._value ?? this._attrs.get('value') ?? (this.type === 'checkbox' || this.type === 'radio' ? 'on' : '')
|
||||
}
|
||||
|
||||
set value(wert) {
|
||||
if (this.tagName === 'SELECT') {
|
||||
for (const o of this.querySelectorAll('option')) o._selected = o.value === String(wert)
|
||||
} else if (this.tagName === 'OPTION') this.setAttribute('value', wert)
|
||||
else this._value = String(wert)
|
||||
}
|
||||
|
||||
get checked() { return this._checked ?? this._attrs.has('checked') }
|
||||
|
||||
set checked(an) {
|
||||
this._checked = Boolean(an)
|
||||
if (an && this.type === 'radio' && this.name) {
|
||||
let wurzel = this
|
||||
while (wurzel.parentNode) wurzel = wurzel.parentNode
|
||||
for (const r of wurzel.querySelectorAll('input')) if (r !== this && r.type === 'radio' && r.name === this.name) r._checked = false
|
||||
}
|
||||
}
|
||||
|
||||
get selected() { return this._selected ?? this._attrs.has('selected') }
|
||||
|
||||
set selected(an) {
|
||||
this._selected = Boolean(an)
|
||||
if (an && this.parentNode?.tagName === 'SELECT') for (const o of this.parentNode.querySelectorAll('option')) if (o !== this) o._selected = false
|
||||
}
|
||||
|
||||
/* Baum */
|
||||
get children() { return this.childNodes.filter((n) => n.nodeType === 1) }
|
||||
|
||||
get parentElement() { return this.parentNode?.nodeType === 1 ? this.parentNode : null }
|
||||
|
||||
get firstChild() { return this.childNodes[0] ?? null }
|
||||
|
||||
get lastChild() { return this.childNodes.at(-1) ?? null }
|
||||
|
||||
get firstElementChild() { return this.children[0] ?? null }
|
||||
|
||||
get lastElementChild() { return this.children.at(-1) ?? null }
|
||||
|
||||
get isConnected() {
|
||||
let wurzel = this
|
||||
while (wurzel.parentNode) wurzel = wurzel.parentNode
|
||||
return wurzel === this.ownerDocument.documentElement
|
||||
}
|
||||
|
||||
get textContent() { return this.childNodes.map((n) => n.textContent).join('') }
|
||||
|
||||
set textContent(wert) { this.replaceChildren(...(wert === '' || wert === null || wert === undefined ? [] : [String(wert)])) }
|
||||
|
||||
_loese(knoten) {
|
||||
if (knoten.parentNode) knoten.parentNode.removeChild(knoten)
|
||||
return knoten
|
||||
}
|
||||
|
||||
append(...knoten) {
|
||||
for (const wert of knoten) {
|
||||
const k = this._loese(alsKnoten(this.ownerDocument, wert))
|
||||
k.parentNode = this
|
||||
this.childNodes.push(k)
|
||||
}
|
||||
}
|
||||
|
||||
prepend(...knoten) {
|
||||
const neu = knoten.map((wert) => this._loese(alsKnoten(this.ownerDocument, wert)))
|
||||
for (const k of neu) k.parentNode = this
|
||||
this.childNodes.unshift(...neu)
|
||||
}
|
||||
|
||||
appendChild(knoten) { this.append(knoten); return knoten }
|
||||
|
||||
insertBefore(knoten, vor) {
|
||||
if (!vor) return this.appendChild(knoten)
|
||||
this._loese(knoten)
|
||||
knoten.parentNode = this
|
||||
this.childNodes.splice(this.childNodes.indexOf(vor), 0, knoten)
|
||||
return knoten
|
||||
}
|
||||
|
||||
removeChild(knoten) {
|
||||
const i = this.childNodes.indexOf(knoten)
|
||||
if (i < 0) throw new Error('fakeDom: removeChild: kein Kind dieses Elements')
|
||||
this.childNodes.splice(i, 1)
|
||||
knoten.parentNode = null
|
||||
return knoten
|
||||
}
|
||||
|
||||
replaceChildren(...knoten) {
|
||||
for (const k of this.childNodes) k.parentNode = null
|
||||
this.childNodes = []
|
||||
this.append(...knoten)
|
||||
}
|
||||
|
||||
remove() { this.parentNode?.removeChild(this) }
|
||||
|
||||
contains(knoten) {
|
||||
for (let k = knoten; k; k = k.parentNode) if (k === this) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/* Suche */
|
||||
matches(selektor) { return trifft(this, selektor) }
|
||||
|
||||
closest(selektor) {
|
||||
for (let el = this; el && el.nodeType === 1; el = el.parentNode) if (trifft(el, selektor)) return el
|
||||
return null
|
||||
}
|
||||
|
||||
querySelectorAll(selektor) {
|
||||
const liste = parse(selektor)
|
||||
const treffer = []
|
||||
const durchlauf = (el) => {
|
||||
for (const kind of el.children) {
|
||||
if (liste.some((kette) => passt(kind, kette))) treffer.push(kind)
|
||||
durchlauf(kind)
|
||||
}
|
||||
}
|
||||
durchlauf(this)
|
||||
return treffer
|
||||
}
|
||||
|
||||
querySelector(selektor) { return this.querySelectorAll(selektor)[0] ?? null }
|
||||
|
||||
/* Fokus */
|
||||
focus() {
|
||||
if (!this.isConnected || istGesperrt(this)) return
|
||||
const doc = this.ownerDocument
|
||||
const davor = doc._aktiv
|
||||
doc._aktiv = this
|
||||
if (davor !== this) feuere(this, neuesEreignis('focus', { bubbles: false }))
|
||||
}
|
||||
|
||||
blur() { if (this.ownerDocument._aktiv === this) this.ownerDocument._aktiv = null }
|
||||
|
||||
select() {}
|
||||
|
||||
scrollIntoView() {}
|
||||
|
||||
click() { return klickEl(this) }
|
||||
|
||||
/* Ereignisse */
|
||||
addEventListener(typ, fn) {
|
||||
if (!this._listeners.has(typ)) this._listeners.set(typ, [])
|
||||
this._listeners.get(typ).push(fn)
|
||||
}
|
||||
|
||||
removeEventListener(typ, fn) {
|
||||
const liste = this._listeners.get(typ)
|
||||
if (liste?.includes(fn)) liste.splice(liste.indexOf(fn), 1)
|
||||
}
|
||||
|
||||
dispatchEvent(ev) {
|
||||
feuere(this, ev)
|
||||
return !ev.defaultPrevented
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- Ereignisse --- */
|
||||
function neuesEreignis(typ, init = {}) {
|
||||
return {
|
||||
type: typ,
|
||||
target: null,
|
||||
currentTarget: null,
|
||||
bubbles: true,
|
||||
defaultPrevented: false,
|
||||
stopped: false,
|
||||
ergebnisse: [],
|
||||
preventDefault() { this.defaultPrevented = true },
|
||||
stopPropagation() { this.stopped = true },
|
||||
...init,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ereignis an el senden und zu den Eltern aufsteigen lassen; Rueckgaben der Handler (async-Handler: Promises) landen in ev.ergebnisse. */
|
||||
function feuere(el, ev) {
|
||||
ev.target = el
|
||||
for (let knoten = el; knoten && knoten.nodeType === 1; knoten = knoten.parentNode) {
|
||||
ev.currentTarget = knoten
|
||||
for (const fn of [...(knoten._listeners.get(ev.type) ?? [])]) ev.ergebnisse.push(fn.call(knoten, ev))
|
||||
if (ev.stopped || !ev.bubbles) break
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Handler eines Ereignisses abwarten und danach einen Takt, in dem Microtask-Ketten (fetch, await) durchlaufen. */
|
||||
async function abwarten(ergebnisse) {
|
||||
await Promise.all(ergebnisse)
|
||||
await naechsterTakt()
|
||||
}
|
||||
|
||||
/** Klick wie im Browser: Haken setzen/umschalten, click, input/change, bei „submit“-Knopf in einem Formular submit. */
|
||||
function klickEl(el) {
|
||||
if (istGesperrt(el)) return abwarten([])
|
||||
const ergebnisse = []
|
||||
const art = el.tagName === 'INPUT' ? el.type : null
|
||||
if (art === 'checkbox') el.checked = !el.checked
|
||||
else if (art === 'radio') el.checked = true
|
||||
const klick = neuesEreignis('click')
|
||||
feuere(el, klick)
|
||||
ergebnisse.push(...klick.ergebnisse)
|
||||
if (!klick.defaultPrevented) {
|
||||
if (art === 'checkbox' || art === 'radio') {
|
||||
for (const typ of ['input', 'change']) {
|
||||
const ev = neuesEreignis(typ)
|
||||
feuere(el, ev)
|
||||
ergebnisse.push(...ev.ergebnisse)
|
||||
}
|
||||
} else if ((el.tagName === 'BUTTON' && el.type === 'submit') || (art === 'submit')) {
|
||||
const form = el.closest('form')
|
||||
if (form) {
|
||||
const ev = neuesEreignis('submit', { submitter: el })
|
||||
feuere(form, ev)
|
||||
ergebnisse.push(...ev.ergebnisse)
|
||||
}
|
||||
}
|
||||
}
|
||||
return abwarten(ergebnisse)
|
||||
}
|
||||
|
||||
/** Wert eingeben wie ein Nutzer: Text/Auswahl als String, Haken/Radio als Boolean; danach input und change. */
|
||||
function setzeEl(el, wert) {
|
||||
if (istGesperrt(el)) return abwarten([])
|
||||
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) el.checked = Boolean(wert)
|
||||
else el.value = wert
|
||||
const ergebnisse = []
|
||||
for (const typ of ['input', 'change']) {
|
||||
const ev = neuesEreignis(typ)
|
||||
feuere(el, ev)
|
||||
ergebnisse.push(...ev.ergebnisse)
|
||||
}
|
||||
return abwarten(ergebnisse)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- Einbau --- */
|
||||
function neuesDokument() {
|
||||
const doc = { nodeType: 9, _aktiv: null }
|
||||
doc.createElement = (tag) => new FakeElement(doc, tag)
|
||||
doc.createTextNode = (text) => new FakeText(text)
|
||||
doc.documentElement = doc.createElement('html')
|
||||
doc.head = doc.createElement('head')
|
||||
doc.body = doc.createElement('body')
|
||||
doc.documentElement.append(doc.head, doc.body)
|
||||
Object.defineProperty(doc, 'activeElement', { get: () => (doc._aktiv?.isConnected ? doc._aktiv : doc.body) })
|
||||
doc.querySelector = (selektor) => (trifft(doc.documentElement, selektor) ? doc.documentElement : doc.documentElement.querySelector(selektor))
|
||||
doc.querySelectorAll = (selektor) => doc.documentElement.querySelectorAll(selektor)
|
||||
doc.getElementById = (id) => doc.documentElement.querySelector(`[id="${id}"]`)
|
||||
return doc
|
||||
}
|
||||
|
||||
const GLOBALE = ['document', 'window', 'CSS', 'fetch']
|
||||
|
||||
/**
|
||||
* Fake-DOM und Fake-fetch auf globalThis stellen.
|
||||
* => { document, window, fetchLog, antworten, finde, alle, text, klick, setze, sende, ruhe, restore }
|
||||
* finde(baum, vergleich) / alle(baum, vergleich): erstes / alle Elemente im Baum (baum selbst eingeschlossen, Dokumentreihenfolge), fuer die
|
||||
* vergleich(el) wahr ist (Funktion) oder die zum Selektor passen (String).
|
||||
* text(baum): Text des Baums (textContent). klick(el), setze(el, wert), ruhe(): siehe Kopf der Datei.
|
||||
*/
|
||||
export function installFakeDom({ antworten = [] } = {}) {
|
||||
const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]))
|
||||
const document = neuesDokument()
|
||||
const location = { href: 'http://localhost/', hash: '' }
|
||||
const window = {
|
||||
document,
|
||||
location,
|
||||
history: { replaceState: (_zustand, _titel, url) => { location.hash = String(url ?? '').includes('#') ? `#${String(url).split('#')[1]}` : '' } },
|
||||
}
|
||||
const fetchLog = []
|
||||
const warteschlange = [...antworten]
|
||||
const fakeFetch = async (url, optionen = {}) => {
|
||||
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
|
||||
fetchLog.push(aufruf)
|
||||
const eintrag = warteschlange.shift()
|
||||
const { status = 200, body = {} } = (typeof eintrag === 'function' ? eintrag(aufruf) : eintrag) || {}
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
|
||||
}
|
||||
const neu = { document, window, CSS: { escape: cssEscape }, fetch: fakeFetch }
|
||||
for (const [name, wert] of Object.entries(neu)) Object.defineProperty(globalThis, name, { value: wert, writable: true, configurable: true, enumerable: true })
|
||||
|
||||
const knoten = (baum) => [baum, ...(baum?.querySelectorAll?.('*') ?? [])].filter((n) => n?.nodeType === 1)
|
||||
const pruefung = (vergleich) => (typeof vergleich === 'string' ? (el) => trifft(el, vergleich) : vergleich)
|
||||
return {
|
||||
document,
|
||||
window,
|
||||
fetchLog,
|
||||
antworten: warteschlange,
|
||||
finde: (baum, vergleich) => knoten(baum).find(pruefung(vergleich)) ?? null,
|
||||
alle: (baum, vergleich) => knoten(baum).filter(pruefung(vergleich)),
|
||||
text: (baum) => baum?.textContent ?? '',
|
||||
klick: klickEl,
|
||||
setze: setzeEl,
|
||||
sende: (el, typ, init) => {
|
||||
const ev = neuesEreignis(typ, init)
|
||||
feuere(el, ev)
|
||||
return abwarten(ev.ergebnisse)
|
||||
},
|
||||
ruhe: () => abwarten([]),
|
||||
restore() {
|
||||
for (const name of GLOBALE) {
|
||||
if (vorher[name]) Object.defineProperty(globalThis, name, vorher[name])
|
||||
else delete globalThis[name]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
import { BRANCHEN, SCHRITTE, SCHRITT_TITEL, leerFragebogen } from '../server/services/termine/fragebogen.js'
|
||||
import { normalisiereEinstellung } from '../server/services/termine/einstellungen.js'
|
||||
import { ausVorlage } from '../server/services/termine/vorlagen.js'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
// Namensraeume statt benannter Importe: fehlt ein Export, scheitert nur der Test, der ihn braucht (nicht die ganze Datei)
|
||||
import * as einstellungenTab from '../public/termine-tab-einstellungen.js'
|
||||
import * as teamTab from '../public/termine-tab-team.js'
|
||||
import * as huelle from '../public/termine.js'
|
||||
import { FENSTER_FEHLER } from '../public/termine-view.js'
|
||||
import { h, request } from '../public/termine-dom.js'
|
||||
import { neuerSpeicher } from '../public/termine-speichern.js'
|
||||
|
||||
// Reine Helfer des Fragebogens in public/termine-view.js (TP2, Task C7a): Schrittstand, Namen der Personen/Plaetze, sofort/Anfrage fuer alle
|
||||
// und die Zusammenfassung auf der Bestellseite. Kein DOM; der Spiegel zum Server (SCHRITTE) steht in test/termineView.test.js.
|
||||
@@ -478,3 +486,354 @@ test('termine-view.js bleibt rein: kein DOM, kein Netz, nur der Import der Anred
|
||||
assert.doesNotMatch(quelle, /\b(document|window|navigator|fetch|XMLHttpRequest|localStorage|sessionStorage)\b/)
|
||||
assert.deepEqual(quelle.match(/^import .*$/gm), ["import { ANREDE_WAHL } from './termine-gaestetexte.js'"])
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- Fake-DOM (Testhilfe, TP2 C7b)
|
||||
// test/support/fakeDom.js tragen auch C8, C9a, C9b und C10b: die Tests hier halten fest, worauf sie sich verlassen.
|
||||
|
||||
/** Fake-DOM fuer die Dauer von fn aufbauen (und immer abbauen). */
|
||||
const mitDom = (optionen, fn) => async () => {
|
||||
const fx = installFakeDom(optionen)
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
/** Namen (data-feld) aller Formularfelder im Baum, in Dokumentreihenfolge. */
|
||||
const felderVon = (fx, baum) => fx.alle(baum, (n) => typeof n.dataset.feld === 'string').map((n) => n.dataset.feld)
|
||||
|
||||
test('Fake-DOM: globale Objekte nur zwischen installFakeDom und restore', () => {
|
||||
const vorher = { document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }
|
||||
const fx = installFakeDom()
|
||||
assert.equal(globalThis.document, fx.document)
|
||||
assert.notEqual(globalThis.fetch, vorher.fetch)
|
||||
assert.equal(globalThis.CSS.escape('leistungen.2.dauer'), 'leistungen\\.2\\.dauer')
|
||||
fx.restore()
|
||||
assert.deepEqual({ document: globalThis.document, fetch: globalThis.fetch, CSS: globalThis.CSS, window: globalThis.window }, vorher)
|
||||
})
|
||||
|
||||
test('Fake-DOM: Baum, Attribute, Eigenschaften und isConnected', mitDom(undefined, (fx) => {
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn tm-x', 'data-aktion': 'weiter', hidden: true }, 'Weiter ', h('b', null, 'jetzt'), 5)
|
||||
assert.equal(knopf.tagName, 'BUTTON')
|
||||
assert.equal(knopf.className, 'kb-btn tm-x')
|
||||
assert.equal(knopf.dataset.aktion, 'weiter')
|
||||
assert.equal(knopf.getAttribute('data-aktion'), 'weiter')
|
||||
assert.equal(knopf.hidden, true)
|
||||
assert.equal(fx.text(knopf), 'Weiter jetzt5')
|
||||
assert.equal(knopf.children.length, 1, 'children: nur Elemente')
|
||||
assert.equal(knopf.childNodes.length, 3)
|
||||
const wurzel = h('div', null, knopf)
|
||||
assert.equal(knopf.isConnected, false)
|
||||
document.body.append(wurzel)
|
||||
assert.equal(knopf.isConnected, true)
|
||||
wurzel.replaceChildren('neu')
|
||||
assert.equal(knopf.isConnected, false)
|
||||
assert.equal(knopf.parentNode, null)
|
||||
wurzel.textContent = ''
|
||||
assert.equal(wurzel.childNodes.length, 0)
|
||||
}))
|
||||
|
||||
test('Fake-DOM: Selektoren wie die Client-Dateien sie brauchen', mitDom(undefined, (fx) => {
|
||||
const zeile = h('li', { class: 'tm-eintrag', 'data-id': 'r_abc' }, h('button', { 'data-aktion': 'bearbeiten' }, 'Bearbeiten'), h('button', { disabled: true }, 'Aus'))
|
||||
const feld = h('p', { 'data-fehler': 'leistungen.2.dauer' })
|
||||
const sperre = h('fieldset', { disabled: true }, h('input', { type: 'text' }))
|
||||
const tafel = h('div', { role: 'tabpanel' }, h('ul', null, zeile), feld, sperre)
|
||||
document.body.append(tafel)
|
||||
assert.equal(tafel.querySelector('li[data-id="r_abc"] [data-aktion="bearbeiten"]').textContent, 'Bearbeiten')
|
||||
assert.equal(tafel.querySelector(`[data-fehler="${CSS.escape('leistungen.2.dauer')}"]`), feld)
|
||||
assert.deepEqual(zeile.querySelectorAll('button:not(:disabled)').map((n) => n.textContent), ['Bearbeiten'])
|
||||
assert.equal(zeile.querySelectorAll('button:disabled').length, 1)
|
||||
assert.equal(sperre.querySelector('input:not(:disabled)'), null, 'fieldset disabled sperrt die Felder darin')
|
||||
assert.equal(zeile.closest('[role="tabpanel"]'), tafel)
|
||||
assert.equal(zeile.closest('form'), null)
|
||||
assert.deepEqual(fx.alle(tafel, 'li, p').map((n) => n.tagName), ['LI', 'P'])
|
||||
assert.equal(fx.finde(tafel, (n) => n.tagName === 'FIELDSET'), sperre)
|
||||
assert.equal(tafel.querySelector('ul > li'), zeile)
|
||||
assert.throws(() => tafel.querySelector('a:hover'), /nicht unterstuetzt/)
|
||||
}))
|
||||
|
||||
test('Fake-DOM: value, checked und selected wie im Browser', mitDom(undefined, async (fx) => {
|
||||
const auswahl = h('select', null, h('option', { value: '' }, '– bitte wählen –'), h('option', { value: 'RP' }, 'Rheinland-Pfalz'), h('option', { value: 'SL', selected: true }, 'Saarland'))
|
||||
assert.equal(auswahl.value, 'SL', 'selected-Attribut gilt')
|
||||
const ohneWahl = h('select', null, h('option', { value: '' }, '–'), h('option', { value: 'RP' }, 'RP'))
|
||||
assert.equal(ohneWahl.value, '', 'ohne selected zeigt der Browser die erste Option')
|
||||
await fx.setze(ohneWahl, 'RP')
|
||||
assert.equal(ohneWahl.value, 'RP')
|
||||
const text = h('input', { type: 'text', value: 'alt' })
|
||||
assert.equal(text.value, 'alt')
|
||||
await fx.setze(text, 'neu')
|
||||
assert.equal(text.value, 'neu')
|
||||
assert.equal(text.getAttribute('value'), 'alt', 'die Eigenschaft ändert das Attribut nicht')
|
||||
const haken = h('input', { type: 'checkbox', checked: true })
|
||||
assert.equal(haken.checked, true)
|
||||
await fx.klick(haken)
|
||||
assert.equal(haken.checked, false)
|
||||
const area = h('textarea', null, 'Vorgabe')
|
||||
assert.equal(area.value, 'Vorgabe')
|
||||
const a = h('input', { type: 'radio', name: 'x', checked: true })
|
||||
const b = h('input', { type: 'radio', name: 'x' })
|
||||
h('div', null, a, b)
|
||||
await fx.klick(b)
|
||||
assert.deepEqual([a.checked, b.checked], [false, true], 'Radios einer Gruppe schließen sich aus')
|
||||
}))
|
||||
|
||||
test('Fake-DOM: Ereignisse steigen auf, klick auf Absenden-Knopf sendet das Formular, gesperrte Knöpfe tun nichts, async-Handler werden abgewartet', mitDom(undefined, async (fx) => {
|
||||
const log = []
|
||||
const knopf = h('button', { type: 'submit' }, 'Weiter')
|
||||
const aus = h('button', { type: 'submit', disabled: true }, 'Aus')
|
||||
const lauf = h('button', { type: 'button' }, 'Lauf')
|
||||
const form = h('form', null, knopf, aus, lauf)
|
||||
form.addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault()
|
||||
log.push('submit')
|
||||
await Promise.resolve()
|
||||
await new Promise((r) => setImmediate(r))
|
||||
log.push('submit fertig')
|
||||
})
|
||||
form.addEventListener('click', (ev) => log.push(`click ${ev.target.textContent} ${ev.currentTarget.tagName}`))
|
||||
lauf.addEventListener('click', async () => { await new Promise((r) => setImmediate(r)); log.push('lauf fertig') })
|
||||
document.body.append(form)
|
||||
await fx.klick(aus)
|
||||
assert.deepEqual(log, [], 'gesperrt: kein Ereignis')
|
||||
await fx.klick(knopf)
|
||||
assert.deepEqual(log, ['click Weiter FORM', 'submit', 'submit fertig'])
|
||||
log.length = 0
|
||||
await fx.klick(lauf)
|
||||
assert.deepEqual(log, ['click Lauf FORM', 'lauf fertig'], 'kein submit bei type=button')
|
||||
log.length = 0
|
||||
form.addEventListener('keydown', (ev) => log.push(`${ev.type} ${ev.key}`))
|
||||
await fx.sende(lauf, 'keydown', { key: 'ArrowRight' })
|
||||
assert.deepEqual(log, ['keydown ArrowRight'])
|
||||
}))
|
||||
|
||||
test('Fake-DOM: fetch schreibt { method, url, body } ins Protokoll und liefert die Antworten der Reihe nach', mitDom({ antworten: [{ status: 400, body: { error: 'Nein', fehler: { 'betrieb.telefon': 'fehlt' }, code: 'X' } }, { body: { ok: 1 } }] }, async (fx) => {
|
||||
await assert.rejects(request('/api/termine/einstellungen', { method: 'PATCH', body: { projectId: 'p1', version: 3 } }), (err) => err.status === 400 && err.code === 'X' && err.fehler['betrieb.telefon'] === 'fehlt')
|
||||
assert.deepEqual(await request('/api/termine/x?projectId=p1'), { ok: 1 })
|
||||
assert.deepEqual(await request('/api/termine/y'), {}, 'ohne Eintrag: 200 mit {}')
|
||||
assert.deepEqual(fx.fetchLog, [
|
||||
{ method: 'PATCH', url: '/api/termine/einstellungen', body: { projectId: 'p1', version: 3 } },
|
||||
{ method: 'GET', url: '/api/termine/x?projectId=p1', body: undefined },
|
||||
{ method: 'GET', url: '/api/termine/y', body: undefined },
|
||||
])
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- betrieb(ctx, e, { nur, titel, hinweis })
|
||||
const BETRIEB_FELDER = ['name', 'email', 'telefon', 'adresse', 'kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']
|
||||
const betriebCtx = (fehlt = []) => ({ daten: { fehlt }, nurLesen: false })
|
||||
|
||||
test('betrieb mit nur: [email] baut nur das Feld email, lese() liefert nur { email }', mitDom(undefined, async (fx) => {
|
||||
const e = salon()
|
||||
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
|
||||
assert.deepEqual(felderVon(fx, el), ['betrieb.email'])
|
||||
assert.equal(fx.alle(el, 'input').length, 1)
|
||||
const eingabe = fx.finde(el, '[data-feld="betrieb.email"]')
|
||||
assert.equal(eingabe.value, BETRIEB.email, 'vorbelegt aus der Einstellung')
|
||||
await fx.setze(eingabe, 'neu@example.invalid')
|
||||
const gelesen = lese()
|
||||
assert.deepEqual(Object.keys(gelesen), ['email'], 'Schlüsselmenge')
|
||||
assert.deepEqual(gelesen, { email: 'neu@example.invalid' })
|
||||
// in den Entwurf geschrieben ändert sich nur email, alles andere bleibt
|
||||
const entwurf = structuredClone(e)
|
||||
lese(entwurf)
|
||||
assert.deepEqual(entwurf.betrieb, { ...e.betrieb, email: 'neu@example.invalid' })
|
||||
}))
|
||||
|
||||
test('betrieb mit nur: Teilmenge in der Reihenfolge der Seite, Haken und Adressen gelesen, Unbekanntes wirft', mitDom(undefined, async (fx) => {
|
||||
const e = salon()
|
||||
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['websiteUrl', 'name', 'kleinunternehmer'] })
|
||||
assert.deepEqual(felderVon(fx, el), ['betrieb.name', 'betrieb.kleinunternehmer', 'betrieb.websiteUrl'], 'Reihenfolge der Seite, nicht der Aufzählung')
|
||||
await fx.klick(fx.finde(el, '[data-feld="betrieb.kleinunternehmer"]'))
|
||||
await fx.setze(fx.finde(el, '[data-feld="betrieb.websiteUrl"]'), 'https://salon.example.invalid')
|
||||
const gelesen = lese()
|
||||
assert.deepEqual(Object.keys(gelesen).sort(), ['kleinunternehmer', 'name', 'websiteUrl'])
|
||||
assert.equal(gelesen.kleinunternehmer, true)
|
||||
assert.equal(gelesen.websiteUrl, 'https://salon.example.invalid')
|
||||
assert.equal(gelesen.name, BETRIEB.name)
|
||||
assert.throws(() => einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email', 'steuernummer'] }), /steuernummer/)
|
||||
}))
|
||||
|
||||
test('betrieb ohne nur baut weiter alle neun Felder und schreibt alle neun in den Entwurf', mitDom(undefined, async (fx) => {
|
||||
const e = salon()
|
||||
const { el, lese } = einstellungenTab.betrieb(betriebCtx(), e)
|
||||
assert.deepEqual(felderVon(fx, el).sort(), BETRIEB_FELDER.map((n) => `betrieb.${n}`).sort())
|
||||
assert.equal(fx.alle(el, 'input').length, 9)
|
||||
assert.equal(fx.text(fx.finde(el, 'h3')), 'Dein Betrieb')
|
||||
const entwurf = { betrieb: {} }
|
||||
lese(entwurf)
|
||||
assert.deepEqual(Object.keys(entwurf.betrieb).sort(), [...BETRIEB_FELDER].sort())
|
||||
assert.equal(entwurf.betrieb.telefon, BETRIEB.telefon)
|
||||
assert.equal(entwurf.betrieb.kleinunternehmer, false)
|
||||
}))
|
||||
|
||||
test('betrieb: titel und hinweis ersetzen Überschrift und Erklärsatz', mitDom(undefined, (fx) => {
|
||||
const e = salon()
|
||||
const eigen = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], titel: 'Wohin sollen die Nachrichten?', hinweis: 'Eine Adresse für alles.' })
|
||||
assert.equal(fx.text(fx.finde(eigen.el, 'h3')), 'Wohin sollen die Nachrichten?')
|
||||
assert.equal(fx.text(fx.finde(eigen.el, '.tm-abschnitt-text')), 'Eine Adresse für alles.')
|
||||
const ohneHinweis = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'], hinweis: null })
|
||||
assert.equal(fx.finde(ohneHinweis.el, '.tm-abschnitt-text'), null)
|
||||
const standard = einstellungenTab.betrieb(betriebCtx(), e, { nur: ['email'] })
|
||||
assert.equal(fx.text(fx.finde(standard.el, 'h3')), 'Dein Betrieb')
|
||||
assert.match(fx.text(fx.finde(standard.el, '.tm-abschnitt-text')), /Diese Angaben sehen deine Gäste/)
|
||||
}))
|
||||
|
||||
test('betrieb: der Hinweis auf fehlende Angaben nennt mit nur nur die Felder, die zu sehen sind', mitDom(undefined, (fx) => {
|
||||
const e = salon()
|
||||
const fehlt = ['betrieb.telefon', 'betrieb.adresse']
|
||||
const alles = einstellungenTab.betrieb(betriebCtx(fehlt), e)
|
||||
assert.match(fx.text(fx.finde(alles.el, '.tm-box-warn')), /deine Telefonnummer und deine Anschrift/, 'ohne nur wie bisher')
|
||||
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['email'] }).el, '.tm-box-warn'), null)
|
||||
const nurTelefon = fx.text(fx.finde(einstellungenTab.betrieb(betriebCtx(fehlt), e, { nur: ['telefon'] }).el, '.tm-box-warn'))
|
||||
assert.match(nurTelefon, /Es fehlt noch: deine Telefonnummer\.$/, 'die Anschrift steht nicht in der Liste dessen, was fehlt')
|
||||
assert.equal(fx.finde(einstellungenTab.betrieb(betriebCtx([]), e).el, '.tm-box-warn'), null)
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- wochenplanFeld(r, pfad)
|
||||
test('wochenplanFeld: sieben Tagesfelder mit Namen aus dem Pfad, gefüllt aus dem Plan der Person', mitDom(undefined, (fx) => {
|
||||
const r = { id: 'r_anna0001', plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 6: [['09:00', '13:00']] } }
|
||||
const { el } = teamTab.wochenplanFeld(r, 'ressourcen.2')
|
||||
assert.deepEqual(felderVon(fx, el), [1, 2, 3, 4, 5, 6, 7].map((nr) => `ressourcen.2.plan.${nr}`))
|
||||
assert.deepEqual(fx.alle(el, 'label').map((l) => fx.text(l)), ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'])
|
||||
const eingaben = fx.alle(el, 'input')
|
||||
assert.deepEqual(eingaben.map((i) => i.value), ['09:00–12:30, 13:30–18:00', '', '', '', '', '09:00–13:00', ''])
|
||||
assert.ok(eingaben.every((i) => i.getAttribute('placeholder') === 'geschlossen'))
|
||||
assert.equal(fx.text(fx.finde(el, 'legend')), 'Wochenplan')
|
||||
assert.equal(fx.alle(el, '[data-fehler]').length, 7, 'je Tag ein Platz für den Fehler')
|
||||
}))
|
||||
|
||||
test('wochenplanFeld: gültige Fenster ⇒ { plan } mit genau diesen Fenstern, ein leerer Tag ist geschlossen', mitDom(undefined, async (fx) => {
|
||||
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.0')
|
||||
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.1"]'), '09:00–12:30, 13:30–18:00')
|
||||
await fx.setze(fx.finde(el, '[data-feld="ressourcen.0.plan.3"]'), '8:00-12:00')
|
||||
const gelesen = lese()
|
||||
assert.deepEqual(gelesen, { plan: { 1: [['09:00', '12:30'], ['13:30', '18:00']], 3: [['08:00', '12:00']] } })
|
||||
assert.equal(gelesen.fehler, undefined)
|
||||
assert.ok(!(2 in gelesen.plan) && !(7 in gelesen.plan), 'leerer Tag ⇒ geschlossen (kein Eintrag)')
|
||||
// ein vorher offener Tag, der geleert wird, ist danach geschlossen
|
||||
const offen = teamTab.wochenplanFeld({ id: 'r1', plan: { 1: [['09:00', '12:00']], 2: [['10:00', '11:00']] } }, 'ressourcen.0')
|
||||
await fx.setze(fx.finde(offen.el, '[data-feld="ressourcen.0.plan.1"]'), '')
|
||||
assert.deepEqual(offen.lese(), { plan: { 2: [['10:00', '11:00']] } })
|
||||
assert.deepEqual(teamTab.wochenplanFeld(null, 'ressourcen.0').lese(), { plan: {} }, 'ohne Person: alles geschlossen')
|
||||
}))
|
||||
|
||||
test('wochenplanFeld: ein ungültiges Fenster ⇒ { fehler } mit Text am Feld des Tages, kein Teilplan', mitDom(undefined, async (fx) => {
|
||||
const { el, lese } = teamTab.wochenplanFeld({ id: 'r1', plan: {} }, 'ressourcen.1')
|
||||
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.1"]'), '09:00–12:00')
|
||||
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.2"]'), 'abc')
|
||||
await fx.setze(fx.finde(el, '[data-feld="ressourcen.1.plan.5"]'), '09:00–09:00')
|
||||
const gelesen = lese()
|
||||
assert.deepEqual(gelesen, { fehler: { 'ressourcen.1.plan.2': FENSTER_FEHLER, 'ressourcen.1.plan.5': FENSTER_FEHLER } })
|
||||
assert.equal(gelesen.plan, undefined)
|
||||
assert.ok(typeof gelesen.fehler['ressourcen.1.plan.2'] === 'string' && gelesen.fehler['ressourcen.1.plan.2'].length > 0)
|
||||
}))
|
||||
|
||||
test('Reiter Team & Plätze: der Editor einer Person speichert den Wochenplan aus wochenplanFeld und zeigt Fehler am Tag', mitDom(undefined, async (fx) => {
|
||||
const einstellung = salon()
|
||||
const anna = einstellung.ressourcen[0]
|
||||
const ctx = {
|
||||
s: {}, projectId: 'p1', daten: { einstellung, version: 4, feeds: {}, fehlt: [] }, nurLesen: false, auswahl: {}, aktuell: () => true,
|
||||
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
|
||||
meldung() {}, hinweise() {}, zumReiter() {}, neuLaden: async () => {},
|
||||
}
|
||||
const tafel = h('div', { role: 'tabpanel' })
|
||||
document.body.append(tafel)
|
||||
teamTab.render(tafel, ctx)
|
||||
await fx.ruhe()
|
||||
await fx.klick(fx.finde(tafel, `li[data-id="${anna.id}"] [data-aktion="bearbeiten"]`))
|
||||
const form = fx.finde(tafel, (n) => n.tagName === 'FORM' && n.getAttribute('aria-label') === `Bearbeiten: ${anna.name}`)
|
||||
assert.ok(form, 'Editor ist offen')
|
||||
const speichern = fx.finde(form, (n) => n.tagName === 'BUTTON' && n.type === 'submit')
|
||||
const montag = fx.finde(form, '[data-feld="ressourcen.0.plan.1"]')
|
||||
// Fehler: nichts wird gesendet, der Text steht am Tag
|
||||
await fx.setze(montag, 'abc')
|
||||
await fx.klick(speichern)
|
||||
assert.deepEqual(fx.fetchLog, [])
|
||||
assert.equal(fx.text(fx.finde(form, '[data-fehler="ressourcen.0.plan.1"]')), FENSTER_FEHLER)
|
||||
assert.equal(montag.getAttribute('aria-invalid'), 'true')
|
||||
// gültig: PATCH mit dem Plan (alle anderen Tage unverändert)
|
||||
await fx.setze(montag, '10:00-13:00')
|
||||
fx.antworten.push({ body: { einstellung, version: 5, fehlt: [] } })
|
||||
await fx.klick(speichern)
|
||||
assert.equal(fx.fetchLog.length, 1)
|
||||
const [aufruf] = fx.fetchLog
|
||||
assert.deepEqual([aufruf.method, aufruf.url], ['PATCH', '/api/termine/einstellungen'])
|
||||
assert.equal(aufruf.body.version, 4)
|
||||
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[1], [['10:00', '13:00']])
|
||||
assert.deepEqual(aufruf.body.einstellung.ressourcen[0].plan[2], anna.plan[2])
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- termine-speichern.js: fehlt aus der Antwort
|
||||
const speicherCtx = (einstellung, fehlt) => ({
|
||||
aktuell: () => true,
|
||||
daten: { einstellung, version: 3, fehlt },
|
||||
api: { send: (method, pfad, body = {}) => request(`/api/termine${pfad}`, { method, body: { projectId: 'p1', ...body } }) },
|
||||
hinweise() {}, meldung() {}, neuLaden: async () => {},
|
||||
})
|
||||
|
||||
test('Speichern: antwortet der PATCH mit fehlt, steht es danach in den Daten des Reiters', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4, fehlt: ['betrieb.telefon'] } }] }, async (fx) => {
|
||||
const ctx = speicherCtx(salon(), ['betrieb.telefon', 'betrieb.adresse'])
|
||||
const wurzel = h('div')
|
||||
document.body.append(wurzel)
|
||||
const sp = neuerSpeicher(ctx, wurzel, () => {})
|
||||
assert.equal(await sp.speichere((entwurf) => { entwurf.betrieb.adresse = 'Hauptstraße 1' }, { container: wurzel }), true)
|
||||
assert.deepEqual(ctx.daten.fehlt, ['betrieb.telefon'])
|
||||
assert.equal(ctx.daten.version, 4)
|
||||
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url]), [['PATCH', '/api/termine/einstellungen']])
|
||||
}))
|
||||
|
||||
test('Speichern: ohne fehlt in der Antwort ist es leer', mitDom({ antworten: [{ body: { einstellung: salon(), version: 4 } }] }, async () => {
|
||||
const ctx = speicherCtx(salon(), ['betrieb.telefon'])
|
||||
const wurzel = h('div')
|
||||
document.body.append(wurzel)
|
||||
const sp = neuerSpeicher(ctx, wurzel, () => {})
|
||||
assert.equal(await sp.speichere(() => true, { container: wurzel }), true)
|
||||
assert.deepEqual(ctx.daten.fehlt, [])
|
||||
}))
|
||||
|
||||
// ------------------------------------------------------- termine.js: baueCtx
|
||||
test('baueCtx: Schnittstelle, Daten und Meldungen wie der Reiter-Kontext', mitDom({ antworten: [{}, {}, {}, { body: { version: 9, einstellung: null } }, { body: { version: 11 } }] }, async (fx) => {
|
||||
const s = { daten: { version: 3 }, nurLesen: false, meldungEl: h('div', { class: 'tm-meldung', tabindex: '-1' }) }
|
||||
document.body.append(s.meldungEl)
|
||||
let aktuell = true
|
||||
let gezeichnet = 0
|
||||
const ctx = huelle.baueCtx(s, { projekt: { projectId: 'p 1' }, aktuell: () => aktuell, hinweise: () => { gezeichnet += 1 } })
|
||||
assert.equal(ctx.s, s)
|
||||
assert.equal(ctx.projectId, 'p 1')
|
||||
assert.equal(ctx.daten, s.daten)
|
||||
assert.equal(ctx.nurLesen, false)
|
||||
assert.equal(typeof ctx.auswahl, 'object')
|
||||
assert.equal(ctx.aktuell(), true)
|
||||
assert.equal(typeof ctx.zumReiter, 'function')
|
||||
// Schnittstelle: GET und DELETE mit projectId in der Adresse, PATCH/POST/PUT mit projectId im Body
|
||||
await ctx.api.get('/buchungen', { von: '2026-10-05' })
|
||||
await ctx.api.send('DELETE', '/abwesenheiten/a1')
|
||||
await ctx.api.send('PATCH', '/einstellungen', { version: 3 })
|
||||
assert.deepEqual(fx.fetchLog.map((a) => [a.method, a.url, a.body]), [
|
||||
['GET', '/api/termine/buchungen?projectId=p+1&von=2026-10-05', undefined],
|
||||
['DELETE', '/api/termine/abwesenheiten/a1?projectId=p+1', undefined],
|
||||
['PATCH', '/api/termine/einstellungen', { projectId: 'p 1', version: 3 }],
|
||||
])
|
||||
// Meldung nur solange die Ansicht aktuell ist; hinweise() zeichnet nur dann
|
||||
ctx.meldung('Gespeichert.')
|
||||
assert.equal(s.meldungEl.textContent, 'Gespeichert.')
|
||||
ctx.hinweise()
|
||||
assert.equal(gezeichnet, 1)
|
||||
// neu laden: Daten und ctx.daten werden ersetzt und die Hinweise neu gezeichnet
|
||||
const daten = await ctx.neuLaden()
|
||||
assert.deepEqual(daten, { version: 9, einstellung: null })
|
||||
assert.equal(s.daten, daten)
|
||||
assert.equal(ctx.daten, daten)
|
||||
assert.equal(gezeichnet, 2)
|
||||
assert.equal(fx.fetchLog.at(-1).url, '/api/termine/einstellungen?projectId=p%201')
|
||||
aktuell = false
|
||||
ctx.meldung('Zu spät.')
|
||||
ctx.hinweise()
|
||||
await ctx.neuLaden()
|
||||
assert.equal(s.meldungEl.textContent, 'Gespeichert.', 'eine veraltete Ansicht bekommt keine Meldung')
|
||||
assert.equal(gezeichnet, 2)
|
||||
assert.equal(ctx.daten, daten, 'und keine neuen Daten')
|
||||
// ohne hinweise/zumReiter ist der Kontext trotzdem benutzbar (Fragebogen)
|
||||
const schlicht = huelle.baueCtx(s, { projekt: { projectId: 'p1' }, aktuell: () => true })
|
||||
schlicht.hinweise()
|
||||
schlicht.zumReiter('team')
|
||||
}))
|
||||
@@ -363,6 +363,32 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn
|
||||
}
|
||||
})
|
||||
|
||||
test('Reiter-Exporte für den Fragebogen (TP2 C7b): acht Abschnitte, wochenplanFeld, baueCtx, fehlt aus der Antwort', async () => {
|
||||
const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||
// Einstellungen: die acht Abschnitte sind Exporte; der Reiter baut sie weiter selbst (betrieb ohne nur = alle neun Felder, Verhalten in termineFragebogenView.test.js)
|
||||
const einstellungen = await import('../public/termine-tab-einstellungen.js')
|
||||
for (const name of ['betrieb', 'darstellung', 'regeln', 'gastFelder', 'erinnerung', 'storno', 'rechtliches', 'zahlung']) {
|
||||
assert.equal(typeof einstellungen[name], 'function', `${name} ist exportiert`)
|
||||
assert.match(lies('public/termine-tab-einstellungen.js'), new RegExp(`export function ${name}\\(`), name)
|
||||
}
|
||||
assert.match(lies('public/termine-tab-einstellungen.js'), /const abschnitte = \[betrieb\(ctx, e\), darstellung\(e\), regelnAbschnitt, gastFelder\(ctx, e\), erinnerung\(e\), storno\(ctx, e, regelnAbschnitt\.absage\), rechtliches\(e\), zahlung\(e\), aufbewahrung\(e\)\]/, 'der Reiter benutzt sie selbst')
|
||||
// Team: wochenplanFeld ist ein Export, den der Editor einer Person selbst benutzt (die Tagesfelder gibt es nur dort)
|
||||
const team = ohneKommentare(lies('public/termine-tab-team.js'))
|
||||
assert.match(team, /export function wochenplanFeld\(r, pfad\)/)
|
||||
assert.match(team, /= wochenplanFeld\(/, 'formular benutzt wochenplanFeld')
|
||||
assert.equal(team.match(/WOCHENTAGE_PLAN\.map/g).length, 1, 'die Tagesfelder werden nur in wochenplanFeld gebaut')
|
||||
assert.equal(team.match(/planAusTexten\(/g).length, 1, 'der Wochenplan wird nur in wochenplanFeld gelesen')
|
||||
// Speichern: fehlt kommt aus der Antwort des Servers
|
||||
const speichern = ohneKommentare(lies('public/termine-speichern.js'))
|
||||
assert.match(speichern, /fehlt: antwort\.fehlt \?\? \[\]/)
|
||||
assert.doesNotMatch(speichern, /fehlt: \[\]/)
|
||||
// Huelle: der Kontext der Reiter wird in baueCtx gebaut (exportiert, auch der Fragebogen braucht ihn), zeigeReiter benutzt es
|
||||
const huelle = ohneKommentare(lies('public/termine.js'))
|
||||
assert.match(huelle, /export function baueCtx\(/)
|
||||
assert.match(huelle, /const ctx = baueCtx\(/)
|
||||
assert.equal(huelle.match(/api: \{/g).length, 1, 'die Schnittstelle des Kontexts steht nur in baueCtx')
|
||||
})
|
||||
|
||||
test('beschrifteFehler: Fehler ohne Platz am Feld nennen den Eintrag', () => {
|
||||
const quellen = [{
|
||||
leistungen: [{ id: 's_aaaaaaaa', name: 'Herrenschnitt' }, { id: 's_bbbbbbbb', name: 'Färben' }],
|
||||
|
||||
Reference in new issue
Block a user