Skip to content
Merged
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/form-library-and-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openworkflowspec/diagram-editor": minor
---

Add react-hook-form and split node properties into separate read only and editable branches
1 change: 1 addition & 0 deletions packages/open-workflow-diagram-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"fast-equals": "catalog:",
"js-yaml": "catalog:",
"radix-ui": "catalog:",
"react-hook-form": "catalog:",
"sonner": "catalog:",
"use-sync-external-store": "catalog:"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <ReadOnlyProperties fields={fields} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<K extends DetailField["kind"]> = {
field: Extract<DetailField, { kind: K }>;
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 <Textarea value={field.value} readOnly disabled={isReadOnly} />;
function LongStringControl({ field }: ControlProps<"long-string">) {
return <Textarea value={field.value} readOnly disabled />;
}

function DurationControl({ field, isReadOnly }: ControlProps<"duration">) {
function DurationControl({ field }: ControlProps<"duration">) {
const { t } = useI18n();
return (
<Input
value={field.value}
disabled={isReadOnly}
pattern={ISO_8601_DURATION_REGEX.source}
title={t("sidebar.duration.title")}
disabled
/>
);
}

function ExpressionControl({ field, isReadOnly }: ControlProps<"runtime-expression">) {
function ExpressionControl({ field }: ControlProps<"runtime-expression">) {
return (
<div>
<span className="dec-sidebar-hint-text">Runtime expression</span>
<Input value={field.value} disabled={isReadOnly} />
<Input value={field.value} disabled />
</div>
);
}

function EnumControl({ field, isReadOnly }: ControlProps<"enum">) {
function EnumControl({ field }: ControlProps<"enum">) {
return (
<Combobox value={field.value} disabled={isReadOnly}>
<Combobox value={field.value} disabled>
<ComboboxTrigger>
<ComboboxValue placeholder="Select an option" />
</ComboboxTrigger>
Expand All @@ -80,46 +82,45 @@ function EnumControl({ field, isReadOnly }: ControlProps<"enum">) {
);
}

function TextControl({ value, isReadOnly }: { value: string; isReadOnly: boolean }) {
return <Input value={value} disabled={isReadOnly} />;
function TextControl({ value }: { value: string }) {
return <Input value={value} disabled />;
}

function NumberControl({ value, isReadOnly }: { value: number; isReadOnly: boolean }) {
return <Input type="number" value={value} disabled={isReadOnly} />;
function NumberControl({ value }: { value: number }) {
return <Input type="number" value={value} disabled />;
}

function BooleanControl({ value, isReadOnly }: { value: boolean; isReadOnly: boolean }) {
return <Switch checked={value} disabled={isReadOnly} />;
function BooleanControl({ value }: { value: boolean }) {
return <Switch checked={value} disabled />;
}

export function FieldControl({ field, isReadOnly }: { field: DetailField; isReadOnly: boolean }) {
const props = { isReadOnly };
export function FieldControl({ field }: { field: DetailField }) {
const { t } = useI18n();

switch (field.kind) {
case "long-string":
return <LongStringControl field={field} {...props} />;
return <LongStringControl field={field} />;

case "duration":
return <DurationControl field={field} {...props} />;
return <DurationControl field={field} />;

case "runtime-expression":
return <ExpressionControl field={field} {...props} />;
return <ExpressionControl field={field} />;

case "enum":
return <EnumControl field={field} {...props} />;
return <EnumControl field={field} />;

case "scalar":
if (typeof field.value === "string") {
return <TextControl value={field.value} {...props} />;
return <TextControl value={field.value} />;
}

if (typeof field.value === "number") {
return <NumberControl value={field.value} {...props} />;
return <NumberControl value={field.value} />;
}

if (typeof field.value === "boolean") {
return <BooleanControl value={field.value} {...props} />;
return <BooleanControl value={field.value} />;
}

return String(field.value);
Expand Down
12 changes: 2 additions & 10 deletions packages/open-workflow-diagram-editor/src/side-panel/Fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,12 @@ export function InlineField({ label, value }: { label: string; value: string })
);
}

export function PropertyField({
label,
field,
isReadOnly,
}: {
label: string;
field: DetailField;
isReadOnly: boolean;
}) {
export function PropertyField({ label, field }: { label: string; field: DetailField }) {
return (
<div className="dec-sidebar-prop">
<dt className="dec-sidebar-prop-label">{label}</dt>
<dd className="dec-sidebar-prop-value">
<FieldControl field={field} isReadOnly={isReadOnly} />
<FieldControl field={field} />
</dd>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,18 +30,6 @@ type NodeDetailsViewProps = {
node: RF.Node<BaseNodeData>;
};

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

export function NodeDetailsView({ node }: NodeDetailsViewProps) {
const { t } = useI18n();
const { errors, taskReferences, isReadOnly } = useDiagramEditorContext();
Expand All @@ -65,11 +55,11 @@ export function NodeDetailsView({ node }: NodeDetailsViewProps) {
{fields.length > 0 && (
<>
<SectionHeader label={t("sidebar.sectionProperties")} />
<dl>
{fields.map((field) => (
<FieldRow key={field.path} label={field.path} field={field} isReadOnly={isReadOnly} />
))}
</dl>
{isReadOnly ? (
<ReadOnlyProperties fields={fields} />
) : (
<EditableProperties fields={fields} />
)}
</>
)}
{isReadOnly && task !== undefined && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<dl>
{fields.map((field) => (
<PropertyField key={field.path} label={field.path} field={field} />
))}
</dl>
);
}
Original file line number Diff line number Diff line change
@@ -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(<FieldControl field={field} />);

const control = screen.getByDisplayValue(displayed);
expect(control.tagName).toBe("INPUT");
expect(control).toBeDisabled();
});

it("renders a number scalar as a number input", () => {
renderWithProviders(
<FieldControl field={{ path: "with.port", kind: "scalar", value: 8080 }} />,
);

expect(screen.getByDisplayValue("8080")).toHaveAttribute("type", "number");
});

it("renders a boolean as a disabled switch", () => {
renderWithProviders(
<FieldControl field={{ path: "fork.compete", kind: "scalar", value: true }} />,
);

const control = screen.getByRole("switch");
expect(control).toBeChecked();
expect(control).toBeDisabled();
});
});

it("renders a long string as a disabled textarea", () => {
renderWithProviders(
<FieldControl field={{ path: "run.script.code", kind: "long-string", value: "echo hi" }} />,
);

const control = screen.getByDisplayValue("echo hi");
expect(control.tagName).toBe("TEXTAREA");
expect(control).toBeDisabled();
});

it("constrains a duration to the ISO 8601 format", () => {
renderWithProviders(
<FieldControl field={{ path: "timeout.after", kind: "duration", value: "PT5M" }} />,
);

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(
<FieldControl
field={{
path: "with.output",
kind: "enum",
value: "content",
options: ["raw", "content", "response"],
}}
/>,
);

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(<FieldControl field={{ path: "switch", kind: "array", count }} />);

expect(screen.getByText(expected)).toBeInTheDocument();
});

it("renders an object as a placeholder glyph", () => {
renderWithProviders(<FieldControl field={{ path: "with.headers", kind: "object" }} />);

expect(screen.getByText("{...}")).toBeInTheDocument();
});
});
Loading