Skip to content

Commit aa804aa

Browse files
authored
fix(dashboard): allow editing free-form objects in application forms (#4682)
## What this PR does Free-form object fields, such as NATS `config.merge` and `config.resolver`, get a JSON editor. The form keeps nested objects, arrays and primitive values. Input that is not a JSON object stays visible, and schema validation blocks the create or update request until it is fixed. A JSON object that fails the schema marks the field invalid and points it at the error list. Typed maps keep their key and value editor, and read-only and immutable fields stay protected. Fixes #4676. ### Validation - All 463 console tests in 63 files pass, and so does the TypeScript check. - Tests cover typing into the editor, nested NATS objects next to the typed users editor, input that is not a JSON object, schema errors on a valid object, existing values, immutable fields and free-form objects inside arrays. On the order page they cover a create after a form and YAML round trip, saving an edited object, and blocking input that is not JSON in create and edit mode. - The two schema-error tests fail on the component without the change. ### Screenshots ![Nested JSON](https://github.com/user-attachments/assets/04c95513-4e7d-465a-9c7d-e4384e13a47c) ![Invalid JSON](https://github.com/user-attachments/assets/8cbb0e88-965a-4765-b0f9-9c0ab8c7f065) ### Downstream repositories This change preserves the public resource schema and client contract. The file-level trigger map was reviewed; no companion repository update is required. - [x] No downstream repository is affected by this change - [ ] [cozystack/website](https://github.com/cozystack/website) - follow-up: - [ ] [cozystack/terraform-provider-cozystack](https://github.com/cozystack/terraform-provider-cozystack) - follow-up: - [ ] [cozystack/ansible-cozystack](https://github.com/cozystack/ansible-cozystack) - follow-up: - [ ] [cozystack/ccp](https://github.com/cozystack/ccp) - follow-up: - [ ] [cozystack/talm](https://github.com/cozystack/talm) - follow-up: - [ ] [cozystack/cozyhr](https://github.com/cozystack/cozyhr) - follow-up: - [ ] [cozystack/cozy-proxy](https://github.com/cozystack/cozy-proxy) - follow-up: - [ ] [cozystack/cozystack-telemetry-server](https://github.com/cozystack/cozystack-telemetry-server) - follow-up: - [ ] [cozystack/external-apps-example](https://github.com/cozystack/external-apps-example) - follow-up: - [ ] [cozystack/examples](https://github.com/cozystack/examples) - follow-up: - [ ] [cozystack/community](https://github.com/cozystack/community) - follow-up: ### Release note ```release-note fix(dashboard): allow editing free-form objects in application forms ``` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a JSON editor for configuration fields that accept objects with arbitrary properties. Edit nested values directly, with changes retained when switching between form and YAML views. * **Bug Fixes** * Invalid JSON is flagged and prevents create or update submissions until corrected. The editor supports empty objects, read-only fields, and objects nested in arrays, and makes validation errors accessible. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
2 parents 6f49e5f + 96b3589 commit aa804aa

4 files changed

Lines changed: 288 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { createRef, useState } from "react"
2+
import { describe, it, expect, vi } from "vitest"
3+
import { act, fireEvent, render, screen } from "@testing-library/react"
4+
import type { FieldProps } from "@rjsf/utils"
5+
import { SchemaForm, type SchemaFormHandle } from "./SchemaForm.tsx"
6+
import { FreeformObjectField } from "./FreeformObjectField.tsx"
7+
import { IMMUTABLE_HELP_TEXT } from "../lib/immutable-paths.ts"
8+
9+
const schema = {
10+
type: "object",
11+
properties: {
12+
users: { type: "object", additionalProperties: { type: "object", properties: { password: { type: "string" } } } },
13+
config: { type: "object", properties: {
14+
merge: { type: "object", default: {}, "x-kubernetes-preserve-unknown-fields": true },
15+
resolver: { type: "object", additionalProperties: true },
16+
} },
17+
},
18+
}
19+
20+
function setup(initial: unknown = { users: {}, config: { merge: {}, resolver: {} } }, openAPISchema: unknown = schema, immutableMode?: "enforce") {
21+
const ref = createRef<SchemaFormHandle>()
22+
const changed = vi.fn()
23+
function Controlled() {
24+
const [data, setData] = useState(initial)
25+
return <SchemaForm ref={ref} openAPISchema={JSON.stringify(openAPISchema)} formData={data} onChange={(next) => { changed(next); setData(next) }} immutableMode={immutableMode} />
26+
}
27+
render(<Controlled />)
28+
const validate = () => {
29+
let valid = false
30+
act(() => { valid = ref.current!.validate() })
31+
return valid
32+
}
33+
return { changed, validate }
34+
}
35+
36+
describe("free-form object editing", () => {
37+
it("preserves typed whitespace instead of reformatting each valid keystroke", () => {
38+
const { changed, validate } = setup()
39+
const text = '{ "jetstream": true, "subjects": ["one"] }\n'
40+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: text } })
41+
expect(screen.getByLabelText("merge")).toHaveValue(text)
42+
expect(changed.mock.lastCall![0].config.merge).toEqual({ jetstream: true, subjects: ["one"] })
43+
expect(validate()).toBe(true)
44+
})
45+
46+
it("edits both NATS objects and preserves nested types and the typed users editor", () => {
47+
const { changed, validate } = setup()
48+
const merge = { accounts: { A: { jetstream: { max_streams: 2 }, enabled: false, subjects: ["a", "b"], extra: null } } }
49+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: JSON.stringify(merge) } })
50+
fireEvent.change(screen.getByLabelText("resolver"), { target: { value: '{"type":"full","dir":"/data"}' } })
51+
expect(changed.mock.lastCall![0].config).toEqual({ merge, resolver: { type: "full", dir: "/data" } })
52+
expect(screen.getByPlaceholderText("Enter key name...")).toBeInTheDocument()
53+
expect(validate()).toBe(true)
54+
})
55+
56+
it.each(["{", "", "[]", "null", "12", '"text"'])("blocks submission for %j and recovers after correction", (input) => {
57+
const { validate } = setup()
58+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: input } })
59+
expect(screen.getByLabelText("merge")).toHaveValue(input)
60+
expect(screen.getByText("Enter a valid JSON object.")).toBeInTheDocument()
61+
expect(validate()).toBe(false)
62+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: '{"jetstream":true}' } })
63+
expect(validate()).toBe(true)
64+
})
65+
66+
it("renders existing values and allows replacing them with an empty object", () => {
67+
const { changed, validate } = setup({ config: { merge: { accounts: { A: { jetstream: true } } }, resolver: {} } })
68+
expect(JSON.parse((screen.getByLabelText("merge") as HTMLTextAreaElement).value)).toEqual({ accounts: { A: { jetstream: true } } })
69+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: "{}" } })
70+
expect(changed.mock.lastCall![0].config.merge).toEqual({})
71+
expect(validate()).toBe(true)
72+
})
73+
74+
it("keeps immutable objects disabled with their explanation", () => {
75+
setup({ settings: { enabled: true } }, { type: "object", properties: { settings: { type: "object", additionalProperties: true, "x-kubernetes-validations": [{ rule: "self == oldSelf" }] } } }, "enforce")
76+
expect(screen.getByLabelText("settings")).toBeDisabled()
77+
expect(screen.getByText(IMMUTABLE_HELP_TEXT)).toBeInTheDocument()
78+
})
79+
80+
it("marks a valid JSON object that fails the schema as invalid and points at its errors", () => {
81+
const { validate } = setup({ settings: {} }, { type: "object", properties: { settings: { type: "object", additionalProperties: true, minProperties: 1 } } })
82+
const field = screen.getByLabelText("settings")
83+
expect(field).toHaveAttribute("aria-invalid", "false")
84+
expect(validate()).toBe(false)
85+
expect(field).toHaveAttribute("aria-invalid", "true")
86+
expect(field.getAttribute("aria-describedby")!.split(" ")).toContain("root_settings__error")
87+
expect(document.getElementById("root_settings__error")).toBeInTheDocument()
88+
expect(screen.queryByText("Enter a valid JSON object.")).not.toBeInTheDocument()
89+
fireEvent.change(field, { target: { value: '{"enabled":true}' } })
90+
expect(validate()).toBe(true)
91+
expect(field).toHaveAttribute("aria-invalid", "false")
92+
expect(field.getAttribute("aria-describedby")!.split(" ")).not.toContain("root_settings__error")
93+
})
94+
95+
it("does not point at an error list that hideError keeps from rendering", () => {
96+
const props = {
97+
schema: { type: "object", additionalProperties: true },
98+
formData: {},
99+
onChange: vi.fn(),
100+
idSchema: { $id: "root_settings" },
101+
name: "settings",
102+
required: false,
103+
readonly: false,
104+
disabled: false,
105+
uiSchema: {},
106+
rawErrors: ["must NOT have fewer than 1 properties"],
107+
hideError: true,
108+
registry: {},
109+
} as unknown as FieldProps
110+
render(<FreeformObjectField {...props} />)
111+
const field = screen.getByLabelText("settings")
112+
expect(field).toHaveAttribute("aria-invalid", "true")
113+
expect(field.getAttribute("aria-describedby")!.split(" ")).not.toContain("root_settings__error")
114+
})
115+
116+
it("reaches free-form objects nested in arrays", () => {
117+
const { changed, validate } = setup({ items: [{ options: {} }] }, { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { options: { type: "object", additionalProperties: true } } } } } })
118+
fireEvent.change(screen.getByLabelText("options"), { target: { value: '{"nested":[1,true]}' } })
119+
expect(changed.mock.lastCall![0].items[0].options).toEqual({ nested: [1, true] })
120+
expect(validate()).toBe(true)
121+
})
122+
})
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useState } from "react"
2+
import { errorId } from "@rjsf/utils"
3+
import type { FieldProps } from "@rjsf/utils"
4+
5+
function displayValue(value: unknown) {
6+
return typeof value === "string" ? value : JSON.stringify(value ?? {}, null, 2)
7+
}
8+
9+
export function FreeformObjectField({
10+
schema, formData, onChange, idSchema, name, required, readonly, disabled, uiSchema, rawErrors, hideError,
11+
}: FieldProps) {
12+
const source = displayValue(formData)
13+
const [draft, setDraft] = useState<{ source: string, text: string } | null>(null)
14+
const value = draft?.source === source ? draft.text : source
15+
const invalid = typeof formData === "string"
16+
// A parsed object can still fail the schema, and RJSF lists those errors
17+
// under the field template rather than here.
18+
const hasSchemaErrors = Boolean(rawErrors?.length)
19+
const helpId = `${idSchema.$id}__json_help`
20+
const describedBy = [
21+
helpId,
22+
...(uiSchema?.["ui:help"] ? [`${idSchema.$id}__help`] : []),
23+
...(hasSchemaErrors && !hideError ? [errorId(idSchema)] : []),
24+
].join(" ")
25+
26+
return (
27+
<div className="form-group field">
28+
<label htmlFor={idSchema.$id} className="control-label mb-2 block text-sm font-medium text-slate-700">
29+
{schema.title ?? name}
30+
{required && <span className="required ml-1 text-red-500">*</span>}
31+
</label>
32+
{schema.description && <p className="mb-3 text-xs text-slate-500">{schema.description}</p>}
33+
<textarea
34+
id={idSchema.$id}
35+
value={value}
36+
rows={8}
37+
readOnly={readonly}
38+
disabled={disabled}
39+
spellCheck={false}
40+
aria-invalid={invalid || hasSchemaErrors}
41+
aria-describedby={describedBy}
42+
className="w-full rounded-lg border border-slate-300 bg-white p-3 font-mono text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 disabled:opacity-50"
43+
onChange={(event) => {
44+
const text = event.target.value
45+
const change = (next: unknown) => {
46+
setDraft({ source: displayValue(next), text })
47+
onChange(next)
48+
}
49+
try {
50+
const parsed: unknown = JSON.parse(text)
51+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
52+
change(parsed)
53+
return
54+
}
55+
} catch {
56+
// Keep incomplete input in formData so schema validation blocks submission.
57+
}
58+
change(text)
59+
}}
60+
/>
61+
<p id={helpId} className={invalid ? "mt-1 text-xs text-red-600" : "mt-1 text-xs text-slate-500"}>
62+
{invalid ? "Enter a valid JSON object." : "Enter a JSON object. Nested objects and arrays are supported."}
63+
</p>
64+
</div>
65+
)
66+
}

