Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selected-node-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Add selected node details to sidepanel
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@

export * from "./workflowSdk";
export * from "./graph";
export * from "./taskDetails";
export * from "./taskSubType";
export * from "./elkjs";
106 changes: 106 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/core/taskDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { Specification } from "@serverlessworkflow/sdk";

/* TaskBase: Common fields every task inherits (none required) (metadata is dropped for now) */
const TASK_BASE_KEYS = new Set(["if", "input", "output", "export", "timeout", "then", "metadata"]);

/* Number of object levels to expand into dot-notation rows */
const MAX_DEPTH = 4;

/* Flattened task row - kind: how the view should render it */
export type DetailField =
| { path: string; kind: "text"; display: string }
| { path: string; kind: "array"; count: number }
| { path: string; kind: "object" };

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function flattenFields(
value: unknown,
path: string = "",
depth: number = 0,
outputFields: DetailField[] = [],
): void {
if (value === undefined || value === null) {
return;
}

if (Array.isArray(value)) {
outputFields.push({ path, kind: "array", count: value.length });
return;
}

if (isPlainObject(value)) {
if (depth >= MAX_DEPTH) {
/* Too deep - bare path, full value available in Source */
outputFields.push({ path, kind: "object" });
return;
}

for (const [key, val] of Object.entries(value)) {
flattenFields(val, path ? `${path}.${key}` : key, depth + 1, outputFields);
}
return;
}
outputFields.push({ path, kind: "text", display: String(value) });
}

/* Builds the flattened detail rows for a task: task-specific fields first, inherited base fields last */
export function getTaskDetails(task: Specification.Task): DetailField[] {
const record = task as Record<string, unknown>;
const nested = (key: string): Record<string, unknown> | undefined => {
const value = record[key];
return isPlainObject(value) ? value : undefined;
};

// Handle timeout as it can be a string or an object (after)
const timeoutSource =
typeof record.timeout === "string"
? { path: "timeout", value: record.timeout }
: {
path: "timeout.after",
value: nested("timeout")?.after,
};

/* Base fields, each labelled with its dsl path */
const baseSources: Array<{ path: string; value: unknown }> = [
{ path: "if", value: record.if },
{ path: "input.from", value: nested("input")?.from },
{ path: "output.as", value: nested("output")?.as },
{ path: "export.as", value: nested("export")?.as },
timeoutSource,
{ path: "then", value: record.then },
];

const base: DetailField[] = [];
for (const { path, value } of baseSources) {
flattenFields(value, path, 0, base);
}

/* Top level keys (base keys and metadata excluded) */
const specific: DetailField[] = [];
for (const [key, value] of Object.entries(record)) {
if (!TASK_BASE_KEYS.has(key)) {
flattenFields(value, key, 0, specific);
}
}

return [...specific, ...base];
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const en = {
"sidebar.title": "Title",
"sidebar.summary": "Summary",
"sidebar.tags": "Tags",
"sidebar.node": "Node",
"sidebar.sectionProperties": "Properties",
"sidebar.sectionSource": "Source",
"sidebar.viewSource": "View source",
"sidebar.noDetails": "No additional details for this node",
} as const;

export type TranslationKeys = keyof typeof en;
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export type DiagramProps = {

export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow();
const { model, nodes, edges, setNodes, setEdges } = useDiagramEditorContext();
const { model, nodes, edges, setNodes, setEdges, setSelectedNodeId } = useDiagramEditorContext();

const [minimapVisible, setMinimapVisible] = React.useState(false);

Expand All @@ -69,6 +69,10 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
(changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)),
[setEdges],
);
const onSelectionChange = React.useCallback<RF.OnSelectionChangeFunc>(
({ nodes: selectedNodes }) => setSelectedNodeId(selectedNodes[0]?.id ?? null),
[setSelectedNodeId],
);
Comment thread
lornakelly marked this conversation as resolved.

// Rebuild nodes and edges as model changes with debouncing
React.useEffect(() => {
Expand Down Expand Up @@ -134,6 +138,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onSelectionChange={onSelectionChange}
onlyRenderVisibleElements={true}
zoomOnDoubleClick={false}
elementsSelectable={true}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export function SectionHeader({ label }: { label: string }) {
return (
<div className="dec-sidebar-section-header">
<h3 className="dec-sidebar-section-title">{label}</h3>
<div className="dec-sidebar-section-divider" />
</div>
);
}

export function InlineField({ label, value }: { label: string; value: string }) {
return (
<div className="dec-sidebar-inline-field">
<dt className="dec-sidebar-field-label">{label}</dt>
<dd className="dec-sidebar-field-value">{value}</dd>
</div>
);
}

export function PropertyField({ label, value }: { label: string; value: string }) {
return (
<div className="dec-sidebar-prop">
<dt className="dec-sidebar-prop-label">{label}</dt>
<dd className="dec-sidebar-prop-value">{value}</dd>
</div>
);
}

export function StackedField({ label, value }: { label: string; value: string }) {
return (
<div className="dec-sidebar-stacked-field">
<dt className="dec-sidebar-stacked-label">{label}</dt>
<dd className="dec-sidebar-stacked-value">{value}</dd>
</div>
);
}

export function JsonField({ json, summary = "{...}" }: { json: string; summary?: string }) {
return (
<div className="dec-sidebar-json-field">
<details className="dec-sidebar-json-details">
<summary className="dec-sidebar-json-summary">{summary}</summary>
<pre className="dec-sidebar-json-pre">{json}</pre>
</details>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright 2021-Present The Serverless Workflow Specification Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type * as RF from "@xyflow/react";
import { useI18n } from "@serverlessworkflow/i18n";
import { getTaskDetails, type DetailField } from "@/core/taskDetails";
import type { BaseNodeData } from "@/react-flow/nodes/Nodes";
import { JsonField, PropertyField, SectionHeader } from "./Fields";

type NodeDetailsViewProps = {
node: RF.Node<BaseNodeData>;
};

const OBJECT_GLYPH = "{...}";

function itemCount(length: number): string {
return `${length} item${length === 1 ? "" : "s"}`;
}
Comment thread
lornakelly marked this conversation as resolved.

function fieldText(field: DetailField): string {
switch (field.kind) {
case "array":
return itemCount(field.count);
case "text":
return field.display;
case "object":
return OBJECT_GLYPH;
}
}

function FieldRow({ label, field }: { label: string; field: DetailField }) {
return <PropertyField label={label} value={fieldText(field)} />;
}

export function NodeDetailsView({ node }: NodeDetailsViewProps) {
const { t } = useI18n();
const task = node.data.task;

const fields = task ? getTaskDetails(task) : [];

if (fields.length === 0) {
return <p className="dec-sidebar-hint-text">{t("sidebar.noDetails")}</p>;
}

/* TODO FUTURE: Once we have a synced text -> diagram view, re-look at the source JSON block, it becomes redundant with dual view but if user wants standalone diagram without text then it is still valid so look at conditionally displaying it */
return (
<div data-testid="node-details">
<SectionHeader label={t("sidebar.sectionProperties")} />
<dl>
{fields.map((field) => (
<FieldRow key={field.path} label={field.path} field={field} />
))}
</dl>
{task !== undefined && (
<>
<div className="dec-sidebar-section-spacer" />
<SectionHeader label={t("sidebar.sectionSource")} />
<JsonField json={JSON.stringify(task, null, 2)} summary={t("sidebar.viewSource")} />
</>
)}
</div>
);
}
Loading
Loading