Files
dapelza 10f9170d61 Phase 3: Formular zum Anlegen und Bearbeiten
Neues Modal (js/ui/form.js) mit allen Feldern aus anforderung.md
Abschnitt 6: Name, Kategorie, Kurz-/Volltext-Beschreibung, Adresse, Ort,
Koordinaten, Altersgruppe, Kosten, Oeffnungszeiten, Aufenthaltsdauer,
Ausstattungs-Checkboxen, Indoor/Outdoor, Bild-URL, Website, Tags.

Validierung: Pflichtfelder (Name, Kategorie, Koordinaten), Koordinaten-
Bereichspruefung, Alter min<=max, Bild-/Website-URL nur http/https.
Fehler erscheinen inline je Feld (aria-invalid/aria-describedby), erstes
fehlerhaftes Feld wird fokussiert.

EventMap.ui.openPlaceForm(place) dient als Einstiegspunkt: ohne Argument
zum Neuanlegen, mit Place-Objekt zum Bearbeiten. Speichern aktualisiert
den Store mit neuer Array-Referenz (Karte/Liste reagieren automatisch)
und persistiert nach LocalStorage.

Verdrahtung: neuer "+ Neues Ausflugsziel hinzufuegen"-Button in der
Karten-Toolbar; der bisherige Bearbeiten-Platzhalter in der
Detailansicht (js/ui/detail.js) oeffnet jetzt das Formular vorausgefuellt
mit dem aktuell angezeigten Ausflugsziel.
2026-07-24 11:43:01 +02:00

