Add project overview import
This commit is contained in:
+109
-1
@@ -6,6 +6,7 @@ import {
|
||||
createGlobalDevice,
|
||||
createProject,
|
||||
deleteGlobalDevice,
|
||||
importProjectTransferAsNew,
|
||||
listGlobalDevices,
|
||||
listProjects,
|
||||
updateGlobalDevice,
|
||||
@@ -55,6 +56,16 @@ export default function ProjectsPage() {
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
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() {
|
||||
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>) {
|
||||
event.preventDefault();
|
||||
if (!globalDeviceForm.name.trim()) {
|
||||
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
|
||||
|
||||
<div className="row g-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-body">
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
|
||||
</form>
|
||||
</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 className="col-12 col-lg-8">
|
||||
|
||||
@@ -64,7 +64,15 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
|
||||
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})`);
|
||||
}
|
||||
|
||||
@@ -233,6 +241,16 @@ export function exportProjectTransfer(projectId: string) {
|
||||
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(
|
||||
projectId: string,
|
||||
mode: "replace" | "duplicate",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-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 { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||
|
||||
@@ -22,6 +25,31 @@ export function exportProjectTransfer(req: Request, res: Response) {
|
||||
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(
|
||||
req: Request,
|
||||
res: Response,
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
import {
|
||||
exportProjectTransfer,
|
||||
importProjectTransfer,
|
||||
importProjectTransferAsNewProject,
|
||||
} from "../controllers/project-transfer.controller.js";
|
||||
|
||||
export const projectRouter = Router();
|
||||
|
||||
projectRouter.get("/", listProjects);
|
||||
projectRouter.post("/", createProject);
|
||||
projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||
projectRouter.get("/:projectId", getProject);
|
||||
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
import { z } from "zod";
|
||||
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", [
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("replace"),
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
transfer: z.unknown(),
|
||||
transfer: requiredProjectTransferSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("duplicate"),
|
||||
transfer: z.unknown(),
|
||||
transfer: requiredProjectTransferSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user