diff --git a/.changeset/form-library-and-split.md b/.changeset/form-library-and-split.md
new file mode 100644
index 00000000..1993471d
--- /dev/null
+++ b/.changeset/form-library-and-split.md
@@ -0,0 +1,5 @@
+---
+"@openworkflowspec/diagram-editor": minor
+---
+
+Add react-hook-form and split node properties into separate read only and editable branches
diff --git a/packages/open-workflow-diagram-editor/package.json b/packages/open-workflow-diagram-editor/package.json
index f78d9e7b..4bfc10fd 100644
--- a/packages/open-workflow-diagram-editor/package.json
+++ b/packages/open-workflow-diagram-editor/package.json
@@ -51,6 +51,7 @@
"fast-equals": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:",
+ "react-hook-form": "catalog:",
"sonner": "catalog:",
"use-sync-external-store": "catalog:"
},
diff --git a/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx b/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx
new file mode 100644
index 00000000..146c0bd0
--- /dev/null
+++ b/packages/open-workflow-diagram-editor/src/side-panel/EditableProperties.tsx
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021-Present The Open 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 { DetailField } from "@/core/taskDetails";
+import { ReadOnlyProperties } from "./ReadOnlyProperties";
+
+/**
+ * Editable presentation of a task's properties.
+ *
+ * PLACEHOLDER. This change establishes the read-only/edit seam only, so the editable
+ * branch currently renders the same static rows as read-only mode until we implement
+ *
+ */
+export function EditableProperties({ fields }: { fields: DetailField[] }) {
+ return ;
+}
diff --git a/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx b/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx
index e6df46da..e96d1af9 100644
--- a/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx
+++ b/packages/open-workflow-diagram-editor/src/side-panel/FieldControls.tsx
@@ -28,42 +28,44 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useI18n } from "@openworkflowspec/i18n";
+/**
+ * These controls are read only presentation of a field and are always disabled.
+ */
type ControlProps = {
field: Extract;
- isReadOnly: boolean;
};
const ISO_8601_DURATION_REGEX =
/^P(?=\d|T)(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?=\d)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/;
-function LongStringControl({ field, isReadOnly }: ControlProps<"long-string">) {
- return ;
+function LongStringControl({ field }: ControlProps<"long-string">) {
+ return ;
}
-function DurationControl({ field, isReadOnly }: ControlProps<"duration">) {
+function DurationControl({ field }: ControlProps<"duration">) {
const { t } = useI18n();
return (
);
}
-function ExpressionControl({ field, isReadOnly }: ControlProps<"runtime-expression">) {
+function ExpressionControl({ field }: ControlProps<"runtime-expression">) {
return (
);
diff --git a/packages/open-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx b/packages/open-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx
index b371e77d..e6911623 100644
--- a/packages/open-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx
+++ b/packages/open-workflow-diagram-editor/src/side-panel/NodeDetailsView.tsx
@@ -17,9 +17,11 @@
import type * as RF from "@xyflow/react";
import { dump } from "js-yaml";
import { useI18n } from "@openworkflowspec/i18n";
-import { getTaskDetails, type DetailField } from "@/core/taskDetails";
+import { getTaskDetails } from "@/core/taskDetails";
import type { BaseNodeData } from "@/react-flow/nodes/Nodes";
-import { YamlField, PropertyField, SectionHeader } from "./Fields";
+import { YamlField, SectionHeader } from "./Fields";
+import { ReadOnlyProperties } from "./ReadOnlyProperties";
+import { EditableProperties } from "./EditableProperties";
import { useDiagramEditorContext } from "@/store/DiagramEditorContext";
import { getNodeErrorField, getNodeErrors } from "@/core";
import { ErrorSection } from "./ErrorsSection";
@@ -28,18 +30,6 @@ type NodeDetailsViewProps = {
node: RF.Node;
};
-function FieldRow({
- label,
- field,
- isReadOnly,
-}: {
- label: string;
- field: DetailField;
- isReadOnly: boolean;
-}) {
- return ;
-}
-
export function NodeDetailsView({ node }: NodeDetailsViewProps) {
const { t } = useI18n();
const { errors, taskReferences, isReadOnly } = useDiagramEditorContext();
@@ -65,11 +55,11 @@ export function NodeDetailsView({ node }: NodeDetailsViewProps) {
{fields.length > 0 && (
<>
-
- {fields.map((field) => (
-
- ))}
-
+ {isReadOnly ? (
+
+ ) : (
+
+ )}
>
)}
{isReadOnly && task !== undefined && (
diff --git a/packages/open-workflow-diagram-editor/src/side-panel/ReadOnlyProperties.tsx b/packages/open-workflow-diagram-editor/src/side-panel/ReadOnlyProperties.tsx
new file mode 100644
index 00000000..47833df2
--- /dev/null
+++ b/packages/open-workflow-diagram-editor/src/side-panel/ReadOnlyProperties.tsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021-Present The Open 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 { DetailField } from "@/core/taskDetails";
+import { PropertyField } from "./Fields";
+
+/**
+ * Static presentation of a task's flattened properties.
+ */
+export function ReadOnlyProperties({ fields }: { fields: DetailField[] }) {
+ return (
+
+ {fields.map((field) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/FieldControls.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/FieldControls.test.tsx
new file mode 100644
index 00000000..a10af2ad
--- /dev/null
+++ b/packages/open-workflow-diagram-editor/tests/side-panel/FieldControls.test.tsx
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021-Present The Open 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 { FieldControl } from "../../src/side-panel/FieldControls";
+import type { DetailField } from "../../src/core/taskDetails";
+import { renderWithProviders } from "../test-utils/render-helpers";
+
+describe("FieldControl", () => {
+ describe("scalar", () => {
+ const scalarCases: Array<[string, DetailField, string]> = [
+ ["string", { path: "call", kind: "scalar", value: "http" }, "http"],
+ ["number", { path: "with.port", kind: "scalar", value: 8080 }, "8080"],
+ ];
+
+ it.each(scalarCases)("renders a %s as a disabled input", (_label, field, displayed) => {
+ renderWithProviders();
+
+ const control = screen.getByDisplayValue(displayed);
+ expect(control.tagName).toBe("INPUT");
+ expect(control).toBeDisabled();
+ });
+
+ it("renders a number scalar as a number input", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByDisplayValue("8080")).toHaveAttribute("type", "number");
+ });
+
+ it("renders a boolean as a disabled switch", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const control = screen.getByRole("switch");
+ expect(control).toBeChecked();
+ expect(control).toBeDisabled();
+ });
+ });
+
+ it("renders a long string as a disabled textarea", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const control = screen.getByDisplayValue("echo hi");
+ expect(control.tagName).toBe("TEXTAREA");
+ expect(control).toBeDisabled();
+ });
+
+ it("constrains a duration to the ISO 8601 format", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const control = screen.getByDisplayValue("PT5M");
+ expect(control).toBeDisabled();
+ expect(control).toHaveAttribute("pattern");
+ expect(control).toHaveAttribute("title", expect.stringContaining("ISO 8601"));
+ });
+
+ it("renders an enum as a combobox showing the selected option", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("content")).toBeInTheDocument();
+ });
+
+ /* Arrays and objects are not editable in the panel — they report their shape and the
+ full value stays available in the Source section. */
+ it.each([
+ [1, "1 item"],
+ [3, "3 items"],
+ ])("summarises an array of %i as %s", (count, expected) => {
+ renderWithProviders();
+
+ expect(screen.getByText(expected)).toBeInTheDocument();
+ });
+
+ it("renders an object as a placeholder glyph", () => {
+ renderWithProviders();
+
+ expect(screen.getByText("{...}")).toBeInTheDocument();
+ });
+});
diff --git a/packages/open-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx b/packages/open-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx
index f3de883f..ae3881f4 100644
--- a/packages/open-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx
+++ b/packages/open-workflow-diagram-editor/tests/side-panel/NodeDetailsView.test.tsx
@@ -246,4 +246,38 @@ describe("NodeDetailsView", () => {
expect(container.querySelector(".dec-sidebar-yaml-pre")).toBeNull();
});
});
+
+ describe("read-only and editable modes", () => {
+ const node = makeNode({
+ label: "getPets",
+ task: {
+ call: "http",
+ with: { endpoint: "https://api.example.com" },
+ },
+ });
+
+ const modes = [
+ ["read-only", true],
+ ["editable", false],
+ ] as const;
+
+ /* The read-only/edit split is the same until the react-hook-form editor
+ lands, so both branches render the same property rows. */
+ it.each(modes)("renders the task's property rows in %s mode", (_mode, isReadOnly) => {
+ renderWithProviders(, { isReadOnly });
+
+ expect(screen.getByText("Properties")).toBeInTheDocument();
+ expect(screen.getByText("call")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("http")).toBeInTheDocument();
+ expect(screen.getByText("with.endpoint")).toBeInTheDocument();
+ expect(screen.getByDisplayValue("https://api.example.com")).toBeInTheDocument();
+ });
+
+ it.each(modes)("renders property controls as disabled in %s mode", (_mode, isReadOnly) => {
+ renderWithProviders(, { isReadOnly });
+
+ expect(screen.getByDisplayValue("http")).toBeDisabled();
+ expect(screen.getByDisplayValue("https://api.example.com")).toBeDisabled();
+ });
+ });
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a28b2a1..6f37b56c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -120,6 +120,9 @@ catalogs:
react-dom:
specifier: ^19.2.8
version: 19.2.8
+ react-hook-form:
+ specifier: ^7.86.0
+ version: 7.86.0
rimraf:
specifier: ^6.1.3
version: 6.1.3
@@ -297,6 +300,9 @@ importers:
radix-ui:
specifier: 'catalog:'
version: 1.6.7(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react-hook-form:
+ specifier: 'catalog:'
+ version: 7.86.0(react@19.2.8)
sonner:
specifier: 'catalog:'
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -3521,6 +3527,12 @@ packages:
peerDependencies:
react: ^19.2.8
+ react-hook-form@7.86.0:
+ resolution: {integrity: sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ react: ^16.8.0 || ^17 || ^18 || ^19
+
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -6899,6 +6911,10 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
+ react-hook-form@7.86.0(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+
react-is@17.0.2: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8):
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 06c4a81e..fb989021 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -40,6 +40,7 @@ catalog:
radix-ui: ^1.6.5
react: ^19.2.8
react-dom: ^19.2.8
+ react-hook-form: ^7.86.0
rimraf: ^6.1.3
sonner: ^2.0.8
storybook: ^10.5.10