Add workflow standard comparison controls
This commit is contained in:
@@ -160,6 +160,29 @@ export type WorkflowStandardProvenance = {
|
|||||||
reset_available: boolean;
|
reset_available: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorkflowStandardDiffItem = {
|
||||||
|
resource_type: "graph" | "node" | "edge";
|
||||||
|
resource_id: string;
|
||||||
|
state: "unchanged" | "local_only" | "upstream_only" | "same_change" | "conflict";
|
||||||
|
recommended_action: "none" | "keep_local" | "adopt_upstream" | "either" | "manual_resolution";
|
||||||
|
changed_fields: string[];
|
||||||
|
baseline?: Record<string, unknown> | null;
|
||||||
|
local?: Record<string, unknown> | null;
|
||||||
|
latest?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowStandardDiff = {
|
||||||
|
override_definition_id: string;
|
||||||
|
baseline_definition_id: string;
|
||||||
|
pinned_baseline_revision: number;
|
||||||
|
local_revision: number;
|
||||||
|
latest_baseline_revision: number;
|
||||||
|
counts: Record<string, number>;
|
||||||
|
conflict_count: number;
|
||||||
|
auto_mergeable: boolean;
|
||||||
|
items: WorkflowStandardDiffItem[];
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowDefinition = {
|
export type WorkflowDefinition = {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string | null;
|
tenant_id: string | null;
|
||||||
@@ -457,6 +480,16 @@ export function resetWorkflowDefinitionToStandard(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function compareWorkflowDefinitionToStandard(
|
||||||
|
settings: ApiSettings,
|
||||||
|
definitionId: string
|
||||||
|
): Promise<WorkflowStandardDiff> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/standard-diff`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listWorkflowRevisions(
|
export async function listWorkflowRevisions(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
definitionId: string
|
definitionId: string
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
activateWorkflowDefinition,
|
activateWorkflowDefinition,
|
||||||
archiveWorkflowDefinition,
|
archiveWorkflowDefinition,
|
||||||
|
compareWorkflowDefinitionToStandard,
|
||||||
compileWorkflowBpmn,
|
compileWorkflowBpmn,
|
||||||
createWorkflowDefinition,
|
createWorkflowDefinition,
|
||||||
deleteWorkflowDefinition,
|
deleteWorkflowDefinition,
|
||||||
deriveWorkflowDefinition,
|
deriveWorkflowDefinition,
|
||||||
getWorkflowRevision,
|
|
||||||
listWorkflowDefinitions,
|
listWorkflowDefinitions,
|
||||||
listWorkflowNodeTypes,
|
listWorkflowNodeTypes,
|
||||||
listWorkflowRevisions,
|
listWorkflowRevisions,
|
||||||
@@ -63,7 +63,8 @@ import {
|
|||||||
type WorkflowDiagnostic,
|
type WorkflowDiagnostic,
|
||||||
type WorkflowGraphEdge,
|
type WorkflowGraphEdge,
|
||||||
type WorkflowNodeType,
|
type WorkflowNodeType,
|
||||||
type WorkflowRevision
|
type WorkflowRevision,
|
||||||
|
type WorkflowStandardDiff
|
||||||
} from "../../api/workflow";
|
} from "../../api/workflow";
|
||||||
import WorkflowCanvas, {
|
import WorkflowCanvas, {
|
||||||
updateWorkflowGraphNode
|
updateWorkflowGraphNode
|
||||||
@@ -151,10 +152,6 @@ export default function WorkflowPage({
|
|||||||
() => definitions.find((item) => item.id === draft?.id) ?? null,
|
() => definitions.find((item) => item.id === draft?.id) ?? null,
|
||||||
[definitions, draft?.id]
|
[definitions, draft?.id]
|
||||||
);
|
);
|
||||||
const baselineDefinition = useMemo(() => {
|
|
||||||
const baselineId = selectedDefinition?.standard?.baseline_definition_id;
|
|
||||||
return definitions.find((item) => item.id === baselineId) ?? null;
|
|
||||||
}, [definitions, selectedDefinition]);
|
|
||||||
const dirty = Boolean(draft)
|
const dirty = Boolean(draft)
|
||||||
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
|
||||||
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
|
||||||
@@ -987,7 +984,6 @@ export default function WorkflowPage({
|
|||||||
open={compareOpen}
|
open={compareOpen}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
override={selectedDefinition}
|
override={selectedDefinition}
|
||||||
baseline={baselineDefinition}
|
|
||||||
onClose={() => setCompareOpen(false)}
|
onClose={() => setCompareOpen(false)}
|
||||||
/>
|
/>
|
||||||
<WorkflowDefinitionSettingsDialog
|
<WorkflowDefinitionSettingsDialog
|
||||||
@@ -1041,45 +1037,31 @@ function WorkflowStandardComparisonDialog({
|
|||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
override,
|
override,
|
||||||
baseline,
|
|
||||||
onClose
|
onClose
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
override: WorkflowDefinition | null;
|
override: WorkflowDefinition | null;
|
||||||
baseline: WorkflowDefinition | null;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [pinned, setPinned] = useState<WorkflowRevision | null>(null);
|
const [comparison, setComparison] = useState<WorkflowStandardDiff | null>(null);
|
||||||
const [latest, setLatest] = useState<WorkflowRevision | null>(null);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const standard = override?.standard;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !baseline || !standard) return;
|
if (!open || !override) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setError("");
|
setError("");
|
||||||
const pinnedRevision = standard.pinned_baseline_revision
|
setComparison(null);
|
||||||
?? standard.active_baseline_revision
|
void compareWorkflowDefinitionToStandard(settings, override.id).then((result) => {
|
||||||
?? 1;
|
|
||||||
void Promise.all([
|
|
||||||
getWorkflowRevision(settings, baseline.id, pinnedRevision),
|
|
||||||
getWorkflowRevision(
|
|
||||||
settings,
|
|
||||||
baseline.id,
|
|
||||||
standard.latest_baseline_revision
|
|
||||||
)
|
|
||||||
]).then(([pinnedItem, latestItem]) => {
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setPinned(pinnedItem);
|
setComparison(result);
|
||||||
setLatest(latestItem);
|
|
||||||
}).catch((loadError) => {
|
}).catch((loadError) => {
|
||||||
if (!cancelled) setError(apiErrorMessage(loadError));
|
if (!cancelled) setError(apiErrorMessage(loadError));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [baseline, open, settings, standard]);
|
}, [open, override, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -1094,44 +1076,79 @@ function WorkflowStandardComparisonDialog({
|
|||||||
{error}
|
{error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!error && !comparison ? <LoadingFrame label="Comparing workflow revisions..." /> : null}
|
||||||
|
{comparison ? (
|
||||||
<div className="workflow-standard-comparison">
|
<div className="workflow-standard-comparison">
|
||||||
<StandardRevisionColumn
|
<div className="workflow-standard-comparison-summary">
|
||||||
label="Pinned standard"
|
<span>
|
||||||
revision={pinned}
|
Pinned revision <strong>{comparison.pinned_baseline_revision}</strong>
|
||||||
/>
|
</span>
|
||||||
<StandardRevisionColumn
|
<span>
|
||||||
label="Local override"
|
Local revision <strong>{comparison.local_revision}</strong>
|
||||||
revision={override?.revision ?? null}
|
</span>
|
||||||
/>
|
<span>
|
||||||
<StandardRevisionColumn
|
Latest standard <strong>{comparison.latest_baseline_revision}</strong>
|
||||||
label="Latest standard"
|
</span>
|
||||||
revision={latest}
|
<StatusBadge
|
||||||
|
status={comparison.auto_mergeable ? "ready" : "warning"}
|
||||||
|
label={comparison.auto_mergeable
|
||||||
|
? "No semantic conflicts"
|
||||||
|
: `${comparison.conflict_count} conflicts`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="workflow-standard-diff-list">
|
||||||
|
{comparison.items.map((item) => (
|
||||||
|
<section
|
||||||
|
className={`workflow-standard-diff-item state-${item.state}`}
|
||||||
|
key={`${item.resource_type}:${item.resource_id}`}
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<span>
|
||||||
|
<strong>{item.resource_id}</strong>
|
||||||
|
<small>{item.resource_type} · {item.changed_fields.join(", ")}</small>
|
||||||
|
</span>
|
||||||
|
<StatusBadge
|
||||||
|
status={item.state === "conflict"
|
||||||
|
? "warning"
|
||||||
|
: item.state === "upstream_only"
|
||||||
|
? "queued"
|
||||||
|
: "active"}
|
||||||
|
label={standardDiffLabel(item.state)}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<p>{standardDiffAction(item.recommended_action)}</p>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{!comparison.items.length ? (
|
||||||
|
<p className="workflow-standard-diff-empty">
|
||||||
|
The local override and latest standard contain no semantic changes.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StandardRevisionColumn({
|
function standardDiffLabel(state: string): string {
|
||||||
label,
|
return ({
|
||||||
revision
|
local_only: "Local change",
|
||||||
}: {
|
upstream_only: "Standard update",
|
||||||
label: string;
|
same_change: "Same change",
|
||||||
revision: WorkflowRevision | null;
|
conflict: "Conflict",
|
||||||
}) {
|
unchanged: "Unchanged"
|
||||||
return (
|
} as Record<string, string>)[state] ?? state;
|
||||||
<section className="workflow-standard-revision">
|
}
|
||||||
<header>
|
|
||||||
<strong>{label}</strong>
|
function standardDiffAction(action: string): string {
|
||||||
<small>
|
return ({
|
||||||
{revision
|
keep_local: "The local change can be retained automatically.",
|
||||||
? `Revision ${revision.revision} · ${revision.content_hash.slice(0, 12)}`
|
adopt_upstream: "The standard update can be adopted automatically.",
|
||||||
: "Loading..."}
|
either: "Both revisions made the same semantic change.",
|
||||||
</small>
|
manual_resolution: "Local and upstream changes overlap and need a decision.",
|
||||||
</header>
|
none: "No action is required."
|
||||||
<pre>{revision ? JSON.stringify(revision.graph, null, 2) : ""}</pre>
|
} as Record<string, string>)[action] ?? action;
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkflowDefinitionSettingsDialog({
|
function WorkflowDefinitionSettingsDialog({
|
||||||
|
|||||||
@@ -19,55 +19,83 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workflow-standard-comparison {
|
.workflow-standard-comparison {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-standard-revision {
|
.workflow-standard-comparison-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
align-items: center;
|
||||||
min-height: 0;
|
gap: 18px;
|
||||||
flex-direction: column;
|
padding: 10px 12px;
|
||||||
border: var(--border-line);
|
border: var(--border-line);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-standard-revision header {
|
.workflow-standard-comparison-summary .status-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-standard-revision small {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-standard-revision pre {
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
gap: 6px;
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
font-size: 11px;
|
}
|
||||||
white-space: pre;
|
|
||||||
|
.workflow-standard-diff-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: var(--border-line);
|
||||||
|
border-left: 3px solid var(--success);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item.state-conflict {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item.state-upstream_only {
|
||||||
|
border-left-color: var(--info-text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item header > span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item small,
|
||||||
|
.workflow-standard-diff-item p,
|
||||||
|
.workflow-standard-diff-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-standard-diff-item p {
|
||||||
|
margin: 6px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workflow-standard-comparison {
|
.workflow-standard-comparison-summary {
|
||||||
grid-template-columns: 1fr;
|
align-items: flex-start;
|
||||||
overflow: auto;
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-standard-revision {
|
.workflow-standard-comparison-summary .status-badge {
|
||||||
min-height: 260px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user