From 29530652323423e5af3d9f665d9f990728f8cc4b Mon Sep 17 00:00:00 2001 From: lornakelly Date: Tue, 2 Jun 2026 15:58:58 +0100 Subject: [PATCH] Add node details to sidebar Signed-off-by: lornakelly --- .changeset/selected-node-details.md | 5 + .../src/core/index.ts | 1 + .../src/core/taskDetails.ts | 106 +++++++++++++ .../src/i18n/locales/en.ts | 5 + .../src/react-flow/diagram/Diagram.tsx | 7 +- .../src/side-panel/Fields.tsx | 62 ++++++++ .../src/side-panel/NodeDetailsView.tsx | 76 +++++++++ .../src/side-panel/SidePanel.css | 90 ++++++++++- .../src/side-panel/SidePanel.tsx | 71 +++++++-- .../src/side-panel/WorkflowInfoView.tsx | 28 +--- .../src/store/DiagramEditorContext.tsx | 2 + .../store/DiagramEditorContextProvider.tsx | 23 ++- .../tests/core/taskDetails.test.ts | 145 ++++++++++++++++++ .../tests/side-panel/NodeDetailsView.test.tsx | 98 ++++++++++++ .../DiagramEditorContextProvider.test.tsx | 47 +++++- .../tests/test-utils/render-helpers.tsx | 2 + 16 files changed, 723 insertions(+), 45 deletions(-) create mode 100644 .changeset/selected-node-details.md create mode 100644 packages/serverless-workflow-diagram-editor/src/core/taskDetails.ts create mode 100644 packages/serverless-workflow-diagram-editor/src/side-panel/Fields.tsx create mode 100644 packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx create mode 100644 packages/serverless-workflow-diagram-editor/tests/core/taskDetails.test.ts create mode 100644 packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx diff --git a/.changeset/selected-node-details.md b/.changeset/selected-node-details.md new file mode 100644 index 00000000..827eac67 --- /dev/null +++ b/.changeset/selected-node-details.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +Add selected node details to sidepanel diff --git a/packages/serverless-workflow-diagram-editor/src/core/index.ts b/packages/serverless-workflow-diagram-editor/src/core/index.ts index db021855..f6362e8d 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/index.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/index.ts @@ -16,5 +16,6 @@ export * from "./workflowSdk"; export * from "./graph"; +export * from "./taskDetails"; export * from "./taskSubType"; export * from "./elkjs"; diff --git a/packages/serverless-workflow-diagram-editor/src/core/taskDetails.ts b/packages/serverless-workflow-diagram-editor/src/core/taskDetails.ts new file mode 100644 index 00000000..f8b2ca35 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/core/taskDetails.ts @@ -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 { + 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; + const nested = (key: string): Record | 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]; +} diff --git a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts index 5c0ca0a1..1ba204ed 100644 --- a/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts +++ b/packages/serverless-workflow-diagram-editor/src/i18n/locales/en.ts @@ -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; diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index 6cb41c3b..9c290db3 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -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); @@ -69,6 +69,10 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { (changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)), [setEdges], ); + const onSelectionChange = React.useCallback( + ({ nodes: selectedNodes }) => setSelectedNodeId(selectedNodes[0]?.id ?? null), + [setSelectedNodeId], + ); // Rebuild nodes and edges as model changes with debouncing React.useEffect(() => { @@ -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} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/Fields.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/Fields.tsx new file mode 100644 index 00000000..62da659c --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/Fields.tsx @@ -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 ( +
+

{label}

