This commit is contained in:
@@ -1,4 +1,7 @@
|
|||||||
-- Wegwichtel editor support: route activation timestamp.
|
-- Wegwichtel editor support: route activation timestamp.
|
||||||
-- New routes remain unpublished while activated_at is NULL.
|
-- Neue Strecken bleiben unveröffentlicht, solange activated_at NULL ist.
|
||||||
-- Run once against the SQLite database if the routes table does not already contain this column.
|
-- 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;
|
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
|
## Enthaltene Dateien
|
||||||
|
|
||||||
@@ -9,36 +9,32 @@ Dieses Paket ergänzt die Oberfläche um einen einfachen Streckeneditor auf der
|
|||||||
- neues Dialogfenster `#editor-dialog`
|
- neues Dialogfenster `#editor-dialog`
|
||||||
- Einbindung von `css/editor.css` und `js/editor-ui.js`
|
- Einbindung von `css/editor.css` und `js/editor-ui.js`
|
||||||
- `public/css/editor.css`
|
- `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`
|
- `public/js/editor-ui.js`
|
||||||
- Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular
|
- Editor-Dialog, Streckenliste, Streckendetailformular, POI-Formular
|
||||||
- POI-Sortierung per Pfeil nach oben/unten
|
- POI-Sortierung per Pfeil nach oben/unten
|
||||||
- Upload einer Audiodatei und beliebig vieler Bilder pro POI
|
- Upload einer Audiodatei und beliebig vieler Bilder pro POI
|
||||||
- `database/migrations/20260901_add_route_activated_at.sql`
|
- `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
|
## Datenbank
|
||||||
|
|
||||||
Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist.
|
Neue Strecken bleiben fachlich Entwürfe, solange `routes.activated_at` NULL ist. Die Aktivierung wird in dieser Fassung nicht durch die Programmlogik gesetzt.
|
||||||
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
|
```sh
|
||||||
sqlite3 <pfad-zur-wegwichtel-db> < database/migrations/20260901_add_route_activated_at.sql
|
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`
|
Das Paket ersetzt `database/schema.sql` bewusst nicht blind, damit bestehende Tabellen, Indizes und Trigger nicht versehentlich überschrieben werden.
|
||||||
- `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.
|
|
||||||
|
|||||||
+39
-7
@@ -6,6 +6,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const ids = Object.freeze({
|
const ids = Object.freeze({
|
||||||
addPoi: 'editor-add-poi',
|
addPoi: 'editor-add-poi',
|
||||||
button: 'editor-button',
|
button: 'editor-button',
|
||||||
|
coordinateGrid: 'editor-coordinate-grid',
|
||||||
close: 'editor-close',
|
close: 'editor-close',
|
||||||
dialog: 'editor-dialog',
|
dialog: 'editor-dialog',
|
||||||
entryTitle: 'editor-entry-title',
|
entryTitle: 'editor-entry-title',
|
||||||
@@ -41,6 +42,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
title: 'editor-dialog-title'
|
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 = {
|
const state = {
|
||||||
currentPoi: null,
|
currentPoi: null,
|
||||||
currentRoute: null,
|
currentRoute: null,
|
||||||
@@ -223,7 +249,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function iconButton(label, symbol, action) {
|
function iconButton(label, symbol, action) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.className = 'editor-icon-button';
|
button.className = classes.iconButton;
|
||||||
button.dataset.editorAction = action;
|
button.dataset.editorAction = action;
|
||||||
button.textContent = symbol;
|
button.textContent = symbol;
|
||||||
button.title = label;
|
button.title = label;
|
||||||
@@ -234,7 +260,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function routeItem(route) {
|
function routeItem(route) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'editor-route-item';
|
item.className = classes.routeItem;
|
||||||
item.dataset.routeId = routeId(route);
|
item.dataset.routeId = routeId(route);
|
||||||
|
|
||||||
const label = document.createElement('div');
|
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');
|
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'editor-icon-row';
|
actions.className = classes.iconRow;
|
||||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||||
|
|
||||||
item.append(label, actions);
|
item.append(label, actions);
|
||||||
@@ -251,7 +277,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function poiItem(poi, index) {
|
function poiItem(poi, index) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'editor-poi-item';
|
item.className = classes.poiItem;
|
||||||
item.dataset.poiId = poiId(poi);
|
item.dataset.poiId = poiId(poi);
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
@@ -259,7 +285,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
appendText(label, 'small', `Position ${index + 1}`);
|
appendText(label, 'small', `Position ${index + 1}`);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
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 oben schieben', '↑', 'move-poi-up'));
|
||||||
actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down'));
|
actions.appendChild(iconButton('Station nach unten schieben', '↓', 'move-poi-down'));
|
||||||
actions.appendChild(iconButton('Station bearbeiten', '✎', 'edit-poi'));
|
actions.appendChild(iconButton('Station bearbeiten', '✎', 'edit-poi'));
|
||||||
@@ -274,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!state.routes.length) {
|
if (!state.routes.length) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'editor-route-item';
|
item.className = classes.routeItem;
|
||||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
return;
|
return;
|
||||||
@@ -288,7 +314,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!state.pois.length) {
|
if (!state.pois.length) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = 'editor-poi-item';
|
item.className = classes.poiItem;
|
||||||
appendText(item, 'span', 'Noch keine Stationen angelegt.');
|
appendText(item, 'span', 'Noch keine Stationen angelegt.');
|
||||||
list.appendChild(item);
|
list.appendChild(item);
|
||||||
return;
|
return;
|
||||||
@@ -533,10 +559,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function classNames() {
|
||||||
|
return Object.values(classes);
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
bind();
|
bind();
|
||||||
ns.EditorUi = Object.freeze({
|
ns.EditorUi = Object.freeze({
|
||||||
close: closeEditor,
|
close: closeEditor,
|
||||||
|
classNames,
|
||||||
open: openEditor,
|
open: openEditor,
|
||||||
refresh: loadRoutes
|
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