605 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Formular (Modal) zum manuellen Anlegen und Bearbeiten eines
// Ausflugsziels (Phase 3, siehe anforderung.md Abschnitt 6).
//
// API: EventMap.ui.initForm(containerId) initialisiert das Modal einmalig
// (analog zu js/ui/detail.js). EventMap.ui.openPlaceForm(placeOrNull) öffnet
// es: ohne Argument im "Neu anlegen"-Modus, mit einem Place-Objekt im
// "Bearbeiten"-Modus (überschreibt den bestehenden Eintrag statt einen
// neuen anzulegen).
//
// Rendering ausschließlich über textContent/DOM-APIs (el()), niemals
// innerHTML mit Formular-/Place-Daten (siehe js/utils/dom.js).
(function () {
const { getState, setState, persistPlaces } = EventMap.store;
const { normalizePlace } = EventMap.models;
const { COST_TYPES, ENVIRONMENT_TYPES } = EventMap.models;
const { CATEGORIES } = EventMap.config;
const { el, isSafeHttpUrl, isValidCoordinate } = EventMap.utils;
const COST_LABELS = {
free: 'Kostenlos',
paid: 'Kostenpflichtig',
mixed: 'Teilweise kostenpflichtig',
unknown: 'Keine Angabe',
};
const ENVIRONMENT_LABELS = {
indoor: 'Drinnen (Indoor)',
outdoor: 'Draußen (Outdoor)',
mixed: 'Drinnen & draußen',
};
// Felder, für die ein eigenes Fehler-Element existiert (siehe fieldWrapper()).
const ERROR_FIELDS = [
'name',
'category',
'latitude',
'longitude',
'ageMin',
'ageMax',
'duration',
'image',
'website',
];
let container = null;
let refs = null;
let editingPlace = null;
function fieldWrapper(id, labelText, inputNode, { required = false } = {}) {
inputNode.id = id;
if (required) {
inputNode.setAttribute('required', '');
inputNode.setAttribute('aria-required', 'true');
}
const errorEl = el('p', {
className: 'form-field__error',
attrs: { id: `${id}-error`, role: 'alert' },
});
errorEl.hidden = true;
inputNode.setAttribute('aria-describedby', `${id}-error`);
const labelChildren = [labelText];
if (required) {
labelChildren.push(
el('span', { className: 'form-field__required', attrs: { 'aria-hidden': 'true' } }, [' *'])
);
}
const wrapper = el('div', { className: 'form-field' }, [
el('label', { className: 'form-field__label', attrs: { for: id } }, labelChildren),
inputNode,
errorEl,
]);
return { wrapper, input: inputNode, errorEl };
}
function textInput(type = 'text', extraAttrs = {}) {
return el('input', { attrs: { type, ...extraAttrs } });
}
function buildCategorySelect() {
const select = el('select', {}, [
el('option', { attrs: { value: '' }, text: '-- Bitte wählen --' }),
...CATEGORIES.map((cat) =>
el('option', { attrs: { value: cat.id }, text: `${cat.icon} ${cat.label}` })
),
]);
return select;
}
function buildCostTypeSelect() {
return el(
'select',
{},
COST_TYPES.map((type) =>
el('option', { attrs: { value: type }, text: COST_LABELS[type] || type })
)
);
}
function buildEnvironmentSelect() {
return el(
'select',
{},
ENVIRONMENT_TYPES.map((type) =>
el('option', { attrs: { value: type }, text: ENVIRONMENT_LABELS[type] || type })
)
);
}
function checkboxField(id, labelText) {
const input = el('input', { attrs: { type: 'checkbox', id } });
const wrapper = el('label', { className: 'form-checkbox', attrs: { for: id } }, [
input,
labelText,
]);
return { wrapper, input };
}
function section(legendText, children) {
return el('fieldset', { className: 'place-form__section' }, [
el('legend', { className: 'place-form__section-label', text: legendText }),
...children,
]);
}
function row(children) {
return el('div', { className: 'place-form__row' }, children);
}
function buildSkeleton(rootContainer) {
const nodeRefs = {};
nodeRefs.backdrop = el('div', {
className: 'place-form-modal__backdrop',
on: { click: () => closeForm() },
});
nodeRefs.closeBtn = el(
'button',
{
className: 'place-form-modal__close',
attrs: { type: 'button', 'aria-label': 'Formular schließen' },
on: { click: () => closeForm() },
},
['✕']
);
nodeRefs.title = el('h2', {
className: 'place-form__title',
attrs: { id: 'place-form-title' },
});
// Basisdaten
const nameField = fieldWrapper('pf-name', 'Name', textInput('text'), { required: true });
const categoryField = fieldWrapper('pf-category', 'Kategorie', buildCategorySelect(), {
required: true,
});
const shortDescriptionField = fieldWrapper(
'pf-short-description',
'Kurzbeschreibung',
textInput('text')
);
const descriptionField = fieldWrapper(
'pf-description',
'Ausführliche Beschreibung',
el('textarea', {})
);
// Adresse / Position
const addressField = fieldWrapper('pf-address', 'Adresse', textInput('text'));
const cityField = fieldWrapper('pf-city', 'Ort', textInput('text'));
const latitudeField = fieldWrapper(
'pf-latitude',
'Breitengrad',
textInput('number', { step: 'any' }),
{ required: true }
);
const longitudeField = fieldWrapper(
'pf-longitude',
'Längengrad',
textInput('number', { step: 'any' }),
{ required: true }
);
// Altersgruppe / Kosten / Öffnungszeiten / Dauer
const ageMinField = fieldWrapper(
'pf-age-min',
'Altersgruppe ab (Jahre)',
textInput('number', { step: '1', min: '0' })
);
const ageMaxField = fieldWrapper(
'pf-age-max',
'Altersgruppe bis (Jahre)',
textInput('number', { step: '1', min: '0' })
);
const costTypeField = fieldWrapper('pf-cost-type', 'Kosten', buildCostTypeSelect());
const costDescriptionField = fieldWrapper(
'pf-cost-description',
'Kosten Details',
textInput('text')
);
const openingHoursField = fieldWrapper('pf-opening-hours', 'Öffnungszeiten', textInput('text'));
const durationField = fieldWrapper(
'pf-duration',
'Aufenthaltsdauer (Minuten)',
textInput('number', { step: '1', min: '0' })
);
// Ausstattung
const strollerCheckbox = checkboxField('pf-facility-stroller', 'Kinderwagen geeignet');
const wheelchairCheckbox = checkboxField('pf-facility-wheelchair', 'Barrierefrei');
const toiletsCheckbox = checkboxField('pf-facility-toilets', 'Toiletten vorhanden');
const parkingCheckbox = checkboxField('pf-facility-parking', 'Parkplatz vorhanden');
const restaurantCheckbox = checkboxField('pf-facility-restaurant', 'Gastronomie vorhanden');
const environmentField = fieldWrapper('pf-environment', 'Indoor / Outdoor', buildEnvironmentSelect());
// Weitere Informationen
const imageField = fieldWrapper('pf-image', 'Bild-URL', textInput('url'));
const websiteField = fieldWrapper('pf-website', 'Website', textInput('url'));
const tagsField = fieldWrapper(
'pf-tags',
'Tags (kommagetrennt)',
textInput('text')
);
nodeRefs.cancelBtn = el(
'button',
{ className: 'btn btn--secondary', attrs: { type: 'button' }, on: { click: () => closeForm() } },
['Abbrechen']
);
nodeRefs.submitBtn = el(
'button',
{ className: 'btn btn--primary', attrs: { type: 'submit' } },
['Ausflugsziel speichern']
);
nodeRefs.form = el(
'form',
{ className: 'place-form', attrs: { id: 'place-form', novalidate: '' }, on: { submit: handleSubmit } },
[
section('Basisdaten', [
nameField.wrapper,
categoryField.wrapper,
shortDescriptionField.wrapper,
descriptionField.wrapper,
]),
section('Adresse & Position', [
addressField.wrapper,
cityField.wrapper,
row([latitudeField.wrapper, longitudeField.wrapper]),
]),
section('Altersgruppe, Kosten & Zeiten', [
row([ageMinField.wrapper, ageMaxField.wrapper]),
row([costTypeField.wrapper, costDescriptionField.wrapper]),
openingHoursField.wrapper,
durationField.wrapper,
]),
section('Ausstattung', [
el('div', { className: 'place-form__checkbox-group' }, [
strollerCheckbox.wrapper,
wheelchairCheckbox.wrapper,
toiletsCheckbox.wrapper,
parkingCheckbox.wrapper,
restaurantCheckbox.wrapper,
]),
environmentField.wrapper,
]),
section('Weitere Informationen', [
imageField.wrapper,
websiteField.wrapper,
tagsField.wrapper,
]),
el('div', { className: 'place-form__actions' }, [nodeRefs.cancelBtn, nodeRefs.submitBtn]),
]
);
nodeRefs.panel = el(
'div',
{
className: 'place-form-modal__panel',
attrs: { role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'place-form-title' },
},
[nodeRefs.closeBtn, nodeRefs.title, nodeRefs.form]
);
rootContainer.appendChild(nodeRefs.backdrop);
rootContainer.appendChild(nodeRefs.panel);
// Flache Referenzen für Lesen/Schreiben/Fehleranzeige der Feldwerte.
nodeRefs.name = nameField.input;
nodeRefs.nameError = nameField.errorEl;
nodeRefs.category = categoryField.input;
nodeRefs.categoryError = categoryField.errorEl;
nodeRefs.shortDescription = shortDescriptionField.input;
nodeRefs.description = descriptionField.input;
nodeRefs.address = addressField.input;
nodeRefs.city = cityField.input;
nodeRefs.latitude = latitudeField.input;
nodeRefs.latitudeError = latitudeField.errorEl;
nodeRefs.longitude = longitudeField.input;
nodeRefs.longitudeError = longitudeField.errorEl;
nodeRefs.ageMin = ageMinField.input;
nodeRefs.ageMinError = ageMinField.errorEl;
nodeRefs.ageMax = ageMaxField.input;
nodeRefs.ageMaxError = ageMaxField.errorEl;
nodeRefs.costType = costTypeField.input;
nodeRefs.costDescription = costDescriptionField.input;
nodeRefs.openingHours = openingHoursField.input;
nodeRefs.duration = durationField.input;
nodeRefs.durationError = durationField.errorEl;
nodeRefs.stroller = strollerCheckbox.input;
nodeRefs.wheelchair = wheelchairCheckbox.input;
nodeRefs.toilets = toiletsCheckbox.input;
nodeRefs.parking = parkingCheckbox.input;
nodeRefs.restaurant = restaurantCheckbox.input;
nodeRefs.environment = environmentField.input;
nodeRefs.image = imageField.input;
nodeRefs.imageError = imageField.errorEl;
nodeRefs.website = websiteField.input;
nodeRefs.websiteError = websiteField.errorEl;
nodeRefs.tags = tagsField.input;
return nodeRefs;
}
function parseOptionalNumber(raw) {
const trimmed = (raw || '').trim();
if (trimmed === '') return null;
const num = Number(trimmed);
return Number.isFinite(num) ? num : NaN;
}
function readValues() {
return {
name: refs.name.value.trim(),
category: refs.category.value,
shortDescription: refs.shortDescription.value.trim(),
description: refs.description.value.trim(),
address: refs.address.value.trim(),
city: refs.city.value.trim(),
latitude: parseOptionalNumber(refs.latitude.value),
longitude: parseOptionalNumber(refs.longitude.value),
ageMin: parseOptionalNumber(refs.ageMin.value),
ageMax: parseOptionalNumber(refs.ageMax.value),
costType: refs.costType.value,
costDescription: refs.costDescription.value.trim(),
openingHours: refs.openingHours.value.trim(),
duration: parseOptionalNumber(refs.duration.value),
stroller: refs.stroller.checked,
wheelchair: refs.wheelchair.checked,
toilets: refs.toilets.checked,
parking: refs.parking.checked,
restaurant: refs.restaurant.checked,
environment: refs.environment.value,
image: refs.image.value.trim(),
website: refs.website.value.trim(),
tags: refs.tags.value,
};
}
/** Validiert die gelesenen Formularwerte. Gibt ein Objekt {feldname: Fehlermeldung} zurück. */
function validate(values) {
const errors = {};
if (!values.name) {
errors.name = 'Bitte einen Namen angeben.';
}
if (!values.category) {
errors.category = 'Bitte eine Kategorie auswählen.';
}
if (values.latitude === null) {
errors.latitude = 'Bitte einen Breitengrad angeben.';
} else if (Number.isNaN(values.latitude) || values.latitude < -90 || values.latitude > 90) {
errors.latitude = 'Breitengrad muss eine Zahl zwischen -90 und 90 sein.';
}
if (values.longitude === null) {
errors.longitude = 'Bitte einen Längengrad angeben.';
} else if (Number.isNaN(values.longitude) || values.longitude < -180 || values.longitude > 180) {
errors.longitude = 'Längengrad muss eine Zahl zwischen -180 und 180 sein.';
}
if (!errors.latitude && !errors.longitude && !isValidCoordinate(values.latitude, values.longitude)) {
errors.latitude = 'Koordinaten sind ungültig.';
}
if (values.ageMin !== null && (Number.isNaN(values.ageMin) || values.ageMin < 0)) {
errors.ageMin = 'Bitte ein gültiges Alter (mindestens 0) angeben.';
}
if (values.ageMax !== null && (Number.isNaN(values.ageMax) || values.ageMax < 0)) {
errors.ageMax = 'Bitte ein gültiges Alter (mindestens 0) angeben.';
}
if (
!errors.ageMin &&
!errors.ageMax &&
values.ageMin !== null &&
values.ageMax !== null &&
values.ageMin > values.ageMax
) {
errors.ageMax = 'Der Wert "bis" darf nicht kleiner als "ab" sein.';
}
if (values.duration !== null && (Number.isNaN(values.duration) || values.duration < 0)) {
errors.duration = 'Bitte eine gültige Aufenthaltsdauer in Minuten (mindestens 0) angeben.';
}
if (values.image && !isSafeHttpUrl(values.image)) {
errors.image = 'Bitte eine gültige Bild-URL (http/https) angeben.';
}
if (values.website && !isSafeHttpUrl(values.website)) {
errors.website = 'Bitte eine gültige Website-URL (http/https) angeben.';
}
return errors;
}
function clearErrors() {
for (const key of ERROR_FIELDS) {
const input = refs[key];
const errorEl = refs[`${key}Error`];
if (!input || !errorEl) continue;
input.removeAttribute('aria-invalid');
errorEl.textContent = '';
errorEl.hidden = true;
}
}
function showErrors(errors) {
clearErrors();
for (const [key, message] of Object.entries(errors)) {
const input = refs[key];
const errorEl = refs[`${key}Error`];
if (!input || !errorEl) continue;
input.setAttribute('aria-invalid', 'true');
errorEl.textContent = message;
errorEl.hidden = false;
}
}
function focusFirstError(errors) {
const firstKey = ERROR_FIELDS.find((key) => errors[key]);
if (firstKey && refs[firstKey]) refs[firstKey].focus();
}
/** Baut aus Formularwerten + optionaler Basis (bestehendes Place-Objekt) ein Rohobjekt für normalizePlace(). */
function buildRawPlace(values, base) {
return {
...base,
name: values.name,
category: values.category,
shortDescription: values.shortDescription,
description: values.description,
address: values.address,
city: values.city,
latitude: values.latitude,
longitude: values.longitude,
ageRecommendation: { min: values.ageMin, max: values.ageMax },
cost: { type: values.costType, description: values.costDescription },
openingHours: values.openingHours,
durationMinutes: values.duration,
facilities: {
strollerAccessible: values.stroller,
wheelchairAccessible: values.wheelchair,
toilets: values.toilets,
parking: values.parking,
restaurant: values.restaurant,
},
environment: values.environment,
image: values.image,
website: values.website,
tags: values.tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean),
// Explizit zurücksetzen: normalizePlace() setzt bei Bearbeiten dadurch
// einen neuen Zeitstempel, statt den aus `base` übernommenen zu behalten.
updatedAt: undefined,
};
}
function savePlace(normalized) {
const state = getState();
const places = editingPlace
? state.places.map((p) => (p.id === normalized.id ? normalized : p))
: [...state.places, normalized];
setState({ places });
persistPlaces();
}
function handleSubmit(event) {
event.preventDefault();
const values = readValues();
const errors = validate(values);
showErrors(errors);
if (Object.keys(errors).length > 0) {
focusFirstError(errors);
return;
}
const base = editingPlace ? { ...editingPlace } : {};
const normalized = normalizePlace(buildRawPlace(values, base));
savePlace(normalized);
closeForm();
}
function prefillForm(place) {
refs.name.value = place.name || '';
refs.category.value = place.category || '';
refs.shortDescription.value = place.shortDescription || '';
refs.description.value = place.description || '';
refs.address.value = place.address || '';
refs.city.value = place.city || '';
refs.latitude.value = place.latitude ?? '';
refs.longitude.value = place.longitude ?? '';
const age = place.ageRecommendation || {};
refs.ageMin.value = age.min ?? '';
refs.ageMax.value = age.max ?? '';
const cost = place.cost || {};
refs.costType.value = cost.type || 'unknown';
refs.costDescription.value = cost.description || '';
refs.openingHours.value = place.openingHours || '';
refs.duration.value = place.durationMinutes ?? '';
const facilities = place.facilities || {};
refs.stroller.checked = Boolean(facilities.strollerAccessible);
refs.wheelchair.checked = Boolean(facilities.wheelchairAccessible);
refs.toilets.checked = Boolean(facilities.toilets);
refs.parking.checked = Boolean(facilities.parking);
refs.restaurant.checked = Boolean(facilities.restaurant);
refs.environment.value = place.environment || 'outdoor';
refs.image.value = place.image || '';
refs.website.value = place.website || '';
refs.tags.value = Array.isArray(place.tags) ? place.tags.join(', ') : '';
}
/** Öffnet das Formular. Ohne Argument (bzw. mit null) im "Neu anlegen"-Modus, sonst im Bearbeiten-Modus. */
function openPlaceForm(place) {
if (!container || !refs) {
console.error('EventMap: Formular wurde noch nicht initialisiert (initForm()).');
return;
}
editingPlace = place || null;
clearErrors();
refs.form.reset();
if (editingPlace) {
refs.title.textContent = 'Ausflugsziel bearbeiten';
refs.submitBtn.textContent = 'Änderungen speichern';
prefillForm(editingPlace);
} else {
refs.title.textContent = 'Neues Ausflugsziel hinzufügen';
refs.submitBtn.textContent = 'Ausflugsziel speichern';
refs.category.value = '';
refs.costType.value = 'unknown';
refs.environment.value = 'outdoor';
}
container.hidden = false;
document.body.classList.add('place-form-modal-open');
refs.name.focus();
}
function closeForm() {
if (!container || container.hidden) return;
container.hidden = true;
document.body.classList.remove('place-form-modal-open');
editingPlace = null;
}
/** Initialisiert das Formular-Modal im Element mit der übergebenen id. */
function initForm(containerId) {
const containerEl = document.getElementById(containerId);
if (!containerEl) {
console.error(`EventMap: Formular-Container #${containerId} nicht gefunden.`);
return;
}
container = containerEl;
refs = buildSkeleton(container);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !container.hidden) {
closeForm();
}
});
}
EventMap.ui.initForm = initForm;
EventMap.ui.openPlaceForm = openPlaceForm;
})();