Files
ThothII/frontend/src/shell/WorkspacePublishDialog.tsx
T

217 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import {
asWorkspaceApiError,
asWorkspaceConflict,
publishWorkspace,
validateWorkspace,
type CanonicalWorkspace,
type PublishWorkspaceRequest,
type WorkspaceConflict,
type WorkspaceRevision,
} from "../api/workspaces";
import type { WorkspaceDraft } from "../workspaces/drafts";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
export interface WorkspacePublishDialogProps {
open: boolean;
request: PublishWorkspaceRequest;
onOpenChange: (open: boolean) => void;
onPublished: (revision: WorkspaceRevision | undefined) => void;
onResolved: (draft: WorkspaceDraft) => void;
onPull: () => Promise<void> | void;
onReload: () => void;
}
function valueAt(workspace: CanonicalWorkspace, path: string): string {
const value = path.split(".").reduce<unknown>((current, key) => (
current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined
), workspace);
return value === undefined ? "—" : typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: JSON.stringify(value);
}
function valueAtPath(workspace: CanonicalWorkspace, path: string): unknown {
return path.split(".").reduce<unknown>((current, key) => (
current && typeof current === "object" ? (current as Record<string, unknown>)[key] : undefined
), workspace);
}
function replaceAtPath(value: Record<string, unknown>, path: string[], replacement: unknown): Record<string, unknown> {
const [key, ...remaining] = path;
const copy = { ...value };
if (remaining.length === 0) {
if (replacement === undefined) delete copy[key];
else copy[key] = replacement;
return copy;
}
const child = copy[key];
copy[key] = replaceAtPath(child && typeof child === "object" && !Array.isArray(child) ? child as Record<string, unknown> : {}, remaining, replacement);
return copy;
}
function requestWithWorkspace(request: PublishWorkspaceRequest, workspace: CanonicalWorkspace): PublishWorkspaceRequest {
return request.action === "delete" ? request : { ...request, workspace };
}
function RevisionSummary({ request }: { request: PublishWorkspaceRequest }) {
const label = request.action === "create" ? "New workspace" : request.action === "delete" ? "Deletion" : "Workspace update";
return <p className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 font-mono text-xs text-muted-foreground">{label} · base {request.baseCommit.slice(0, 12)}</p>;
}
function ConflictReview({ conflict, onPull, onReload, onResolved }: {
conflict: WorkspaceConflict;
onPull: () => Promise<void> | void;
onReload: () => void;
onResolved: (draft: WorkspaceDraft) => void;
}) {
const [pulling, setPulling] = useState(false);
const [pulled, setPulled] = useState(false);
const [pullError, setPullError] = useState(false);
const [choices, setChoices] = useState<Record<string, "local" | "remote">>({});
const canSave = Boolean(conflict.actual.blob) && conflict.fields.every((field) => choices[field]);
async function pull() {
setPulling(true);
setPullError(false);
try {
await onPull();
setPulled(true);
} catch {
setPullError(true);
} finally {
setPulling(false);
}
}
function saveRevisedDraft() {
if (!conflict.actual.blob || !canSave) return;
const workspace = conflict.fields.reduce<CanonicalWorkspace>((current, field) => (
choices[field] === "local"
? replaceAtPath(current as unknown as Record<string, unknown>, field.split("."), valueAtPath(conflict.local, field)) as unknown as CanonicalWorkspace
: current
), conflict.remote);
onResolved({
workspaceId: workspace.workspace.id,
baseCommit: conflict.actual.commit,
baseBlob: conflict.actual.blob,
workspace,
updatedAt: new Date().toISOString(),
});
}
return <div className="grid gap-4">
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
<p className="font-semibold">The registry changed before publication.</p>
<p className="mt-1 text-muted-foreground">Choose a value for every changed field to save a revised browser draft against registry revision {conflict.actual.commit.slice(0, 12)}. Saving never publishes it.</p>
</div>
<div className="grid gap-3">
{conflict.fields.map((field) => <section key={field} className="overflow-hidden rounded-md border border-border/70" aria-label={`Conflict for ${field}`}>
<h3 className="border-b border-border/70 bg-muted/40 px-3 py-2 font-mono text-xs font-semibold">{field}</h3>
<dl className="grid divide-y divide-border/70 text-sm sm:grid-cols-3 sm:divide-x sm:divide-y-0">
<div className="min-w-0 px-3 py-2"><dt className="text-xs text-muted-foreground">Base</dt><dd className="mt-1 break-words font-mono text-xs">{valueAt(conflict.base, field)}</dd></div>
<div className="min-w-0 px-3 py-2"><dt className="text-xs text-muted-foreground">Your draft</dt><dd className="mt-1 break-words font-mono text-xs">{valueAt(conflict.local, field)}</dd></div>
<div className="min-w-0 px-3 py-2"><dt className="text-xs text-muted-foreground">Registry</dt><dd className="mt-1 break-words font-mono text-xs">{valueAt(conflict.remote, field)}</dd></div>
</dl>
<fieldset className="flex flex-wrap gap-3 border-t border-border/70 px-3 py-2 text-xs">
<legend className="sr-only">Resolve {field}</legend>
<label className="flex items-center gap-1.5"><input type="radio" name={`resolution-${field}`} aria-label={`Use your draft for ${field}`} checked={choices[field] === "local"} onChange={() => setChoices((current) => ({ ...current, [field]: "local" }))} />Use your draft</label>
<label className="flex items-center gap-1.5"><input type="radio" name={`resolution-${field}`} aria-label={`Use registry value for ${field}`} checked={choices[field] === "remote"} onChange={() => setChoices((current) => ({ ...current, [field]: "remote" }))} />Use registry value</label>
</fieldset>
</section>)}
</div>
{pulled && <p role="status" className="text-sm text-muted-foreground">Latest registry state pulled. Reload the workspace before editing or publishing again.</p>}
{pullError && <p role="alert" className="text-sm text-destructive">Registry pull could not be completed. Try again or reload the workspace.</p>}
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={saveRevisedDraft} disabled={!canSave}>Save revised draft</Button>
<Button variant="outline" onClick={() => { void pull(); }} disabled={pulling}>{pulling ? "Pulling…" : "Pull latest registry"}</Button>
<Button onClick={onReload}>Reload workspace</Button>
</div>
</div>;
}
export function WorkspacePublishDialog({ open, request, onOpenChange, onPublished, onResolved, onPull, onReload }: WorkspacePublishDialogProps) {
const [validatedRequest, setValidatedRequest] = useState<PublishWorkspaceRequest>();
const [confirmationOpen, setConfirmationOpen] = useState(false);
const [conflict, setConflict] = useState<WorkspaceConflict>();
const [message, setMessage] = useState<string>();
const [publishing, setPublishing] = useState(false);
useEffect(() => {
if (!open) return;
setValidatedRequest(undefined);
setConfirmationOpen(false);
setConflict(undefined);
setMessage(undefined);
setPublishing(false);
}, [open, request]);
async function validate() {
setMessage(undefined);
setConflict(undefined);
if (request.action === "delete") {
setValidatedRequest(request);
setMessage("Deletion is pinned to the published revision.");
return;
}
try {
const result = await validateWorkspace(request.workspace);
setValidatedRequest(requestWithWorkspace(request, result.workspace));
setMessage("Workspace definition is valid.");
} catch (error) {
const safe = asWorkspaceApiError(error);
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Validation could not be completed");
}
}
async function publish() {
if (!validatedRequest) return;
setPublishing(true);
setMessage(undefined);
try {
const result = await publishWorkspace(validatedRequest);
onPublished(result?.revision);
onOpenChange(false);
} catch (error) {
const detectedConflict = asWorkspaceConflict(error);
if (detectedConflict) {
setConflict(detectedConflict);
setConfirmationOpen(false);
} else {
const safe = asWorkspaceApiError(error);
setMessage(safe ? `${safe.code}: ${safe.message}` : "workspace_invalid: Publication could not be completed");
}
} finally {
setPublishing(false);
}
}
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="max-w-[min(94vw,46rem)] p-0">
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<DialogTitle>{conflict ? "Publication conflict" : "Publish workspace"}</DialogTitle>
<DialogDescription>{conflict ? "Choose each local or registry value, then save a revised draft for normal validation and confirmation." : "Validation and an explicit confirmation are required before this shared definition is published."}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 px-5 py-5">
{conflict ? <ConflictReview conflict={conflict} onPull={onPull} onReload={onReload} onResolved={onResolved} /> : <>
<RevisionSummary request={validatedRequest ?? request} />
{message && <p role="status" className="rounded-md border border-border/70 bg-muted/40 px-3 py-2 text-sm">{message}</p>}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={() => { void validate(); }}>Validate draft</Button>
<Button disabled={!validatedRequest} onClick={() => setConfirmationOpen(true)}>Publish</Button>
</div>
</>}
</div>
<button type="button" aria-label="Close publish workspace" onClick={() => onOpenChange(false)} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">×</button>
</DialogContent>
<Dialog open={confirmationOpen} onOpenChange={setConfirmationOpen}>
<DialogContent showCloseButton={false} className="max-w-md">
<DialogHeader><DialogTitle>Confirm publication</DialogTitle><DialogDescription>This publishes the validated workspace definition to the shared Git registry.</DialogDescription></DialogHeader>
<RevisionSummary request={validatedRequest ?? request} />
<DialogFooter><Button variant="outline" onClick={() => setConfirmationOpen(false)}>Cancel</Button><Button onClick={() => { void publish(); }} disabled={publishing}>{publishing ? "Publishing…" : "Confirm publish"}</Button></DialogFooter>
</DialogContent>
</Dialog>
</Dialog>;
}