Add project overview import
This commit is contained in:
@@ -246,7 +246,12 @@ rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
|||||||
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||||
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||||
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben erhalten.
|
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||||
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
|
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
||||||
|
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||||
|
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||||
|
projektgebundenen Endpunkt beschränkt.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
|
|||||||
@@ -5,18 +5,6 @@ requirements and intended sequencing, not proof of implementation.
|
|||||||
|
|
||||||
## Circuit List Editor
|
## Circuit List Editor
|
||||||
|
|
||||||
- Open the ProjectDevice drawer on the right so it does not cover the leading
|
|
||||||
circuit identifier and display-name columns.
|
|
||||||
- Format displayed power values in kW with at most three decimal places.
|
|
||||||
- Format displayed factors and percentage-like values with at most two decimal
|
|
||||||
places.
|
|
||||||
- Do not round persisted values; formatting applies only to the displayed
|
|
||||||
value outside edit mode.
|
|
||||||
- Replace the separate `Zeilensumme` and `Stromkreissumme` columns with one
|
|
||||||
`Gesamtsumme` column:
|
|
||||||
- device rows show their device-row total;
|
|
||||||
- circuit summary rows show the complete circuit total;
|
|
||||||
- compact single-device rows show the identical device/circuit total once.
|
|
||||||
- Collect the remaining editor interaction issues before changing additional
|
- Collect the remaining editor interaction issues before changing additional
|
||||||
behavior.
|
behavior.
|
||||||
|
|
||||||
@@ -53,20 +41,11 @@ requirements and intended sequencing, not proof of implementation.
|
|||||||
exceptional voltage is not silently overwritten when phase type or project
|
exceptional voltage is not silently overwritten when phase type or project
|
||||||
settings change.
|
settings change.
|
||||||
|
|
||||||
## Project Overview Import
|
|
||||||
|
|
||||||
- Add JSON project import to the project overview so a portable project file
|
|
||||||
can create a new project without first opening another project.
|
|
||||||
- Keep replacement of an existing project inside that project's settings.
|
|
||||||
- Validate format version and checksum before creating project data.
|
|
||||||
|
|
||||||
## Recommended Sequence
|
## Recommended Sequence
|
||||||
|
|
||||||
1. Circuit-list presentation improvements.
|
1. Distribution-board floor and supply fields.
|
||||||
2. Project-overview import as a new project.
|
2. Device voltage default and override semantics.
|
||||||
3. Distribution-board floor and supply fields.
|
3. Snapshot-to-revision descriptions and history presentation.
|
||||||
4. Device voltage default and override semantics.
|
|
||||||
5. Snapshot-to-revision descriptions and history presentation.
|
|
||||||
|
|
||||||
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||||
must not be inferred from these tasks.
|
must not be inferred from these tasks.
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@
|
|||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||||
"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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.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.persistence.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-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.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.persistence.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-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.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.persistence.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-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.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.service.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-structure-project-command.repository.test.ts tests/project-location-structure-project-command.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.persistence.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-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.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",
|
||||||
|
|||||||
+109
-1
@@ -6,6 +6,7 @@ import {
|
|||||||
createGlobalDevice,
|
createGlobalDevice,
|
||||||
createProject,
|
createProject,
|
||||||
deleteGlobalDevice,
|
deleteGlobalDevice,
|
||||||
|
importProjectTransferAsNew,
|
||||||
listGlobalDevices,
|
listGlobalDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
updateGlobalDevice,
|
updateGlobalDevice,
|
||||||
@@ -55,6 +56,16 @@ export default function ProjectsPage() {
|
|||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [projectImport, setProjectImport] = useState<unknown>(null);
|
||||||
|
const [projectImportFilename, setProjectImportFilename] = useState("");
|
||||||
|
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
|
||||||
|
const [projectImportFileError, setProjectImportFileError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [importedProject, setImportedProject] = useState<{
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -87,6 +98,49 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleProjectImportFile(file: File | undefined) {
|
||||||
|
setProjectImport(null);
|
||||||
|
setProjectImportFilename("");
|
||||||
|
setProjectImportFileError(null);
|
||||||
|
setImportedProject(null);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setProjectImport(JSON.parse(await file.text()) as unknown);
|
||||||
|
setProjectImportFilename(file.name);
|
||||||
|
} catch {
|
||||||
|
setProjectImportFileError(
|
||||||
|
"Die ausgewählte Datei enthält kein gültiges JSON."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportProject() {
|
||||||
|
if (projectImport === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setImportedProject(null);
|
||||||
|
try {
|
||||||
|
const imported = await importProjectTransferAsNew(projectImport);
|
||||||
|
setProjects(await listProjects());
|
||||||
|
setImportedProject(imported);
|
||||||
|
setProjectImport(null);
|
||||||
|
setProjectImportFilename("");
|
||||||
|
setProjectImportInputKey((current) => current + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Projektdatei konnte nicht importiert werden."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!globalDeviceForm.name.trim()) {
|
if (!globalDeviceForm.name.trim()) {
|
||||||
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
<section className="col-12 col-lg-4">
|
<section className="col-12 col-lg-4">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm mb-4">
|
||||||
<div className="card-header">Neues Projekt</div>
|
<div className="card-header">Neues Projekt</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||||
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Projekt importieren</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small">
|
||||||
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||||
|
mit neuen internen IDs angelegt.
|
||||||
|
</p>
|
||||||
|
<label className="form-label" htmlFor="project-overview-import">
|
||||||
|
Projektdatei auswählen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="form-control"
|
||||||
|
disabled={isSaving}
|
||||||
|
id="project-overview-import"
|
||||||
|
key={projectImportInputKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleProjectImportFile(event.target.files?.[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{projectImportFilename ? (
|
||||||
|
<div className="form-text">
|
||||||
|
{projectImportFilename} ist bereit.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{projectImportFileError ? (
|
||||||
|
<div className="text-danger small mt-1">
|
||||||
|
{projectImportFileError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary mt-3 w-100"
|
||||||
|
disabled={isSaving || projectImport === null}
|
||||||
|
onClick={() => void handleImportProject()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Als neues Projekt importieren
|
||||||
|
</button>
|
||||||
|
{importedProject ? (
|
||||||
|
<div className="alert alert-success mt-3 mb-0">
|
||||||
|
<div className="small mb-2">
|
||||||
|
„{importedProject.name}“ wurde angelegt.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
href={`/projects/${importedProject.projectId}`}
|
||||||
|
>
|
||||||
|
Importiertes Projekt öffnen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-8">
|
<section className="col-12 col-lg-8">
|
||||||
|
|||||||
@@ -64,7 +64,15 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const details = await response.text();
|
let details = await response.text();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details) as { error?: unknown };
|
||||||
|
if (typeof parsed.error === "string") {
|
||||||
|
details = parsed.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep non-JSON error responses unchanged.
|
||||||
|
}
|
||||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +241,16 @@ export function exportProjectTransfer(projectId: string) {
|
|||||||
return request<unknown>(`/api/projects/${projectId}/export`);
|
return request<unknown>(`/api/projects/${projectId}/export`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function importProjectTransferAsNew(transfer: unknown) {
|
||||||
|
return request<{ projectId: string; name: string }>(
|
||||||
|
"/api/projects/import",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ transfer }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function importProjectTransfer(
|
export function importProjectTransfer(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
mode: "replace" | "duplicate",
|
mode: "replace" | "duplicate",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { NextFunction, Request, Response } from "express";
|
import type { NextFunction, Request, Response } from "express";
|
||||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
import { importProjectTransferSchema } from "../../shared/validation/project-transfer.schemas.js";
|
import {
|
||||||
|
importProjectTransferAsNewSchema,
|
||||||
|
importProjectTransferSchema,
|
||||||
|
} from "../../shared/validation/project-transfer.schemas.js";
|
||||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||||
|
|
||||||
@@ -22,6 +25,31 @@ export function exportProjectTransfer(req: Request, res: Response) {
|
|||||||
return res.json(transfer);
|
return res.json(transfer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function importProjectTransferAsNewProject(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const parsed = importProjectTransferAsNewSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith("Project transfer") ||
|
||||||
|
error.message.startsWith("Snapshot"))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function importProjectTransfer(
|
export function importProjectTransfer(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
exportProjectTransfer,
|
exportProjectTransfer,
|
||||||
importProjectTransfer,
|
importProjectTransfer,
|
||||||
|
importProjectTransferAsNewProject,
|
||||||
} from "../controllers/project-transfer.controller.js";
|
} from "../controllers/project-transfer.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
projectRouter.get("/", listProjects);
|
projectRouter.get("/", listProjects);
|
||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
|
projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
|
const requiredProjectTransferSchema = z
|
||||||
|
.unknown()
|
||||||
|
.refine((value) => value !== undefined, {
|
||||||
|
message: "Projektdatei fehlt.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importProjectTransferAsNewSchema = z
|
||||||
|
.object({
|
||||||
|
transfer: requiredProjectTransferSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
mode: z.literal("replace"),
|
mode: z.literal("replace"),
|
||||||
expectedRevision: expectedProjectRevisionSchema,
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
transfer: z.unknown(),
|
transfer: requiredProjectTransferSchema,
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
mode: z.literal("duplicate"),
|
mode: z.literal("duplicate"),
|
||||||
transfer: z.unknown(),
|
transfer: requiredProjectTransferSchema,
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import * as React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import ProjectsPage from "../src/app/projects/page.js";
|
||||||
|
import { importProjectTransferAsNew } from "../src/frontend/utils/api.js";
|
||||||
|
import { importProjectTransferAsNewSchema } from "../src/shared/validation/project-transfer.schemas.js";
|
||||||
|
|
||||||
|
describe("project overview import", () => {
|
||||||
|
it("renders a dedicated import-as-new-project entry point", () => {
|
||||||
|
const globalWithReact = globalThis as typeof globalThis & {
|
||||||
|
React?: typeof React;
|
||||||
|
};
|
||||||
|
const originalReact = globalWithReact.React;
|
||||||
|
globalWithReact.React = React;
|
||||||
|
let markup: string;
|
||||||
|
try {
|
||||||
|
markup = renderToStaticMarkup(React.createElement(ProjectsPage));
|
||||||
|
} finally {
|
||||||
|
globalWithReact.React = originalReact;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(markup, /Projekt importieren/);
|
||||||
|
assert.match(markup, /Projektdatei auswählen/);
|
||||||
|
assert.match(markup, /Als neues Projekt importieren/);
|
||||||
|
assert.doesNotMatch(markup, /Dieses Projekt ersetzen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the collection import endpoint without a target project id", 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({ projectId: "project-copy", name: "Projekt (Kopie)" }),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transfer = { format: "test" };
|
||||||
|
const result = await importProjectTransferAsNew(transfer);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
projectId: "project-copy",
|
||||||
|
name: "Projekt (Kopie)",
|
||||||
|
});
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/import",
|
||||||
|
method: "POST",
|
||||||
|
body: { transfer },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only the strict transfer wrapper", () => {
|
||||||
|
assert.equal(
|
||||||
|
importProjectTransferAsNewSchema.safeParse({ transfer: {} }).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(importProjectTransferAsNewSchema.safeParse({}).success, false);
|
||||||
|
assert.equal(
|
||||||
|
importProjectTransferAsNewSchema.safeParse({
|
||||||
|
transfer: {},
|
||||||
|
mode: "replace",
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user