‎packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { customTemplates, customWidgets } from "./rjsf-registry.ts"
1414
import { addDynamicOptionWidgets } from "../lib/dynamic-options.ts"
1515
import { AdditionalPropertiesField } from "./AdditionalPropertiesField.tsx"
16+
import { FreeformObjectField } from "./FreeformObjectField.tsx"
1617
import { ResourceQuotasField } from "./ResourceQuotasField.tsx"
1718
import { SourceField } from "./SourceField.tsx"
1819
import "./schema-form.css"
@@ -66,6 +67,14 @@ function bindAdditionalProperties(
6667
): UiSchema | undefined {
6768
const node: SchemaNode = fieldSchema
6869

70+
if (
71+
node.type === "object" &&
72+
(!node.properties || Object.keys(node.properties).length === 0) &&
73+
node.additionalProperties === true
74+
) {
75+
return { ...uiNode, "ui:field": "FreeformObjectField" }
76+
}
77+
6978
const isAdditionalPropertiesMap =
7079
node.type === "object" &&
7180
(!node.properties || Object.keys(node.properties).length === 0) &&
@@ -315,6 +324,7 @@ export const SchemaForm = forwardRef<SchemaFormHandle, SchemaFormProps>(function
315324
const customFields = useMemo(
316325
() => ({
317326
AdditionalPropertiesField: AdditionalPropertiesField,
327+
FreeformObjectField: FreeformObjectField,
318328
ResourceQuotasField: ResourceQuotasField,
319329
SourceField: SourceField,
320330
}),
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest"
2+
import { fireEvent, render, screen } from "@testing-library/react"
3+
import userEvent from "@testing-library/user-event"
4+
import { MemoryRouter } from "react-router"
5+
6+
const h = vi.hoisted(() => ({
7+
create: vi.fn(),
8+
update: vi.fn(),
9+
ad: {
10+
spec: {
11+
application: {
12+
kind: "NATS", plural: "natses", singular: "nats",
13+
openAPISchema: JSON.stringify({
14+
type: "object",
15+
properties: {
16+
config: {
17+
type: "object",
18+
properties: {
19+
merge: { type: "object", "x-kubernetes-preserve-unknown-fields": true },
20+
resolver: { type: "object", additionalProperties: true },
21+
},
22+
},
23+
},
24+
}),
25+
},
26+
dashboard: {},
27+
},
28+
},
29+
}))
30+
31+
vi.mock("../lib/tenant-context.tsx", () => ({
32+
useTenantContext: () => ({ tenantNamespace: "tenant-test" }),
33+
}))
34+
vi.mock("../lib/app-definitions.ts", () => ({
35+
useApplicationDefinition: () => ({ data: h.ad, isLoading: false, error: null }),
36+
appDisplayName: () => "NATS", iconDataUrl: () => null,
37+
}))
38+
vi.mock("@cozystack/k8s-client", () => ({
39+
useK8sCreate: () => ({ mutateAsync: h.create, isPending: false }),
40+
useK8sUpdate: () => ({ mutateAsync: h.update, isPending: false }),
41+
K8sApiError: class extends Error {},
42+
}))
43+
vi.mock("../components/YamlEditor.tsx", () => ({
44+
YamlEditor: ({ value }: { value: string }) => <textarea data-testid="yaml" readOnly value={value} />,
45+
}))
46+
47+
const { ApplicationOrderPage } = await import("./ApplicationOrderPage.tsx")
48+
const merge = { accounts: { A: { jetstream: { max_streams: 2 }, subjects: ["one"], enabled: false } } }
49+
50+
describe("ApplicationOrderPage free-form objects", () => {
51+
beforeEach(() => {
52+
h.create.mockReset().mockResolvedValue({})
53+
h.update.mockReset().mockResolvedValue({})
54+
})
55+
56+
it("sends nested objects on create after a form/YAML round trip", async () => {
57+
const user = userEvent.setup()
58+
render(<MemoryRouter><ApplicationOrderPage appNameOverride="nats" /></MemoryRouter>)
59+
await user.type(screen.getByPlaceholderText("nats"), "demo")
60+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: JSON.stringify(merge) } })
61+
fireEvent.change(screen.getByLabelText("resolver"), { target: { value: '{"type":"full"}' } })
62+
await user.click(screen.getByRole("button", { name: /^yaml$/i }))
63+
await screen.findByTestId("yaml")
64+
await user.click(screen.getByRole("button", { name: /^form$/i }))
65+
expect(JSON.parse((screen.getByLabelText("merge") as HTMLTextAreaElement).value)).toEqual(merge)
66+
await user.click(screen.getByRole("button", { name: /^deploy$/i }))
67+
expect(h.create).toHaveBeenCalledOnce()
68+
expect(h.create.mock.calls[0][0].spec.config).toEqual({ merge, resolver: { type: "full" } })
69+
})
70+
71+
it("preserves existing nested settings and saves an edited object", async () => {
72+
const user = userEvent.setup()
73+
render(<MemoryRouter><ApplicationOrderPage appNameOverride="nats" editMode={{ name: "demo", initialSpec: { config: { merge, resolver: { type: "full" } } } }} /></MemoryRouter>)
74+
expect(JSON.parse((screen.getByLabelText("merge") as HTMLTextAreaElement).value)).toEqual(merge)
75+
fireEvent.change(screen.getByLabelText("resolver"), { target: { value: "{}" } })
76+
await user.click(screen.getByRole("button", { name: /^save$/i }))
77+
expect(h.update).toHaveBeenCalledOnce()
78+
expect(h.update.mock.calls[0][0].spec.config).toEqual({ merge, resolver: {} })
79+
})
80+
81+
it.each([false, true])("blocks invalid JSON from the API in edit mode %s", async (edit) => {
82+
const user = userEvent.setup()
83+
render(<MemoryRouter><ApplicationOrderPage appNameOverride="nats" editMode={edit ? { name: "demo", initialSpec: { config: { merge } } } : undefined} /></MemoryRouter>)
84+
if (!edit) await user.type(screen.getByPlaceholderText("nats"), "demo")
85+
fireEvent.change(screen.getByLabelText("merge"), { target: { value: "{" } })
86+
await user.click(screen.getByRole("button", { name: edit ? /^save$/i : /^deploy$/i }))
87+
expect(h.create).not.toHaveBeenCalled()
88+
expect(h.update).not.toHaveBeenCalled()
89+
})
90+
})

0 commit comments

Comments
 (0)