feat(console): mark immutable resource fields as read-only in edit forms#6
feat(console): mark immutable resource fields as read-only in edit forms#6lexfrei wants to merge 6 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request implements immutability enforcement in the SchemaForm component by parsing x-kubernetes-validations and applying ui:disabled and ui:help to immutable paths. It introduces a prepareUpdateSpec utility to ensure immutable values are preserved during updates and adds Vitest for testing. The review feedback highlights improvements for the array overlay logic to prevent potential null-value issues, suggests consolidating utility functions like isPlainObject and deepClone for better consistency, and recommends cleaning up redundant code in the update preparation logic.
| const sourceArr = Array.isArray(source) ? source : [] | ||
| const targetArr = Array.isArray(target) ? target : [] | ||
| const len = Math.max(sourceArr.length, targetArr.length) | ||
| const out: unknown[] = [] | ||
| for (let i = 0; i < len; i++) { | ||
| if (i >= sourceArr.length) { | ||
| out[i] = deepClone(targetArr[i]) | ||
| } else { | ||
| out[i] = overlayPath(targetArr[i], sourceArr[i], path, depth + 1) | ||
| } | ||
| } | ||
| return out |
There was a problem hiding this comment.
The current array overlay logic can produce arrays with undefined elements (holes) when the source array is longer than the target array (e.g., when an item is deleted). When serialized to JSON, these holes become null, which may violate the schema or cause unexpected behavior on the API server. Using targetArr.map is a safer and more concise way to handle positional overlays while correctly respecting deletions and additions.
const sourceArr = Array.isArray(source) ? source : []
const targetArr = Array.isArray(target) ? target : []
return targetArr.map((item, i) =>
i < sourceArr.length
? overlayPath(item, sourceArr[i], path, depth + 1)
: deepClone(item),
)| const isPlainObject = (v: unknown): v is Record<string, unknown> => | ||
| v !== null && typeof v === "object" && !Array.isArray(v) |
There was a problem hiding this comment.
Export isPlainObject so it can be reused in SchemaForm.tsx and deepClone for better consistency and code reuse.
| const isPlainObject = (v: unknown): v is Record<string, unknown> => | |
| v !== null && typeof v === "object" && !Array.isArray(v) | |
| export const isPlainObject = (v: unknown): v is Record<string, unknown> => | |
| v !== null && typeof v === "object" && !Array.isArray(v) |
| function deepClone<T>(value: T): T { | ||
| if (value === null || value === undefined) return value | ||
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | ||
| if (typeof value === "object") { | ||
| const out: Record<string, unknown> = {} | ||
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | ||
| out[k] = deepClone(v) | ||
| } | ||
| return out as unknown as T | ||
| } | ||
| return value | ||
| } |
There was a problem hiding this comment.
Export deepClone to allow its reuse in prepare-update.ts, and use the isPlainObject helper for a cleaner and more consistent implementation.
| function deepClone<T>(value: T): T { | |
| if (value === null || value === undefined) return value | |
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | |
| if (typeof value === "object") { | |
| const out: Record<string, unknown> = {} | |
| for (const [k, v] of Object.entries(value as Record<string, unknown>)) { | |
| out[k] = deepClone(v) | |
| } | |
| return out as unknown as T | |
| } | |
| return value | |
| } | |
| export function deepClone<T>(value: T): T { | |
| if (value === null || value === undefined) return value | |
| if (Array.isArray(value)) return value.map(deepClone) as unknown as T | |
| if (isPlainObject(value)) { | |
| const out: Record<string, unknown> = {} | |
| for (const [k, v] of Object.entries(value)) { | |
| out[k] = deepClone(v) | |
| } | |
| return out as unknown as T | |
| } | |
| return value | |
| } |
| import { | ||
| findImmutablePaths, | ||
| overlayImmutable, | ||
| type ImmutablePath, | ||
| } from "./immutable-paths.ts" |
There was a problem hiding this comment.
Add deepClone to the imports from ./immutable-paths.ts to replace the redundant cloneShallowAsDeep function.
| import { | |
| findImmutablePaths, | |
| overlayImmutable, | |
| type ImmutablePath, | |
| } from "./immutable-paths.ts" | |
| import { | |
| findImmutablePaths, | |
| overlayImmutable, | |
| deepClone, | |
| type ImmutablePath, | |
| } from "./immutable-paths.ts" |
| openAPISchema: string, | ||
| ): T { | ||
| if (original === undefined || original === null) { | ||
| return cloneShallowAsDeep(submitted) |
| function cloneShallowAsDeep<T>(value: T): T { | ||
| if (value === null || value === undefined) return value | ||
| return JSON.parse(JSON.stringify(value)) as T | ||
| } |
| import { | ||
| IMMUTABLE_HELP_TEXT, | ||
| findImmutablePaths, | ||
| type ImmutablePath, | ||
| } from "../lib/immutable-paths.ts" |
There was a problem hiding this comment.
Add isPlainObject to the imports from ../lib/immutable-paths.ts.
| import { | |
| IMMUTABLE_HELP_TEXT, | |
| findImmutablePaths, | |
| type ImmutablePath, | |
| } from "../lib/immutable-paths.ts" | |
| import { | |
| IMMUTABLE_HELP_TEXT, | |
| findImmutablePaths, | |
| isPlainObject, | |
| type ImmutablePath, | |
| } from "../lib/immutable-paths.ts" |
| key: string, | ||
| ): Record<string, unknown> { | ||
| const existing = uiNode[key] | ||
| if (existing && typeof existing === "object" && !Array.isArray(existing)) { |
5727ac1 to
4416ec3
Compare
Introduce pure helpers used to recognise and enforce Kubernetes-style
immutability rules on resource schemas.
findImmutablePaths(schema) walks an OpenAPI/JSON-schema tree and
returns every path whose subschema carries an
x-kubernetes-validations entry with rule "self == oldSelf". Object
properties contribute named segments; array items and
additionalProperties contribute the wildcard segment "*". oneOf,
anyOf and allOf branches are considered when determining whether a
node is immutable.
overlayImmutable(submitted, original, paths) deep-clones the submitted
value, then for each path copies the value from original. Wildcard
semantics dispatch on the runtime value shape:
- *-last on an array: replace with a clone of source (whole-array
immutable).
- *-last on an object map: replace with a clone of source (whole-map
immutable).
- *-not-last on an array: iterate the user's array, restore the
immutable nested subfield from source at shared indices, keep
user-added entries verbatim. Length shrinks are detected and the
overlay is skipped with a console.warn so admission stays the
authoritative enforcer (index-aligned overlay would otherwise
silently swap which element the user deleted).
- *-not-last on an object map: freeze the whole map. The UI marks
such fields ui:disabled (Add/Remove hidden); the overlay matches
so YAML-editor and devtools bypasses can't add or rename keys.
A root-level immutable path or a top-level wildcard immutable path
(both pathological schema shapes) replaces submitted wholesale with a
clone of original and logs a warning.
IMMUTABLE_HELP_TEXT centralises the user-facing string consumed by
later UI wiring.
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
AJV has no CEL evaluator, so the rule does nothing for JSON-Schema validation; the UI consults the raw schema once to harvest immutability paths and then drops the extension so RJSF traversal stays small. Also add characterization tests for keysOrderToUiSchema and sanitizeSchema so existing behaviour (int-or-string coercion, preserve-unknown-fields → additionalProperties, "Chart Values" → "Parameters") is locked in alongside the new strip. Widen keysOrderToUiSchema's parameter to ReadonlyArray<ReadonlyArray<string>> so call sites can pass immutable-path-style arrays without casts. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
b41f2aa to
55c8e1e
Compare
Add an immutableMode prop ("enforce" | "off", default "off"). When
"enforce", every immutable schema path (as reported by
findImmutablePaths) receives ui:disabled=true plus a ui:help string
sourced from IMMUTABLE_HELP_TEXT. RJSF then renders the corresponding
inputs greyed out and surfaces the helper text underneath. The default
keeps every field editable so create flows are unaffected.
Wildcard segments map to RJSF's "items" key for arrays; for object
maps (additionalProperties) the field itself is marked disabled and
the existing AdditionalPropertiesField renderer propagates that to
the nested form, hiding Add/Remove controls.
Parse the raw schema once and derive both the sanitised RJSFSchema
and the immutable-path set from the shared parsed object so the JSON
string isn't decoded twice per render.
Add component-level tests covering:
- enforce / off / omitted prop branches on scalar fields,
- per-element-nested-immutable fields inside array items,
- whole-array immutable shape,
- the deliberate UI/overlay asymmetry on additionalProperties
object maps (the whole map is frozen in both UI and overlay).
Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
ApplicationOrderPage and BackupResourceEditPage now opt their SchemaForm into immutableMode="enforce" when in edit flow so the relevant fields render greyed out with helper text. On submit, prepareUpdateSpec walks the schema for immutable paths and copies the original values from the persisted spec into the outgoing body. The API server therefore observes no delta on those paths and the CEL immutability check is not triggered, even if the user managed to bypass the read-only UI (YAML editor, devtools, RJSF bug). Both pages anchor the overlay source to a mount-time snapshot stored in a ref so a background React-Query refetch can't sneak a different immutable value into the PUT. The snapshot capture lives inside useEffect to keep render side-effect-free. prepareUpdateSpec is a thin pure wrapper around findImmutablePaths + overlayImmutable. It short-circuits to a deep clone when the persisted spec is null/undefined (avoids blanking immutable fields with undefined) and logs a console.warn when the schema string fails to parse instead of silently disabling enforcement. BackupResourceEditPage.handleSubmit also refuses to save when the CRD schema hasn't loaded yet, mirroring the existing !resource guard. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
The workspace already configures vitest + jsdom + @testing-library in apps/console; the README mentioned only install/dev/build. Add a one-line Test section pointing at the workspace-wide "pnpm test". Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
pnpm/action-setup@v4 fails when the workspace doesn't pin a pnpm version anywhere. Declare it in the standard packageManager field on the root package.json so both the Test workflow and any local contributor's corepack pick up the same version we develop against. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
55c8e1e to
d51f984
Compare
Summary
Resource fields declared immutable in the schema via
x-kubernetes-validations: [{rule: "self == oldSelf"}]are now:Create flows are untouched.
Acceptance criterion
The companion cozystack/cozystack#2639 marks MariaDB
storageClassimmutable (itself dependent on cozystack/cozyvalues-gen#24). With all three PRs in place, opening an existing MariaDB instance for edit grays outstorageClass, shows the helper text, keeps the rest editable, and the outgoing PUT carries the originalstorageClassvalue.What's in this PR
apps/console/src/lib/immutable-paths.ts:findImmutablePathswalks the schema tree and returns every path carrying the CEL rule.overlayImmutablecopies the original spec's values along those paths into the submitted body. Wildcard semantics: whole-array/whole-map (*-last) → clone from source; per-element nested (*-not-last) → overlay shared indices/keys, keep user adds, respect deletes. Length shrink with a nested-immutable path bails out with a console.warn (admission stays authoritative). Top-level wildcard paths are handled like root-immutable: warn + clone source.apps/console/src/lib/prepare-update.ts— pure wrapper; warns on malformed schema; short-circuits to a clone when persisted spec is null/undefined.SchemaFormacceptsimmutableMode(enforce | off, default off). When enforcing, walks the schema and emitsui:disabled+ui:helpper immutable path. For object maps (additionalProperties) the field itself is marked disabled — per-entry-value semantics is deliberately deferred.sanitizeSchemastripsx-kubernetes-validationsonce it has been harvested. AJV has no CEL evaluator, so the rule is pure noise to the form validator.ApplicationOrderPageandBackupResourceEditPageopt in to enforcement on edit and anchor the overlay source to a mount-time snapshot so a background React-Query refetch can't sneak a different immutable value into the PUT.Known limitations (pinned with FIXME tests + tracking issues)
propertiesbeneathoneOf/anyOf/allOfbranches.Tests
The console workspace already had vitest + jsdom + @testing-library/react on main; this PR adds 7 new test files (immutable-paths walker, overlay edges, prepare-update, SchemaForm immutable rendering, AdditionalPropertiesField asymmetry, keys-order characterization) totalling 85 passing tests.
pnpm testruns them all.Tested manually against a hand-edited MariaDB
values.schema.jsoncarrying the rule onstorageClass:No-op until cozystack ships the schema annotation
When no schema carries the rule,
findImmutablePathsreturns[]everywhere, all walkers are no-ops, the overlay is identity, edit pages behave exactly as today. The UI lights up resource-by-resource as upstream annotates immutable fields.