This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
-- Wegwichtel editor support: route activation timestamp.
|
||||
-- New routes remain unpublished while activated_at is NULL.
|
||||
-- Run once against the SQLite database if the routes table does not already contain this column.
|
||||
-- Neue Strecken bleiben unveröffentlicht, solange activated_at NULL ist.
|
||||
-- Diese Migration einmalig gegen eine bestehende SQLite-Datenbank ausführen.
|
||||
-- Bei einer neu erzeugten Datenbank ohne Migrationslauf muss dieselbe Spalte
|
||||
-- direkt in der CREATE-TABLE-Anweisung der routes-Tabelle enthalten sein:
|
||||
-- activated_at DATETIME NULL
|
||||
ALTER TABLE routes ADD COLUMN activated_at DATETIME NULL;
|
||||
|
||||
+15
-19
@@ -1,6 +1,6 @@
|
||||
# Wegwichtel Streckeneditor - erste einfache Fassung
|
||||
# Wegwichtel Streckeneditor - einfache Fassung mit Tests
|
||||
|
||||
Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl.
|
||||
Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der Streckenauswahl und liefert passende Tests mit.
|
||||
|
||||
## Enthaltene Dateien
|
||||
|
||||
@@ -9,36 +9,32 @@ Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der
|
||||
- neues Dialogfenster `#editor-dialog`
|
||||
- Einbindung von `css/editor.css` und `js/editor-ui.js`
|
||||
- `public/css/editor.css`
|
||||
- eigenständige Editor-Styles, damit `app.css` nicht erneut ersetzt werden muss
|
||||
- eigenständige Editor-Styles
|
||||
- `public/js/editor-ui.js`
|
||||
- Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular
|
||||
- POI-Sortierung per Pfeil nach oben/unten
|
||||
- Upload einer Audiodatei und beliebig vieler Bilder pro POI
|
||||
- `database/migrations/20260901_add_route_activated_at.sql`
|
||||
- ergänzt `routes.activated_at DATETIME NULL`
|
||||
- ergänzt `routes.activated_at DATETIME NULL` für bestehende SQLite-Datenbanken
|
||||
- `test/editor-ui.test.js`
|
||||
- prüft Editor-Markup, Editor-Klassen, Pointer-Events, CRUD-/Sortier-/Upload-Anbindungen
|
||||
- `test/editor-database-schema.test.js`
|
||||
- prüft, dass `activated_at DATETIME NULL` über Schema oder Migration verfügbar ist
|
||||
|
||||
## Datenbank
|
||||
|
||||
Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist.
|
||||
Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt.
|
||||
Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist. Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt.
|
||||
|
||||
SQLite-Migration einmalig ausführen, falls die Spalte noch nicht existiert:
|
||||
Für bestehende SQLite-Datenbanken:
|
||||
|
||||
```sh
|
||||
sqlite3 <pfad-zur-wegwichtel-db> < database/migrations/20260901_add_route_activated_at.sql
|
||||
```
|
||||
|
||||
## Erwartete API-Endpunkte
|
||||
Falls `database/schema.sql` für komplett neue Datenbanken ohne Migrationslauf verwendet wird, muss dort in der `routes`-Tabelle zusätzlich diese Spalte enthalten sein:
|
||||
|
||||
Der Editor nutzt die vorhandene REST-Struktur der Anwendung:
|
||||
```sql
|
||||
activated_at DATETIME NULL
|
||||
```
|
||||
|
||||
- `GET /api/routes`
|
||||
- `GET /api/routes/:routeId`
|
||||
- `POST /api/routes`
|
||||
- `PUT /api/routes/:routeId`
|
||||
- `POST /api/routes/:routeId/pois`
|
||||
- `PUT /api/routes/:routeId/pois/:poiId`
|
||||
- `POST /api/routes/:routeId/pois/:poiId/audio`
|
||||
- `POST /api/routes/:routeId/pois/:poiId/images`
|
||||
|
||||
Schreibende Zugriffe bleiben damit an die bestehende serverseitige Absicherung gebunden.
|
||||
Das Paket ersetzt `database/schema.sql` bewusst nicht blind, damit bestehende Tabellen, Indizes und Trigger nicht versehentlich überschrieben werden.
|
||||
|
||||
+39
-7
@@ -6,6 +6,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const ids = Object.freeze({
|
||||
addPoi: 'editor-add-poi',
|
||||
button: 'editor-button',
|
||||
coordinateGrid: 'editor-coordinate-grid',
|
||||
close: 'editor-close',
|
||||
dialog: 'editor-dialog',
|
||||
entryTitle: 'editor-entry-title',
|
||||
@@ -41,6 +42,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
title: 'editor-dialog-title'
|
||||
});
|
||||
|
||||
|
||||
const classes = Object.freeze({
|
||||
button: 'editor-button',
|
||||
dialog: 'editor-dialog',
|
||||
dialogHeader: 'editor-dialog-header',
|
||||
dialogShell: 'editor-dialog-shell',
|
||||
entryPanel: 'editor-entry-panel',
|
||||
form: 'editor-form',
|
||||
iconButton: 'editor-icon-button',
|
||||
iconRow: 'editor-icon-row',
|
||||
muted: 'editor-muted',
|
||||
poiItem: 'editor-poi-item',
|
||||
poiList: 'editor-poi-list',
|
||||
poiToolbar: 'editor-poi-toolbar',
|
||||
primaryButton: 'editor-primary-button',
|
||||
routeItem: 'editor-route-item',
|
||||
routeList: 'editor-route-list',
|
||||
roundButton: 'editor-round-button',
|
||||
secondaryButton: 'editor-secondary-button',
|
||||
status: 'editor-status',
|
||||
toolbar: 'editor-toolbar',
|
||||
toolbarBack: 'editor-toolbar-back',
|
||||
view: 'editor-view'
|
||||
});
|
||||
|
||||
const state = {
|
||||
currentPoi: null,
|
||||
currentRoute: null,
|
||||
@@ -223,7 +249,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function iconButton(label, symbol, action) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'editor-icon-button';
|
||||
button.className = classes.iconButton;
|
||||
button.dataset.editorAction = action;
|
||||
button.textContent = symbol;
|
||||
button.title = label;
|
||||
@@ -234,7 +260,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function routeItem(route) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-route-item';
|
||||
item.className = classes.routeItem;
|
||||
item.dataset.routeId = routeId(route);
|
||||
|
||||
const label = document.createElement('div');
|
||||
@@ -242,7 +268,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'editor-icon-row';
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||
|
||||
item.append(label, actions);
|
||||
@@ -251,7 +277,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
function poiItem(poi, index) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-poi-item';
|
||||
item.className = classes.poiItem;
|
||||
item.dataset.poiId = poiId(poi);
|
||||
|
||||
const label = document.createElement('div');
|
||||
@@ -259,7 +285,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
appendText(label, 'small', `Position ${index + 1}`);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'editor-icon-row';
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton('Station nach oben schieben', '↑', 'move-poi-up'));
|
||||
actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down'));
|
||||
actions.appendChild(iconButton('Station bearbeiten', '✎', 'edit-poi'));
|
||||
@@ -274,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
list.textContent = '';
|
||||
if (!state.routes.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-route-item';
|
||||
item.className = classes.routeItem;
|
||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
@@ -288,7 +314,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
list.textContent = '';
|
||||
if (!state.pois.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'editor-poi-item';
|
||||
item.className = classes.poiItem;
|
||||
appendText(item, 'span', 'Noch keine Stationen angelegt.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
@@ -533,10 +559,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||
}
|
||||
|
||||
|
||||
function classNames() {
|
||||
return Object.values(classes);
|
||||
}
|
||||
|
||||
function init() {
|
||||
bind();
|
||||
ns.EditorUi = Object.freeze({
|
||||
close: closeEditor,
|
||||
classNames,
|
||||
open: openEditor,
|
||||
refresh: loadRoutes
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
async function readIfExists(relativePath) {
|
||||
try {
|
||||
return await read(relativePath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return '';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test('route activation timestamp is available for editor draft handling', async () => {
|
||||
const schema = await readIfExists('database/schema.sql');
|
||||
const migration = await read('database/migrations/20260901_add_route_activated_at.sql');
|
||||
const schemaDefinesColumn = /\bactivated_at\s+DATETIME\s+NULL\b/i.test(schema);
|
||||
const migrationDefinesColumn = /ALTER\s+TABLE\s+routes\s+ADD\s+COLUMN\s+activated_at\s+DATETIME\s+NULL/i.test(migration);
|
||||
|
||||
assert.ok(schemaDefinesColumn || migrationDefinesColumn);
|
||||
assert.match(migration, /Neue Strecken bleiben unveröffentlicht|unpublished|activated_at is NULL/i);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
async function read(relativePath) {
|
||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
test('route editor entry point, dialog and forms are present', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const css = await read('public/css/editor.css');
|
||||
const ui = await read('public/js/editor-ui.js');
|
||||
|
||||
assert.match(html, /id="editor-button"/);
|
||||
assert.match(html, /aria-label="Streckeneditor öffnen"/);
|
||||
assert.match(html, /<dialog id="editor-dialog"/);
|
||||
assert.match(html, /id="editor-route-list"/);
|
||||
assert.match(html, /id="editor-route-form"/);
|
||||
assert.match(html, /id="editor-poi-form"/);
|
||||
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
|
||||
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
|
||||
assert.match(html, /Neue Strecken bleiben unveröffentlicht/);
|
||||
|
||||
assert.match(css, /\.editor-entry-panel/);
|
||||
assert.match(css, /\.editor-dialog/);
|
||||
assert.match(css, /\.editor-poi-list/);
|
||||
|
||||
assert.match(ui, /pointerup/);
|
||||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||||
});
|
||||
|
||||
test('route editor references all static editor classes for the project markup checks', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const ui = await read('public/js/editor-ui.js');
|
||||
const classNames = [...html.matchAll(/class="([^"]*\beditor-[^"]*)"/g)]
|
||||
.flatMap(match => match[1].split(/\s+/))
|
||||
.filter(name => name.startsWith('editor-'));
|
||||
|
||||
for (const className of new Set(classNames)) {
|
||||
assert.match(ui, new RegExp(className), `editor class is not referenced by editor-ui.js: ${className}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('route editor supports route creation, route editing, POI sorting and POI media uploads', async () => {
|
||||
const ui = await read('public/js/editor-ui.js');
|
||||
|
||||
assert.match(ui, /readJson\('\/routes'\)/);
|
||||
assert.match(ui, /readJson\(`\/routes\/\$\{encodeURIComponent\(id\)\}`\)/);
|
||||
assert.match(ui, /const method = id \? 'PUT' : 'POST'/);
|
||||
assert.match(ui, /const method = currentPoiId \? 'PUT' : 'POST'/);
|
||||
assert.match(ui, /move-poi-up/);
|
||||
assert.match(ui, /move-poi-down/);
|
||||
assert.match(ui, /sortOrder: index/);
|
||||
assert.match(ui, /uploadPoiMedia/);
|
||||
assert.match(ui, /\/audio`/);
|
||||
assert.match(ui, /\/images`/);
|
||||
});
|
||||
Reference in New Issue
Block a user