Expose project-wide history controls

This commit is contained in:
2026-07-25 23:16:46 +02:00
parent b2763f72d5
commit 59d442593f
8 changed files with 146 additions and 86 deletions
+3 -3
View File
@@ -237,9 +237,9 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
The project page exposes an initially collapsed German snapshot/timeline UI The project page exposes persistent project-wide Undo/Redo in the header of an
with explicit restore confirmation and cursor-based loading of older revision initially collapsed German snapshot/timeline UI, with explicit restore
metadata. confirmation and cursor-based loading of older revision metadata.
Insertions and generated move targets use client-generated stable UUIDs, and Insertions and generated move targets use client-generated stable UUIDs, and
undo restores the same ids from complete server snapshots. The tree response undo restores the same ids from complete server snapshots. The tree response
supplies the optimistic `currentRevision`; obsolete direct field PATCH, supplies the optimistic `currentRevision`; obsolete direct field PATCH,
+4 -3
View File
@@ -6,8 +6,8 @@ Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment, mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
persistentes Undo/Redo, Projektversionen und der Revit-/IFCGUID-Datenaustausch sind automatische periodische Sicherungspunkte und der Revit-/IFCGUID-Datenaustausch
noch nicht implementiert. sind noch nicht implementiert.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf
@@ -18,7 +18,8 @@ noch nicht implementiert.
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren - Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
- komplette Stromkreisblöcke filtern und sortieren - komplette Stromkreisblöcke filtern und sortieren
- BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren - BMKs stabil halten und nur auf ausdrücklichen Befehl neu nummerieren
- Änderungen innerhalb der aktuellen Editorsitzung rückgängig machen und wiederholen - Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
## Technik ## Technik
+4 -3
View File
@@ -142,6 +142,7 @@ Covered operations include:
- renumber and identifier update flows - renumber and identifier update flows
- apply sorted order - apply sorted order
The toolbar currently exposes availability through its Undo/Redo buttons. The editor toolbar exposes availability through its Undo/Redo buttons. The
Named restore points and the revision timeline are available on the project project page provides the same persistent project-wide controls in the header
page; the circuit editor keeps only the compact Undo/Redo controls. of its version card, including after a reload. Named restore points and the
revision timeline remain inside the expandable part of that card.
+8 -5
View File
@@ -121,7 +121,10 @@ Die Projektseite bindet diese APIs in einem einklappbaren Bereich
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte „Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen. Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
Nach einem Restore lädt die Seite sämtliche Projektdaten neu. Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
die Seite sämtliche Projektdaten neu.
`circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen `circuit-device-row.move` verschiebt oder sortiert eine oder mehrere Zeilen
zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue zwischen vorhandenen Stromkreisen derselben Liste. Erwartete und neue
Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch; Stromkreis-/Sortierpositionen machen Forward und Inverse deterministisch;
@@ -157,8 +160,9 @@ einschließlich ProjectDevice-Verknüpfung und lokaler Override-Metadaten. Damit
werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt werden stille Überschreibungen veralteter Zeilen verhindert und Undo/Redo stellt
exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und exakt die vorherigen lokalen Werte wieder her. Die Synchronisieren- und
Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit Trennen-Endpunkte sowie die Projektseite verwenden diesen Command mit
optimistischer Revisionsprüfung. Das dortige Rückgängig löst die persistente optimistischer Revisionsprüfung. Rückgängig/Wiederholen erfolgt dort
projektweite Historie aus; separate direkte Restore-/Reconnect-Schreibwege einheitlich über die persistente projektweite Historie; der frühere
sitzungslokale Spezial-Undo und separate direkte Restore-/Reconnect-Schreibwege
existieren nicht mehr. existieren nicht mehr.
`project-device.update` versioniert Änderungen an den kanonischen `project-device.update` versioniert Änderungen an den kanonischen
Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem Projektgerätefeldern unabhängig davon. Der Store erzeugt die Inverse aus dem
@@ -212,8 +216,7 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Noch nicht unterstützt ## Noch nicht unterstützt
- vollständiges persistentes Undo/Redo, sichtbare Historie nach Reload und - automatische periodische Projekt-Snapshots und Aufbewahrungsrichtlinie
benannte Projektstände
- Mehrbenutzerbetrieb und Konfliktauflösung - Mehrbenutzerbetrieb und Konfliktauflösung
- Revit-/CSV-/IFCGUID-Round-trip - Revit-/CSV-/IFCGUID-Round-trip
- vollständige elektrische Dimensionierung - vollständige elektrische Dimensionierung
@@ -373,6 +373,8 @@ Tasks:
persistent undo/redo persistent undo/redo
- [x] expose named snapshots, confirmed restore and the paginated revision - [x] expose named snapshots, confirmed restore and the paginated revision
timeline on the project page timeline on the project page
- [x] expose persistent project-wide undo/redo controls on the project page
and remove its session-local ProjectDevice synchronization undo state
- keep database backups separate from logical history - keep database backups separate from logical history
Implemented foundation: Implemented foundation:
@@ -401,6 +403,8 @@ Implemented foundation:
undo/redo stacks undo/redo stacks
- the project page lists, creates and explicitly restores named snapshots and - the project page lists, creates and explicitly restores named snapshots and
displays paginated revision metadata without exposing command payloads displays paginated revision metadata without exposing command payloads
- the version-card header reads persisted undo/redo eligibility and executes
optimistic project-wide undo/redo even while its timeline body is collapsed
- a central application dispatcher reconstructs persisted commands and exposes - a central application dispatcher reconstructs persisted commands and exposes
optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow optimistic command, undo and redo endpoints for Circuit and CircuitDeviceRow
field updates field updates
+1 -62
View File
@@ -21,7 +21,6 @@ import {
listProjects, listProjects,
listRooms, listRooms,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
undoProjectCommand,
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
} from "../../../frontend/utils/api"; } from "../../../frontend/utils/api";
@@ -56,11 +55,6 @@ const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
remark: "Bemerkung", remark: "Bemerkung",
}; };
type ProjectDeviceUndoState = {
projectDeviceId: string;
revisionNumber: number;
};
const emptyProjectDevice: CreateProjectDeviceInput = { const emptyProjectDevice: CreateProjectDeviceInput = {
name: "", name: "",
displayName: "", displayName: "",
@@ -117,7 +111,6 @@ export default function ProjectDetailPage() {
const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null); const [syncPreview, setSyncPreview] = useState<ProjectDeviceSyncPreviewDto | null>(null);
const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]); const [selectedSyncRowIds, setSelectedSyncRowIds] = useState<string[]>([]);
const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]); const [selectedSyncFields, setSelectedSyncFields] = useState<ProjectDeviceSyncField[]>([]);
const [projectDeviceUndo, setProjectDeviceUndo] = useState<ProjectDeviceUndoState | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
@@ -180,7 +173,6 @@ export default function ProjectDetailPage() {
setProject((current) => setProject((current) =>
current ? { ...current, currentRevision } : current current ? { ...current, currentRevision } : current
); );
setProjectDeviceUndo(null);
} }
async function handleCreateBoard(event: FormEvent<HTMLFormElement>) { async function handleCreateBoard(event: FormEvent<HTMLFormElement>) {
@@ -386,7 +378,6 @@ export default function ProjectDetailPage() {
return; return;
} }
try { try {
setProjectDeviceUndo(null);
const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId); const preview = await getProjectDeviceSyncPreview(projectId, projectDeviceId);
setSyncPreview(preview); setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)); setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
@@ -438,10 +429,6 @@ export default function ProjectDetailPage() {
setSelectedSyncRowIds( setSelectedSyncRowIds(
result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId) result.preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId)
); );
setProjectDeviceUndo({
projectDeviceId: syncPreview.projectDevice.id,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden."); setError(err instanceof Error ? err.message : "Gerätezeilen konnten nicht synchronisiert werden.");
} finally { } finally {
@@ -474,10 +461,6 @@ export default function ProjectDetailPage() {
.map((row) => row.rowId) .map((row) => row.rowId)
); );
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setProjectDeviceUndo({
projectDeviceId: syncPreview.projectDevice.id,
revisionNumber: result.revision.revisionNumber,
});
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden."); setError(err instanceof Error ? err.message : "Verknüpfungen konnten nicht getrennt werden.");
} finally { } finally {
@@ -485,40 +468,6 @@ export default function ProjectDetailPage() {
} }
} }
async function handleUndoProjectDeviceOperation() {
if (!projectId || !project || !projectDeviceUndo) {
return;
}
setIsSaving(true);
setError(null);
try {
if (
project.currentRevision !==
projectDeviceUndo.revisionNumber
) {
throw new Error(
"Eine neuere Projektänderung verhindert dieses Rückgängig."
);
}
const result = await undoProjectCommand(
projectId,
project.currentRevision
);
const preview = await getProjectDeviceSyncPreview(
projectId,
projectDeviceUndo.projectDeviceId
);
applyProjectRevision(result.history.currentRevision);
setSyncPreview(preview);
setSelectedSyncRowIds(preview.rows.filter((row) => row.differences.length > 0).map((row) => row.rowId));
setProjectDeviceUndo(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Die letzte Aktion konnte nicht rückgängig gemacht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyGlobalToProject() { async function handleCopyGlobalToProject() {
if (!projectId || !project || !selectedGlobalDeviceId) { if (!projectId || !project || !selectedGlobalDeviceId) {
return; return;
@@ -624,7 +573,7 @@ export default function ProjectDetailPage() {
<section className="col-12"> <section className="col-12">
<ProjectVersionHistory <ProjectVersionHistory
currentRevision={project.currentRevision} currentRevision={project.currentRevision}
onRestoreComplete={() => window.location.reload()} onProjectStateChange={() => window.location.reload()}
projectId={projectId} projectId={projectId}
/> />
</section> </section>
@@ -1163,16 +1112,6 @@ export default function ProjectDetailPage() {
</div> </div>
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">
{projectDeviceUndo ? (
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => void handleUndoProjectDeviceOperation()}
disabled={isSaving}
>
Letzte Aktion rückgängig
</button>
) : null}
<button <button
className="btn btn-primary" className="btn btn-primary"
type="button" type="button"
@@ -7,15 +7,19 @@ import React, {
useState, useState,
} from "react"; } from "react";
import type { import type {
ProjectHistoryStateDto,
ProjectRevisionPageDto, ProjectRevisionPageDto,
ProjectRevisionSummaryDto, ProjectRevisionSummaryDto,
ProjectSnapshotMetadataDto, ProjectSnapshotMetadataDto,
} from "../types"; } from "../types";
import { import {
createNamedProjectSnapshot, createNamedProjectSnapshot,
getProjectHistory,
listProjectRevisions, listProjectRevisions,
listProjectSnapshots, listProjectSnapshots,
redoProjectCommand,
restoreProjectSnapshot, restoreProjectSnapshot,
undoProjectCommand,
} from "../utils/api"; } from "../utils/api";
import { import {
getProjectRevisionDescription, getProjectRevisionDescription,
@@ -26,7 +30,7 @@ import {
interface ProjectVersionHistoryProps { interface ProjectVersionHistoryProps {
projectId: string; projectId: string;
currentRevision: number; currentRevision: number;
onRestoreComplete: () => void; onProjectStateChange: () => void;
} }
const revisionPageSize = 10; const revisionPageSize = 10;
@@ -34,8 +38,10 @@ const revisionPageSize = 10;
export function ProjectVersionHistory({ export function ProjectVersionHistory({
projectId, projectId,
currentRevision, currentRevision,
onRestoreComplete, onProjectStateChange,
}: ProjectVersionHistoryProps) { }: ProjectVersionHistoryProps) {
const [history, setHistory] =
useState<ProjectHistoryStateDto | null>(null);
const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>( const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>(
[] []
); );
@@ -51,9 +57,26 @@ export function ProjectVersionHistory({
useState<ProjectSnapshotMetadataDto | null>(null); useState<ProjectSnapshotMetadataDto | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isHistoryLoading, setIsHistoryLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const loadHistory = useCallback(async () => {
setIsHistoryLoading(true);
try {
setHistory(await getProjectHistory(projectId));
} catch (loadError) {
setError(
getVersionHistoryError(
loadError,
"Rückgängig-Status konnte nicht geladen werden."
)
);
} finally {
setIsHistoryLoading(false);
}
}, [projectId]);
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -77,6 +100,10 @@ export function ProjectVersionHistory({
} }
}, [projectId]); }, [projectId]);
useEffect(() => {
void loadHistory();
}, [currentRevision, loadHistory]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void loadOverview(); void loadOverview();
@@ -157,7 +184,7 @@ export function ProjectVersionHistory({
); );
setRestoreCandidate(null); setRestoreCandidate(null);
setIsBusy(false); setIsBusy(false);
onRestoreComplete(); onProjectStateChange();
} catch (restoreError) { } catch (restoreError) {
setError( setError(
getVersionHistoryError( getVersionHistoryError(
@@ -170,6 +197,33 @@ export function ProjectVersionHistory({
} }
} }
async function handleHistoryCommand(direction: "undo" | "redo") {
setIsBusy(true);
setError(null);
try {
const result =
direction === "undo"
? await undoProjectCommand(projectId, currentRevision)
: await redoProjectCommand(projectId, currentRevision);
setHistory(result.history);
onProjectStateChange();
} catch (historyError) {
setError(
getVersionHistoryError(
historyError,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
} finally {
setIsBusy(false);
}
}
const historyIsCurrent =
history?.currentRevision === currentRevision;
return ( return (
<section className="card shadow-sm"> <section className="card shadow-sm">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2"> <div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
@@ -178,6 +232,42 @@ export function ProjectVersionHistory({
<span className="badge text-bg-secondary"> <span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision} Aktuelle Revision {currentRevision}
</span> </span>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
onClick={() => void handleHistoryCommand("undo")}
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
onClick={() => void handleHistoryCommand("redo")}
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
type="button"
>
Wiederholen
</button>
<button <button
aria-expanded={isOpen} aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary" className="btn btn-sm btn-outline-secondary"
@@ -188,6 +278,11 @@ export function ProjectVersionHistory({
</button> </button>
</div> </div>
</div> </div>
{error ? (
<div className="alert alert-warning m-3 mb-0" role="alert">
{error}
</div>
) : null}
{isOpen ? ( {isOpen ? (
<> <>
<div className="card-body border-bottom"> <div className="card-body border-bottom">
@@ -237,12 +332,6 @@ export function ProjectVersionHistory({
</form> </form>
</div> </div>
{error ? (
<div className="alert alert-warning m-3 mb-0" role="alert">
{error}
</div>
) : null}
<div className="card-body border-bottom"> <div className="card-body border-bottom">
<h2 className="h6">Gespeicherte Sicherungspunkte</h2> <h2 className="h6">Gespeicherte Sicherungspunkte</h2>
{isLoading ? ( {isLoading ? (
+24 -1
View File
@@ -6,9 +6,12 @@ import { ProjectVersionHistory } from "../src/frontend/components/project-versio
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js"; import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
import { import {
createNamedProjectSnapshot, createNamedProjectSnapshot,
getProjectHistory,
listProjectRevisions, listProjectRevisions,
listProjectSnapshots, listProjectSnapshots,
redoProjectCommand,
restoreProjectSnapshot, restoreProjectSnapshot,
undoProjectCommand,
} from "../src/frontend/utils/api.js"; } from "../src/frontend/utils/api.js";
import { import {
getProjectRevisionDescription, getProjectRevisionDescription,
@@ -40,11 +43,13 @@ describe("project version history presentation", () => {
createElement(ProjectVersionHistory, { createElement(ProjectVersionHistory, {
projectId: "project-1", projectId: "project-1",
currentRevision: 42, currentRevision: 42,
onRestoreComplete: () => undefined, onProjectStateChange: () => undefined,
}) })
); );
assert.match(markup, /Versionen und Sicherungspunkte/); assert.match(markup, /Versionen und Sicherungspunkte/);
assert.match(markup, /Aktuelle Revision 42/); assert.match(markup, /Aktuelle Revision 42/);
assert.match(markup, /Rückgängig/);
assert.match(markup, /Wiederholen/);
assert.match(markup, /Anzeigen/); assert.match(markup, /Anzeigen/);
assert.doesNotMatch(markup, /Stand speichern/); assert.doesNotMatch(markup, /Stand speichern/);
}); });
@@ -108,6 +113,7 @@ describe("project version history API", () => {
}; };
try { try {
await getProjectHistory("project-1");
await listProjectRevisions("project-1", { await listProjectRevisions("project-1", {
limit: 10, limit: 10,
beforeRevision: 21, beforeRevision: 21,
@@ -120,11 +126,18 @@ describe("project version history API", () => {
"Vor Ausführung" "Vor Ausführung"
); );
await restoreProjectSnapshot("project-1", "snapshot-1", 22); await restoreProjectSnapshot("project-1", "snapshot-1", 22);
await undoProjectCommand("project-1", 23);
await redoProjectCommand("project-1", 24);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
assert.deepEqual(requests, [ assert.deepEqual(requests, [
{
url: "/api/projects/project-1/history",
method: "GET",
body: null,
},
{ {
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21", url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
method: "GET", method: "GET",
@@ -149,6 +162,16 @@ describe("project version history API", () => {
method: "POST", method: "POST",
body: { expectedRevision: 22 }, body: { expectedRevision: 22 },
}, },
{
url: "/api/projects/project-1/history/undo",
method: "POST",
body: { expectedRevision: 23 },
},
{
url: "/api/projects/project-1/history/redo",
method: "POST",
body: { expectedRevision: 24 },
},
]); ]);
}); });
}); });