Expose project version history
This commit is contained in:
@@ -237,6 +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
|
||||||
|
with explicit restore 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,
|
||||||
@@ -308,7 +311,7 @@ Users must be able to override sizing suggestions.
|
|||||||
|
|
||||||
## Current Deferred Work
|
## Current Deferred Work
|
||||||
|
|
||||||
- snapshot UI and periodic snapshot policy
|
- periodic snapshot and retention policy
|
||||||
- Revit/CSV/IFCGUID round-trip
|
- Revit/CSV/IFCGUID round-trip
|
||||||
- full electrical sizing
|
- full electrical sizing
|
||||||
- multi-user/PostgreSQL operation
|
- multi-user/PostgreSQL operation
|
||||||
|
|||||||
@@ -143,5 +143,5 @@ Covered operations include:
|
|||||||
- apply sorted order
|
- apply sorted order
|
||||||
|
|
||||||
The toolbar currently exposes availability through its Undo/Redo buttons.
|
The toolbar currently exposes availability through its Undo/Redo buttons.
|
||||||
Named restore points can be created, listed and restored through the API. A
|
Named restore points and the revision timeline are available on the project
|
||||||
browsable revision/snapshot UI remains future work.
|
page; the circuit editor keeps only the compact Undo/Redo controls.
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
persistent project commands and project-wide toolbar undo/redo, including
|
persistent project commands and project-wide toolbar undo/redo, including
|
||||||
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
moves, atomic multi-section reorders and explicit renumbering. Undo/redo
|
||||||
eligibility is restored from server history after a page reload.
|
eligibility is restored from server history after a page reload.
|
||||||
- A paginated revision-metadata API exists, but there is no browsable timeline
|
- The project page exposes the paginated revision timeline; the circuit editor
|
||||||
UI yet; the editor currently exposes only the next eligible Undo/Redo
|
itself currently exposes only the next eligible Undo/Redo actions.
|
||||||
actions.
|
|
||||||
- Named logical project snapshots can be created, listed and restored through
|
- Named logical project snapshots can be created, listed and restored through
|
||||||
the API with persistent Undo/Redo. Snapshot UI and periodic snapshots are
|
the project page with persistent Undo/Redo. Automatic periodic snapshots are
|
||||||
not implemented yet.
|
not implemented yet.
|
||||||
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
- No Revit/CSV/IFCGUID import and export workflow is implemented yet.
|
||||||
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
- Persistence currently targets local SQLite; PostgreSQL is an architectural option, not an implemented runtime.
|
||||||
|
|||||||
@@ -117,6 +117,11 @@ ein vollständiges inverses Kommando. Undo und Redo können deshalb auch einen
|
|||||||
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
Restore nach einem Neustart exakt zurücknehmen oder wiederholen. Kompatible
|
||||||
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
||||||
nicht Bestandteil des logischen Snapshots sind.
|
nicht Bestandteil des logischen Snapshots sind.
|
||||||
|
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||||
|
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||||
|
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
|
||||||
|
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
||||||
|
Nach 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;
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ Implemented named-snapshot foundation:
|
|||||||
separately and are never imported into the logical snapshot payload
|
separately and are never imported into the logical snapshot payload
|
||||||
- arbitrary restore payloads are rejected by the generic user-command
|
- arbitrary restore payloads are rejected by the generic user-command
|
||||||
endpoint; only server-stored snapshots can initiate a forward restore
|
endpoint; only server-stored snapshots can initiate a forward restore
|
||||||
|
- the project page provides an initially collapsed snapshot/timeline panel,
|
||||||
|
explicit inline restore confirmation and cursor-based loading of older
|
||||||
|
revisions; a successful restore reloads all project read models
|
||||||
|
|
||||||
## Revit / CSV / IFCGUID Round-Trip Direction
|
## Revit / CSV / IFCGUID Round-Trip Direction
|
||||||
|
|
||||||
@@ -252,7 +255,7 @@ Completed foundation:
|
|||||||
|
|
||||||
Remaining constraints before completing project version history:
|
Remaining constraints before completing project version history:
|
||||||
|
|
||||||
- add a browsable timeline/snapshot UI and periodic snapshot policy
|
- define and implement a periodic snapshot and retention policy
|
||||||
- extend project-scoped commands only when further project mutations are
|
- extend project-scoped commands only when further project mutations are
|
||||||
deliberately added to history; the generic versioned envelope is complete
|
deliberately added to history; the generic versioned envelope is complete
|
||||||
- do not model IFCGUID as an overloaded circuit equipment identifier
|
- do not model IFCGUID as an overloaded circuit equipment identifier
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ Tasks:
|
|||||||
- add periodic logical project snapshots
|
- add periodic logical project snapshots
|
||||||
- [x] restore a historical snapshot as a new auditable revision with
|
- [x] restore a historical snapshot as a new auditable revision with
|
||||||
persistent undo/redo
|
persistent undo/redo
|
||||||
|
- [x] expose named snapshots, confirmed restore and the paginated revision
|
||||||
|
timeline on the project page
|
||||||
- keep database backups separate from logical history
|
- keep database backups separate from logical history
|
||||||
|
|
||||||
Implemented foundation:
|
Implemented foundation:
|
||||||
@@ -397,6 +399,8 @@ Implemented foundation:
|
|||||||
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
- named snapshot Create/List endpoints persist complete project-scoped runtime
|
||||||
state with the source revision and SHA-256 without changing revision or
|
state with the source revision and SHA-256 without changing revision or
|
||||||
undo/redo stacks
|
undo/redo stacks
|
||||||
|
- the project page lists, creates and explicitly restores named snapshots and
|
||||||
|
displays paginated revision metadata without exposing command payloads
|
||||||
- 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
|
||||||
|
|||||||
+2
-2
@@ -14,8 +14,8 @@
|
|||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.repository.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
projectDeviceSyncFields,
|
projectDeviceSyncFields,
|
||||||
type ProjectDeviceSyncField,
|
type ProjectDeviceSyncField,
|
||||||
} from "../../../shared/constants/project-device-sync-fields";
|
} from "../../../shared/constants/project-device-sync-fields";
|
||||||
|
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
|
||||||
|
|
||||||
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
|
||||||
name: "Technischer Name",
|
name: "Technischer Name",
|
||||||
@@ -608,6 +609,16 @@ export default function ProjectDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{project ? (
|
||||||
|
<section className="col-12">
|
||||||
|
<ProjectVersionHistory
|
||||||
|
currentRevision={project.currentRevision}
|
||||||
|
onRestoreComplete={() => window.location.reload()}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="col-12 col-lg-4">
|
<section className="col-12 col-lg-4">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm">
|
||||||
<div className="card-header">Neue Verteilung</div>
|
<div className="card-header">Neue Verteilung</div>
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type {
|
||||||
|
ProjectRevisionPageDto,
|
||||||
|
ProjectRevisionSummaryDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshot,
|
||||||
|
listProjectRevisions,
|
||||||
|
listProjectSnapshots,
|
||||||
|
restoreProjectSnapshot,
|
||||||
|
} from "../utils/api";
|
||||||
|
import {
|
||||||
|
getProjectRevisionDescription,
|
||||||
|
getProjectRevisionSourceLabel,
|
||||||
|
mergeProjectRevisionPages,
|
||||||
|
} from "../utils/project-version-history";
|
||||||
|
|
||||||
|
interface ProjectVersionHistoryProps {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
onRestoreComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionPageSize = 10;
|
||||||
|
|
||||||
|
export function ProjectVersionHistory({
|
||||||
|
projectId,
|
||||||
|
currentRevision,
|
||||||
|
onRestoreComplete,
|
||||||
|
}: ProjectVersionHistoryProps) {
|
||||||
|
const [snapshots, setSnapshots] = useState<ProjectSnapshotMetadataDto[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [revisions, setRevisions] = useState<ProjectRevisionSummaryDto[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [nextBeforeRevision, setNextBeforeRevision] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
const [snapshotName, setSnapshotName] = useState("");
|
||||||
|
const [snapshotDescription, setSnapshotDescription] = useState("");
|
||||||
|
const [restoreCandidate, setRestoreCandidate] =
|
||||||
|
useState<ProjectSnapshotMetadataDto | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const [loadedSnapshots, revisionPage] = await Promise.all([
|
||||||
|
listProjectSnapshots(projectId),
|
||||||
|
listProjectRevisions(projectId, { limit: revisionPageSize }),
|
||||||
|
]);
|
||||||
|
setSnapshots(loadedSnapshots);
|
||||||
|
setRevisions(revisionPage.revisions);
|
||||||
|
setNextBeforeRevision(revisionPage.nextBeforeRevision);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
loadError,
|
||||||
|
"Versionshistorie konnte nicht geladen werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadOverview();
|
||||||
|
}
|
||||||
|
}, [loadOverview, currentRevision, isOpen]);
|
||||||
|
|
||||||
|
async function handleCreateSnapshot(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = snapshotName.trim();
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const created = await createNamedProjectSnapshot(
|
||||||
|
projectId,
|
||||||
|
currentRevision,
|
||||||
|
name,
|
||||||
|
snapshotDescription.trim() || undefined
|
||||||
|
);
|
||||||
|
setSnapshots((current) => [created, ...current]);
|
||||||
|
setSnapshotName("");
|
||||||
|
setSnapshotDescription("");
|
||||||
|
} catch (createError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
createError,
|
||||||
|
"Projektstand konnte nicht gespeichert werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoadOlderRevisions() {
|
||||||
|
if (nextBeforeRevision === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const page: ProjectRevisionPageDto = await listProjectRevisions(
|
||||||
|
projectId,
|
||||||
|
{
|
||||||
|
limit: revisionPageSize,
|
||||||
|
beforeRevision: nextBeforeRevision,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRevisions((current) =>
|
||||||
|
mergeProjectRevisionPages(current, page.revisions)
|
||||||
|
);
|
||||||
|
setNextBeforeRevision(page.nextBeforeRevision);
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
loadError,
|
||||||
|
"Ältere Revisionen konnten nicht geladen werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestoreSnapshot() {
|
||||||
|
if (!restoreCandidate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await restoreProjectSnapshot(
|
||||||
|
projectId,
|
||||||
|
restoreCandidate.id,
|
||||||
|
currentRevision
|
||||||
|
);
|
||||||
|
setRestoreCandidate(null);
|
||||||
|
setIsBusy(false);
|
||||||
|
onRestoreComplete();
|
||||||
|
} catch (restoreError) {
|
||||||
|
setError(
|
||||||
|
getVersionHistoryError(
|
||||||
|
restoreError,
|
||||||
|
"Projektstand konnte nicht wiederhergestellt werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setRestoreCandidate(null);
|
||||||
|
setIsBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<span>Versionen und Sicherungspunkte</span>
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="badge text-bg-secondary">
|
||||||
|
Aktuelle Revision {currentRevision}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
onClick={() => setIsOpen((current) => !current)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isOpen ? "Ausblenden" : "Anzeigen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<>
|
||||||
|
<div className="card-body border-bottom">
|
||||||
|
<h2 className="h6">Neuen Sicherungspunkt anlegen</h2>
|
||||||
|
<p className="small text-secondary">
|
||||||
|
Ein Sicherungspunkt speichert den vollständigen Projektstand, ohne
|
||||||
|
die Revision oder Rückgängig-Historie zu verändern.
|
||||||
|
</p>
|
||||||
|
<form className="row g-2" onSubmit={handleCreateSnapshot}>
|
||||||
|
<div className="col-12 col-lg-4">
|
||||||
|
<label className="form-label" htmlFor="snapshot-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="snapshot-name"
|
||||||
|
maxLength={100}
|
||||||
|
placeholder="z. B. Vor Planfreigabe"
|
||||||
|
value={snapshotName}
|
||||||
|
onChange={(event) => setSnapshotName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-lg-6">
|
||||||
|
<label className="form-label" htmlFor="snapshot-description">
|
||||||
|
Beschreibung (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="snapshot-description"
|
||||||
|
maxLength={500}
|
||||||
|
placeholder="Was kennzeichnet diesen Stand?"
|
||||||
|
value={snapshotDescription}
|
||||||
|
onChange={(event) =>
|
||||||
|
setSnapshotDescription(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-lg-2 d-flex align-items-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={isBusy || !snapshotName.trim()}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Stand speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning m-3 mb-0" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="card-body border-bottom">
|
||||||
|
<h2 className="h6">Gespeicherte Sicherungspunkte</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary mb-0">Sicherungspunkte werden geladen …</p>
|
||||||
|
) : snapshots.length ? (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Projektstand</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th className="text-end">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{snapshots.map((snapshot) => (
|
||||||
|
<tr key={snapshot.id}>
|
||||||
|
<td>
|
||||||
|
<strong>{snapshot.name}</strong>
|
||||||
|
{snapshot.description ? (
|
||||||
|
<div className="small text-secondary">
|
||||||
|
{snapshot.description}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>Revision {snapshot.sourceRevision}</td>
|
||||||
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-danger"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setRestoreCandidate(snapshot)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Noch keine Sicherungspunkte vorhanden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restoreCandidate ? (
|
||||||
|
<div className="alert alert-danger mt-3 mb-0" role="alert">
|
||||||
|
<h3 className="h6">
|
||||||
|
„{restoreCandidate.name}“ wirklich wiederherstellen?
|
||||||
|
</h3>
|
||||||
|
<p className="mb-2">
|
||||||
|
Verteiler, Stromkreise, Projektgeräte, Räume und
|
||||||
|
Projekteinstellungen werden auf Revision{" "}
|
||||||
|
{restoreCandidate.sourceRevision} zurückgesetzt. Der aktuelle
|
||||||
|
Stand bleibt als neue rückgängig machbare Revision erhalten.
|
||||||
|
</p>
|
||||||
|
<div className="d-flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleRestoreSnapshot()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Jetzt wiederherstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setRestoreCandidate(null)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="h6">Letzte Änderungen</h2>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary mb-0">Historie wird geladen …</p>
|
||||||
|
) : revisions.length ? (
|
||||||
|
<>
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-sm align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Revision</th>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Art</th>
|
||||||
|
<th>Änderung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{revisions.map((revision) => (
|
||||||
|
<tr key={revision.revisionId}>
|
||||||
|
<td>{revision.revisionNumber}</td>
|
||||||
|
<td>{formatDateTime(revision.createdAtIso)}</td>
|
||||||
|
<td>
|
||||||
|
{getProjectRevisionSourceLabel(revision.source)}
|
||||||
|
</td>
|
||||||
|
<td>{getProjectRevisionDescription(revision)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{nextBeforeRevision !== null ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-secondary mt-3"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleLoadOlderRevisions()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Ältere Änderungen laden
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Für dieses Projekt wurden noch keine versionierten Änderungen
|
||||||
|
gespeichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVersionHistoryError(error: unknown, fallback: string) {
|
||||||
|
const details = error instanceof Error ? error.message : "";
|
||||||
|
if (details.includes("PROJECT_SNAPSHOT_NAME_CONFLICT")) {
|
||||||
|
return "Ein Sicherungspunkt mit diesem Namen ist bereits vorhanden.";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
details.includes("PROJECT_REVISION_CONFLICT") ||
|
||||||
|
details.includes("PROJECT_STATE_CONFLICT")
|
||||||
|
) {
|
||||||
|
return "Das Projekt wurde inzwischen geändert. Bitte lade die Seite neu und versuche es erneut.";
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
@@ -15,6 +15,49 @@ export interface ProjectHistoryStateDto {
|
|||||||
redoChangeSetId: string | null;
|
redoChangeSetId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectRevisionSourceDto =
|
||||||
|
| "user"
|
||||||
|
| "undo"
|
||||||
|
| "redo"
|
||||||
|
| "restore"
|
||||||
|
| "migration";
|
||||||
|
|
||||||
|
export interface ProjectRevisionSummaryDto {
|
||||||
|
revisionId: string;
|
||||||
|
changeSetId: string;
|
||||||
|
revisionNumber: number;
|
||||||
|
createdAtIso: string;
|
||||||
|
actorId: string | null;
|
||||||
|
source: ProjectRevisionSourceDto;
|
||||||
|
description: string | null;
|
||||||
|
commandType: string;
|
||||||
|
payloadSchemaVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRevisionPageDto {
|
||||||
|
projectId: string;
|
||||||
|
currentRevision: number;
|
||||||
|
revisions: ProjectRevisionSummaryDto[];
|
||||||
|
nextBeforeRevision: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSnapshotMetadataDto {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
sourceRevision: number;
|
||||||
|
schemaVersion: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
payloadSha256: string;
|
||||||
|
createdAtIso: string;
|
||||||
|
createdByActorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSnapshotRestoreResultDto
|
||||||
|
extends ProjectCommandResultDto {
|
||||||
|
snapshot: ProjectSnapshotMetadataDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectCommandDto {
|
export interface ProjectCommandDto {
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
type: string;
|
type: string;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import type {
|
|||||||
ProjectDeviceCommandResultDto,
|
ProjectDeviceCommandResultDto,
|
||||||
ProjectDeviceDto,
|
ProjectDeviceDto,
|
||||||
ProjectHistoryStateDto,
|
ProjectHistoryStateDto,
|
||||||
|
ProjectRevisionPageDto,
|
||||||
|
ProjectSnapshotMetadataDto,
|
||||||
|
ProjectSnapshotRestoreResultDto,
|
||||||
ProjectDeviceSyncCommandResultDto,
|
ProjectDeviceSyncCommandResultDto,
|
||||||
ProjectDeviceSyncPreviewDto,
|
ProjectDeviceSyncPreviewDto,
|
||||||
ProjectDto,
|
ProjectDto,
|
||||||
@@ -82,6 +85,62 @@ export function getProjectHistory(projectId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listProjectRevisions(
|
||||||
|
projectId: string,
|
||||||
|
input: { limit?: number; beforeRevision?: number } = {}
|
||||||
|
) {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (input.limit !== undefined) {
|
||||||
|
query.set("limit", String(input.limit));
|
||||||
|
}
|
||||||
|
if (input.beforeRevision !== undefined) {
|
||||||
|
query.set("beforeRevision", String(input.beforeRevision));
|
||||||
|
}
|
||||||
|
const suffix = query.size ? `?${query.toString()}` : "";
|
||||||
|
return request<ProjectRevisionPageDto>(
|
||||||
|
`/api/projects/${projectId}/history/revisions${suffix}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listProjectSnapshots(projectId: string) {
|
||||||
|
return request<ProjectSnapshotMetadataDto[]>(
|
||||||
|
`/api/projects/${projectId}/snapshots`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNamedProjectSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
expectedRevision: number,
|
||||||
|
name: string,
|
||||||
|
description?: string
|
||||||
|
) {
|
||||||
|
return request<ProjectSnapshotMetadataDto>(
|
||||||
|
`/api/projects/${projectId}/snapshots`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
expectedRevision,
|
||||||
|
name,
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreProjectSnapshot(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string,
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<ProjectSnapshotRestoreResultDto>(
|
||||||
|
`/api/projects/${projectId}/snapshots/${snapshotId}/restore`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function executeProjectCommand(
|
export function executeProjectCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
ProjectRevisionSourceDto,
|
||||||
|
ProjectRevisionSummaryDto,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const sourceLabels: Record<ProjectRevisionSourceDto, string> = {
|
||||||
|
user: "Bearbeitung",
|
||||||
|
undo: "Rückgängig",
|
||||||
|
redo: "Wiederholt",
|
||||||
|
restore: "Wiederherstellung",
|
||||||
|
migration: "Migration",
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandTypeLabels: Record<string, string> = {
|
||||||
|
"circuit.update": "Stromkreis bearbeitet",
|
||||||
|
"circuit.insert": "Stromkreis angelegt",
|
||||||
|
"circuit.delete": "Stromkreis gelöscht",
|
||||||
|
"circuit-device-row.update": "Gerätezeile bearbeitet",
|
||||||
|
"circuit-device-row.insert": "Gerätezeile angelegt",
|
||||||
|
"circuit-device-row.delete": "Gerätezeile gelöscht",
|
||||||
|
"circuit-device-row.move": "Gerätezeile verschoben",
|
||||||
|
"circuit-device-row.move-with-new-circuit":
|
||||||
|
"Gerätezeile in neuen Stromkreis verschoben",
|
||||||
|
"circuit.reorder-section": "Stromkreise sortiert",
|
||||||
|
"circuit.reorder-sections": "Stromkreise bereichsübergreifend sortiert",
|
||||||
|
"circuit.renumber-section": "Bereich neu nummeriert",
|
||||||
|
"project-device.update": "Projektgerät bearbeitet",
|
||||||
|
"project-device.insert": "Projektgerät angelegt",
|
||||||
|
"project-device.delete": "Projektgerät gelöscht",
|
||||||
|
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
||||||
|
"project.restore-state": "Projektstand wiederhergestellt",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getProjectRevisionSourceLabel(
|
||||||
|
source: ProjectRevisionSourceDto
|
||||||
|
) {
|
||||||
|
return sourceLabels[source];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectRevisionDescription(
|
||||||
|
revision: ProjectRevisionSummaryDto
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
revision.description?.trim() ||
|
||||||
|
commandTypeLabels[revision.commandType] ||
|
||||||
|
revision.commandType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeProjectRevisionPages(
|
||||||
|
current: ProjectRevisionSummaryDto[],
|
||||||
|
next: ProjectRevisionSummaryDto[]
|
||||||
|
) {
|
||||||
|
const byId = new Map(
|
||||||
|
[...current, ...next].map((revision) => [
|
||||||
|
revision.revisionId,
|
||||||
|
revision,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return [...byId.values()].sort(
|
||||||
|
(left, right) => right.revisionNumber - left.revisionNumber
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { ProjectVersionHistory } from "../src/frontend/components/project-version-history.js";
|
||||||
|
import type { ProjectRevisionSummaryDto } from "../src/frontend/types.js";
|
||||||
|
import {
|
||||||
|
createNamedProjectSnapshot,
|
||||||
|
listProjectRevisions,
|
||||||
|
listProjectSnapshots,
|
||||||
|
restoreProjectSnapshot,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
import {
|
||||||
|
getProjectRevisionDescription,
|
||||||
|
getProjectRevisionSourceLabel,
|
||||||
|
mergeProjectRevisionPages,
|
||||||
|
} from "../src/frontend/utils/project-version-history.js";
|
||||||
|
|
||||||
|
function revision(
|
||||||
|
revisionNumber: number,
|
||||||
|
overrides: Partial<ProjectRevisionSummaryDto> = {}
|
||||||
|
): ProjectRevisionSummaryDto {
|
||||||
|
return {
|
||||||
|
revisionId: `revision-${revisionNumber}`,
|
||||||
|
changeSetId: `change-${revisionNumber}`,
|
||||||
|
revisionNumber,
|
||||||
|
createdAtIso: "2026-07-25T12:00:00.000Z",
|
||||||
|
actorId: null,
|
||||||
|
source: "user",
|
||||||
|
description: null,
|
||||||
|
commandType: "circuit.update",
|
||||||
|
payloadSchemaVersion: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("project version history presentation", () => {
|
||||||
|
it("renders a compact collapsed German entry point", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
createElement(ProjectVersionHistory, {
|
||||||
|
projectId: "project-1",
|
||||||
|
currentRevision: 42,
|
||||||
|
onRestoreComplete: () => undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.match(markup, /Versionen und Sicherungspunkte/);
|
||||||
|
assert.match(markup, /Aktuelle Revision 42/);
|
||||||
|
assert.match(markup, /Anzeigen/);
|
||||||
|
assert.doesNotMatch(markup, /Stand speichern/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses German source and command descriptions", () => {
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("user"), "Bearbeitung");
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("undo"), "Rückgängig");
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("redo"), "Wiederholt");
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionSourceLabel("restore"),
|
||||||
|
"Wiederherstellung"
|
||||||
|
);
|
||||||
|
assert.equal(getProjectRevisionSourceLabel("migration"), "Migration");
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(revision(1)),
|
||||||
|
"Stromkreis bearbeitet"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(2, { description: " Eigene Beschreibung " })
|
||||||
|
),
|
||||||
|
"Eigene Beschreibung"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getProjectRevisionDescription(
|
||||||
|
revision(3, { commandType: "future.command" })
|
||||||
|
),
|
||||||
|
"future.command"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges paginated revisions without duplicates in descending order", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeProjectRevisionPages(
|
||||||
|
[revision(5), revision(4)],
|
||||||
|
[revision(4), revision(3)]
|
||||||
|
).map((entry) => entry.revisionNumber),
|
||||||
|
[5, 4, 3]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project version history API", () => {
|
||||||
|
it("uses the revision-safe timeline and snapshot routes", async () => {
|
||||||
|
const requests: Array<{
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
body: unknown;
|
||||||
|
}> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listProjectRevisions("project-1", {
|
||||||
|
limit: 10,
|
||||||
|
beforeRevision: 21,
|
||||||
|
});
|
||||||
|
await listProjectSnapshots("project-1");
|
||||||
|
await createNamedProjectSnapshot(
|
||||||
|
"project-1",
|
||||||
|
22,
|
||||||
|
"Planfreigabe",
|
||||||
|
"Vor Ausführung"
|
||||||
|
);
|
||||||
|
await restoreProjectSnapshot("project-1", "snapshot-1", 22);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/history/revisions?limit=10&beforeRevision=21",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots",
|
||||||
|
method: "GET",
|
||||||
|
body: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots",
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
expectedRevision: 22,
|
||||||
|
name: "Planfreigabe",
|
||||||
|
description: "Vor Ausführung",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/snapshots/snapshot-1/restore",
|
||||||
|
method: "POST",
|
||||||
|
body: { expectedRevision: 22 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user