diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts index 63f4bb66a..5a993c4db 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.test.ts @@ -415,6 +415,17 @@ describe("overlayImmutable", () => { expect(result).toEqual({ spec: { storage: "keep-me" } }) }) + it("leaves a null ancestor alone when original has no leaf to restore", () => { + // Materialising on the ancestor merely existing would trade the user's + // explicit null for an empty object with nothing put back into it. + const submitted = { spec: null } as Record + const original = { spec: { storage: { size: "10Gi" } } } + const result = overlayImmutable(submitted, original, [ + ["spec", "storage", "storageClass"], + ]) + expect(result).toEqual({ spec: null }) + }) + it("array reordering by the user with index-aligned overlay re-anchors source values to the new index (pinned current behaviour)", () => { // Tracked in cozystack/cozystack-ui#10. // The overlay walks by index, not by content identity. Reordering an diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts index 2f6d1bc31..45a6ea6c9 100644 --- a/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts +++ b/packages/system/dashboard/images/console/apps/console/src/lib/immutable-paths.ts @@ -153,6 +153,36 @@ function wildcardArrayLengthChanged( return false } +/** + * Whether source still holds a value at the end of the remaining path. + * Materialising a missing intermediate object is only justified when there is + * a leaf to restore into it — keyed on the ancestor merely existing, an + * explicit null the user parked at that ancestor would be replaced by an empty + * object with nothing put back. + */ +function sourceHasLeaf( + source: unknown, + path: ImmutablePath, + depth: number, +): boolean { + if (depth === path.length) return source !== undefined + const seg = path[depth] + if (seg === "*") { + // A trailing wildcard freezes the whole collection, so the collection + // being present is itself the value to restore. + if (depth === path.length - 1) return source !== undefined + if (Array.isArray(source)) { + return source.some((v) => sourceHasLeaf(v, path, depth + 1)) + } + if (isPlainObject(source)) { + return Object.values(source).some((v) => sourceHasLeaf(v, path, depth + 1)) + } + return false + } + if (!isPlainObject(source)) return false + return sourceHasLeaf(source[seg], path, depth + 1) +} + function overlayPath( target: unknown, source: unknown, @@ -175,8 +205,11 @@ function overlayPath( const targetObj = isPlainObject(target) ? (target as Record) : // A YAML edit can drop the whole intermediate object; materialise it so - // the immutable leaf underneath is still restored from source. - sourceVal !== undefined && (target === undefined || target === null) + // the immutable leaf underneath is still restored from source. Only when + // source actually has that leaf, so a null the user parked here is not + // traded for an empty object holding nothing. + (target === undefined || target === null) && + sourceHasLeaf(sourceVal, path, depth + 1) ? {} : null if (!targetObj) return target