+
+
+ ); +} + +export function InlineField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function PropertyField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function StackedField({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function JsonField({ json, summary = "{...}" }: { json: string; summary?: string }) { + return ( +
+
+ {summary} +
{json}
+
+
+ ); +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx new file mode 100644 index 00000000..455a5008 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx @@ -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; +}; + +const OBJECT_GLYPH = "{...}"; + +function itemCount(length: number): string { + return `${length} item${length === 1 ? "" : "s"}`; +} + +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 ; +} + +export function NodeDetailsView({ node }: NodeDetailsViewProps) { + const { t } = useI18n(); + const task = node.data.task; + + const fields = task ? getTaskDetails(task) : []; + + if (fields.length === 0) { + return

{t("sidebar.noDetails")}

; + } + + /* 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 ( +
+ +
+ {fields.map((field) => ( + + ))} +
+ {task !== undefined && ( + <> +
+ + + + )} +
+ ); +} diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.css b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.css index 9f0e390d..efe6005b 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.css +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.css @@ -22,20 +22,36 @@ @apply dec:flex dec:items-center dec:gap-2; } + .dec-root .dec-sidebar-header-icon-wrap { + @apply dec:flex dec:items-center dec:justify-center dec:shrink-0 dec:h-9 dec:w-9 dec:rounded-lg dec:text-gray-100; + } + + .dec-root.dark .dec-sidebar-header-icon-wrap { + @apply dec:bg-gray-800; + } + + .dec-root.dark .dec-sidebar-header-icon-wrap.colored { + background: color-mix(in srgb, var(--task-node-color) 14%, transparent); + } + .dec-root .dec-sidebar-header-icon { - @apply dec:h-5 dec:w-5 dec:text-gray-700; + @apply dec:h-5 dec:w-5 dec:text-gray-600; } .dec-root.dark .dec-sidebar-header-icon { @apply dec:text-gray-300; } + .dec-root .dec-sidebar-header-icon-wrap.colored .dec-sidebar-header-icon { + color: var(--task-node-color); + } + .dec-root .dec-sidebar-header-labels { - @apply dec:flex dec:flex-col; + @apply dec:flex dec:flex-col dec:min-w-0; } .dec-root .dec-sidebar-header-name { - @apply dec:text-base dec:font-bold dec:text-gray-900 dec:leading-tight; + @apply dec:text-base dec:font-bold dec:text-gray-900 dec:leading-tight dec:break-words; } .dec-root.dark .dec-sidebar-header-name { @@ -134,6 +150,41 @@ @apply dec:text-gray-100; } + /* Property row */ + .dec-root .dec-sidebar-prop { + @apply dec:py-2 dec:px-1 dec:rounded-sm + dec:border-b dec:border-gray-100 dec:transition-colors; + } + + .dec-root .dec-sidebar-prop:hover { + @apply dec:bg-gray-100/60; + } + + .dec-root.dark .dec-sidebar-prop { + @apply dec:border-gray-800; + } + + .dec-root.dark .dec-sidebar-prop:hover { + @apply dec:bg-gray-800/40; + } + + .dec-root .dec-sidebar-prop-label { + @apply dec:block dec:mb-1 dec:text-xs dec:text-gray-500; + overflow-wrap: anywhere; + } + + .dec-root.dark .dec-sidebar-prop-label { + @apply dec:text-gray-400; + } + + .dec-root .dec-sidebar-prop-value { + @apply dec:text-sm dec:font-medium dec:text-gray-900; + overflow-wrap: anywhere; + } + + .dec-root.dark .dec-sidebar-prop-value { + @apply dec:text-gray-100; + } /* Stacked field (label above value) */ .dec-root .dec-sidebar-stacked-field { @apply dec:py-1.5; @@ -186,4 +237,37 @@ .dec-root .dec-sidebar-section-spacer { @apply dec:mt-4; } + + /** collapsible JSON field */ + .dec-root .dec-sidebar-json-field { + @apply dec:py-2 dec:px-1 dec:rounded-sm dec:border-b dec:border-gray-100 dec:transition-colors; + } + + .dec-root .dec-sidebar-json-field:hover { + @apply dec:bg-gray-100/60; + } + + .dec-root.dark .dec-sidebar-json-field { + @apply dec:border-gray-800; + } + + .dec-root.dark .dec-sidebar-json-field:hover { + @apply dec:bg-gray-800/40; + } + + .dec-root .dec-sidebar-json-summary { + @apply dec:cursor-pointer dec:text-sm dec:font-semibold dec:text-gray-900 dec:select-none dec:rounded-sm; + } + + .dec-root.dark .dec-sidebar-json-summary { + @apply dec:text-gray-100; + } + + .dec-root .dec-sidebar-json-pre { + @apply dec:mt-1 dec:max-h-64 dec:overflow-auto dec:rounded-md dec:bg-gray-100 dec:p-2 dec:text-xs dec:text-gray-800 dec:whitespace-pre-wrap dec:break-words; + } + + .dec-root.dark .dec-sidebar-json-pre { + @apply dec:bg-gray-800 dec:text-gray-200; + } } diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx index fe685c35..5578c079 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/SidePanel.tsx @@ -14,34 +14,83 @@ * limitations under the License. */ +import * as React from "react"; +import type * as RF from "@xyflow/react"; import { useI18n } from "@serverlessworkflow/i18n"; -import { Workflow, Info } from "lucide-react"; -import { Sidebar, SidebarContent, SidebarHeader } from "@/components/ui/sidebar"; +import { Workflow, Info, Box } from "lucide-react"; +import { Sidebar, SidebarContent, SidebarHeader, useSidebar } from "@/components/ui/sidebar"; import { useDiagramEditorContext } from "@/store/DiagramEditorContext"; import { WorkflowInfoView } from "@/side-panel/WorkflowInfoView"; +import { NodeDetailsView } from "@/side-panel/NodeDetailsView"; +import { taskNodeConfigMap, type LeafNodeType } from "@/react-flow/nodes/taskNodeConfig"; +import type { BaseNodeData } from "@/react-flow/nodes/Nodes"; import "./SidePanel.css"; export function SidePanel() { - const { model } = useDiagramEditorContext(); + const { model, nodes, selectedNodeId } = useDiagramEditorContext(); + const { setOpen } = useSidebar(); const { t } = useI18n(); + const selectedNode = React.useMemo( + () => + selectedNodeId !== null + ? ((nodes.find((n) => n.id === selectedNodeId) as RF.Node | undefined) ?? + null) + : null, + [selectedNodeId, nodes], + ); + + const nodeConfig = selectedNode + ? taskNodeConfigMap[selectedNode.type as LeafNodeType] + : undefined; + + const HeaderIcon = selectedNode ? (nodeConfig?.icon ?? Box) : Workflow; + + const prevSelectedNodeId = React.useRef(selectedNodeId); + React.useEffect(() => { + if (selectedNodeId === prevSelectedNodeId.current) { + return; + } + prevSelectedNodeId.current = selectedNodeId; + setOpen(selectedNodeId !== null); + }, [selectedNodeId, setOpen]); + return (
- + + +
- {t("sidebar.workflow")} - {t("sidebar.document")} + + {selectedNode ? selectedNode.data.label || t("sidebar.node") : t("sidebar.workflow")} + + + {selectedNode ? (nodeConfig?.typeLabel ?? t("sidebar.node")) : t("sidebar.document")} +
-
- - {t("sidebar.selectNode")} -
- {model !== null ? : null} + {selectedNode ? ( + + ) : ( + <> +
+ + {t("sidebar.selectNode")} +
+ {model !== null ? : null} + + )}
); diff --git a/packages/serverless-workflow-diagram-editor/src/side-panel/WorkflowInfoView.tsx b/packages/serverless-workflow-diagram-editor/src/side-panel/WorkflowInfoView.tsx index f79e272d..3e7c6222 100644 --- a/packages/serverless-workflow-diagram-editor/src/side-panel/WorkflowInfoView.tsx +++ b/packages/serverless-workflow-diagram-editor/src/side-panel/WorkflowInfoView.tsx @@ -16,38 +16,12 @@ import type { Specification } from "@serverlessworkflow/sdk"; import { useI18n } from "@serverlessworkflow/i18n"; +import { InlineField, SectionHeader, StackedField } from "./Fields"; type WorkflowInfoViewProps = { document: Specification.Document; }; -function SectionHeader({ label }: { label: string }) { - return ( -
-

{label}

-
-
- ); -} - -function InlineField({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function StackedField({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - export function WorkflowInfoView({ document }: WorkflowInfoViewProps) { const { t } = useI18n(); diff --git a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index fde6715b..8d20a272 100644 --- a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -25,11 +25,13 @@ export type DiagramEditorContextType = { errors: Error[]; nodes: RF.Node[]; edges: RF.Edge[]; + selectedNodeId: string | null; setIsReadOnly: React.Dispatch>; setLocale: React.Dispatch>; setNodes: React.Dispatch>; setEdges: React.Dispatch>; + setSelectedNodeId: React.Dispatch>; }; export const DiagramEditorContext = React.createContext( diff --git a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index aeeb45f1..fde69614 100644 --- a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -30,6 +30,7 @@ export const DiagramEditorContextProvider = ( const [locale, setLocale] = React.useState(props.locale); const [nodes, setNodes] = React.useState([] as RF.Node[]); const [edges, setEdges] = React.useState([] as RF.Edge[]); + const [selectedNodeId, setSelectedNodeId] = React.useState(null); const { model, errors } = React.useMemo(() => parseWorkflow(props.content), [props.content]); @@ -39,6 +40,11 @@ export const DiagramEditorContextProvider = ( setLocale(props.locale); }, [props.isReadOnly, props.locale, setIsReadOnly, setLocale]); + // Clear selectedNodeId when model changes + React.useEffect(() => { + setSelectedNodeId(null); + }, [model]); + // Memoize context value to prevent unnecessary re-renders of consumers const context = React.useMemo( () => ({ @@ -48,12 +54,27 @@ export const DiagramEditorContextProvider = ( errors, nodes, edges, + selectedNodeId, setIsReadOnly, setLocale, setNodes, setEdges, + setSelectedNodeId, }), - [isReadOnly, locale, model, errors, nodes, edges, setIsReadOnly, setLocale, setNodes, setEdges], + [ + isReadOnly, + locale, + model, + errors, + nodes, + edges, + selectedNodeId, + setIsReadOnly, + setLocale, + setNodes, + setEdges, + setSelectedNodeId, + ], ); return ( diff --git a/packages/serverless-workflow-diagram-editor/tests/core/taskDetails.test.ts b/packages/serverless-workflow-diagram-editor/tests/core/taskDetails.test.ts new file mode 100644 index 00000000..f7eded4e --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/core/taskDetails.test.ts @@ -0,0 +1,145 @@ +/* + * 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"; +import { describe, expect, it } from "vitest"; +import { getTaskDetails } from "../../src/core"; + +const asTask = (obj: unknown) => obj as Specification.Task; + +describe("getTaskDetails", () => { + it("flattens a HTTP call task into dot-notation text fields", () => { + const fields = getTaskDetails( + asTask({ + call: "http", + with: { method: "GET", url: "http://example.com" }, + }), + ); + + expect(fields).toEqual([ + { path: "call", kind: "text", display: "http" }, + { path: "with.method", kind: "text", display: "GET" }, + { path: "with.url", kind: "text", display: "http://example.com" }, + ]); + }); + + it("orders task specific fields before base fields (if/then)", () => { + const fields = getTaskDetails( + // eslint-disable-next-line unicorn/no-thenable -- 'then' is a real SWF directive + asTask({ if: "${ .ok }", set: { foo: "bar" }, then: "continue" }), + ); + + expect(fields).toEqual([ + { path: "set.foo", kind: "text", display: "bar" }, + { path: "if", kind: "text", display: "${ .ok }" }, + { path: "then", kind: "text", display: "continue" }, + ]); + }); + + it("extracts base fields (input/output/export/timeout) from their nested keys", () => { + const fields = getTaskDetails( + asTask({ + input: { from: "${ .input }" }, + output: { as: "${ .output }" }, + export: { as: "${ .export }" }, + timeout: { after: "${ .timeout }" }, + set: { x: 1 }, + }), + ); + + expect(fields).toEqual([ + { path: "set.x", kind: "text", display: "1" }, + { path: "input.from", kind: "text", display: "${ .input }" }, + { path: "output.as", kind: "text", display: "${ .output }" }, + { path: "export.as", kind: "text", display: "${ .export }" }, + { path: "timeout.after", kind: "text", display: "${ .timeout }" }, + ]); + }); + + it("flattens a timeout duration into dot-notation rows for object timeout 'after'", () => { + const fields = getTaskDetails( + asTask({ + timeout: { after: { minutes: 5, seconds: 30 } }, + }), + ); + + expect(fields).toEqual([ + { path: "timeout.after.minutes", kind: "text", display: "5" }, + { path: "timeout.after.seconds", kind: "text", display: "30" }, + ]); + }); + + it("returns string timeout reference", () => { + const fields = getTaskDetails( + asTask({ + timeout: "MyTimeout", + }), + ); + + expect(fields).toEqual([{ path: "timeout", kind: "text", display: "MyTimeout" }]); + }); + + it.each([{ length: 0 }, { length: 1 }, { length: 2 }])( + "returns an array of $length element(s) as a count", + ({ length }) => { + const items = Array.from({ length }, (_, i) => ({ + [`case${i}`]: { when: "x" }, + })); + const fields = getTaskDetails(asTask({ switch: items })); + + expect(fields).toEqual([{ path: "switch", kind: "array", count: length }]); + }, + ); + + it("summarises objects deeper than the max depth with a bare path", () => { + const fields = getTaskDetails( + asTask({ + with: { + a: { + b: { + client: { + name: "foo", + config: { + c: 1, + }, + }, + }, + }, + }, + }), + ); + + expect(fields).toEqual([ + { path: "with.a.b.client.name", kind: "text", display: "foo" }, + { path: "with.a.b.client.config", kind: "object" }, + ]); + }); + + it("excludes metadata from the fields", () => { + const fields = getTaskDetails( + asTask({ + set: { x: 1 }, + metadata: { author: "foo" }, + }), + ); + + expect(fields).toEqual([{ path: "set.x", kind: "text", display: "1" }]); + }); + + it("returns no fields for a task with no displayable fields", () => { + expect(getTaskDetails(asTask({}))).toEqual([]); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx b/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx new file mode 100644 index 00000000..3ef73c83 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import type * as RF from "@xyflow/react"; +import { NodeDetailsView } from "../../src/side-panel/NodeDetailsView"; +import type { BaseNodeData } from "../../src/react-flow/nodes/Nodes"; +import { renderWithProviders } from "../test-utils/render-helpers"; + +const makeNode = (data: BaseNodeData, type = "call"): RF.Node => ({ + id: "node-1", + type, + position: { x: 0, y: 0 }, + data, +}); + +describe("NodeDetailsView", () => { + it("renders every task field as a path labelled row under the Properties header", () => { + const node = makeNode({ + label: "getPets", + // eslint-disable-next-line unicorn/no-thenable -- 'then' is a real SWF directive + task: { call: "http", with: { endpoint: "https://api.example.com" }, then: "continue" }, + }); + + renderWithProviders(); + + expect(screen.getByTestId("node-details")).toBeInTheDocument(); + expect(screen.getByText("Properties")).toBeInTheDocument(); + expect(screen.getByText("call")).toBeInTheDocument(); + expect(screen.getByText("http")).toBeInTheDocument(); + expect(screen.getByText("with.endpoint")).toBeInTheDocument(); + expect(screen.getByText("https://api.example.com")).toBeInTheDocument(); + expect(screen.getByText("then")).toBeInTheDocument(); + expect(screen.getByText("continue")).toBeInTheDocument(); + }); + + it.each([ + { length: 1, text: "1 item" }, + { length: 2, text: "2 items" }, + ])("renders an array field as a summary $text", ({ length, text }) => { + const items = Array.from({ length }, () => ({})); + const node = makeNode({ label: "step", task: { switch: items } }); + + renderWithProviders(); + + expect(screen.getByText("switch")).toBeInTheDocument(); + expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it("renders an object field as a placeholder glyph (full value in source)", () => { + const node = makeNode({ + label: "step", + task: { with: { a: { b: { client: { config: { z: 1 } } } } } }, + }); + + renderWithProviders(); + expect(screen.getByText("with.a.b.client.config")).toBeInTheDocument(); + expect(screen.getByText("{...}")).toBeInTheDocument(); + }); + + it("renders a collapsed Source section with full json task", () => { + const task = { call: "http", with: { endpoint: "https://api.example.com" } }; + const node = makeNode({ label: "getPets", task }); + + const { container } = renderWithProviders(); + + expect(screen.getByRole("heading", { name: "Source" })).toBeInTheDocument(); + expect(container.querySelector(".dec-sidebar-json-summary")?.textContent).toBe("View source"); + expect(container.querySelector(".dec-sidebar-json-pre")?.textContent).toBe( + JSON.stringify(task, null, 2), + ); + }); + + it("renders node details message when the task has no task", () => { + const node = makeNode({ label: "start" }, "start"); + + renderWithProviders(); + + expect(screen.queryByTestId("node-details")).not.toBeInTheDocument(); + expect(screen.queryByText("Properties")).not.toBeInTheDocument(); + expect(screen.queryByText("Source")).not.toBeInTheDocument(); + expect(screen.getByText("No additional details for this node")).toBeInTheDocument(); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/store/DiagramEditorContextProvider.test.tsx b/packages/serverless-workflow-diagram-editor/tests/store/DiagramEditorContextProvider.test.tsx index 2df47d4e..7c60b3af 100644 --- a/packages/serverless-workflow-diagram-editor/tests/store/DiagramEditorContextProvider.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/store/DiagramEditorContextProvider.test.tsx @@ -15,11 +15,15 @@ */ import * as React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { vi, expect, afterEach, describe, it } from "vitest"; import { useDiagramEditorContext } from "../../src/store/DiagramEditorContext"; import { DiagramEditorContextProvider } from "../../src/store/DiagramEditorContextProvider"; -import { BASIC_INVALID_WORKFLOW_YAML, BASIC_VALID_WORKFLOW_YAML } from "../fixtures/workflows"; +import { + BASIC_INVALID_WORKFLOW_YAML, + BASIC_VALID_WORKFLOW_JSON, + BASIC_VALID_WORKFLOW_YAML, +} from "../fixtures/workflows"; const TestComponent: React.FC = () => { const { isReadOnly, locale, model, errors } = useDiagramEditorContext(); @@ -39,6 +43,16 @@ const TestComponent: React.FC = () => { ); }; +const SelectionButton: React.FC = () => { + const { selectedNodeId, setSelectedNodeId } = useDiagramEditorContext(); + + return ( + + ); +}; + describe("DiagramEditorContextProvider Component", () => { afterEach(() => { vi.restoreAllMocks(); @@ -218,4 +232,33 @@ describe("DiagramEditorContextProvider Component", () => { expect(screen.getByTestId("test-errors")).toHaveTextContent("1"); }); }); + + it("Clears the selected node when content changes", async () => { + const { rerender } = render( + + + , + ); + + fireEvent.click(screen.getByTestId("selected-node")); + expect(screen.getByTestId("selected-node")).toHaveTextContent("step1"); + + rerender( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("selected-node")).toHaveTextContent("null"); + }); + }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index 0ed4a1d1..e0483995 100644 --- a/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -38,10 +38,12 @@ export const createMockContextValue = ( errors: [], nodes: [], edges: [], + selectedNodeId: null, setIsReadOnly: noop, setLocale: noop, setEdges: noop, setNodes: noop, + setSelectedNodeId: noop, ...overrides, });