Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ ENHANCEMENTS:
* Update SuperLinter to version 8.3.2 ([#4815](https://github.com/microsoft/AzureTRE/issues/4815))

BUG FIXES:
* Fix workspace property updates - clearing a field to blank in the UI now properly propagates the change to the API ([#ISSUE_NUMBER](https://github.com/microsoft/AzureTRE/issues/ISSUE_NUMBER))
* Replace deprecated `--username` flag with `--client-id` in `az login --identity` commands across all Porter bundles ([#4817](https://github.com/microsoft/AzureTRE/issues/4817))
* Fix deleted workspaces still accessible via URL - get_*_by_id methods now filter out deleted resources ([#4785](https://github.com/microsoft/AzureTRE/issues/4785))
* Fix circular dependancy in base workspace. ([#4756](https://github.com/microsoft/AzureTRE/pull/4756))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Unit tests for ResourceForm logic
// Tests the removeReadOnlyProps function behavior with empty values

import { describe, it, expect } from "vitest";
import { ResourceTemplate } from "../../../models/resourceTemplate";

// Extracted logic from ResourceForm for testing
const removeReadOnlyProps = (data: any, template: ResourceTemplate): any => {
// flatten all the nested properties from across the template into a basic array we can iterate easily
let allProps = {} as any;

const recurseTemplate = (templateFragment: any) => {
Object.keys(templateFragment).forEach((key) => {
if (key === "properties") {
Object.keys(templateFragment[key]).forEach((prop) => {
allProps[prop] = templateFragment[key][prop];
});
}
if (typeof templateFragment[key] === "object" && key !== "if") {
recurseTemplate(templateFragment[key]);
}
});
};

recurseTemplate(template);

// iterate the data payload
for (let prop in data) {
// if the prop isn't in the template, or it's readOnly, delete it
if (!allProps[prop] || allProps[prop].readOnly === true) {
delete data[prop];
}
}

return data;
};

describe("ResourceForm removeReadOnlyProps", () => {
const mockTemplate = {
id: "test-template",
name: "test-template",
type: "object",
description: "Test template",
version: "1.0.0",
title: "Test Template",
resourceType: "workspace" as any,
current: true,
properties: {
display_name: {
type: "string",
title: "Display Name",
readOnly: false,
},
description: {
type: "string",
title: "Description",
readOnly: false,
},
billing_code: {
type: "string",
title: "Billing Code",
readOnly: false,
},
readonly_field: {
type: "string",
title: "Read Only Field",
readOnly: true,
},
},
system_properties: {},
actions: [],
customActions: [],
required: [],
uiSchema: {},
pipeline: {},
} as ResourceTemplate;

it("should keep fields with non-empty values", () => {
const data = {
display_name: "Test Workspace",
description: "A test workspace",
billing_code: "ABC123",
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect(result).toEqual(data);
expect(result.display_name).toBe("Test Workspace");
expect(result.description).toBe("A test workspace");
expect(result.billing_code).toBe("ABC123");
});

it("should keep fields with empty string values", () => {
const data = {
display_name: "Test Workspace",
description: "",
billing_code: "",
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect(result).toEqual(data);
expect(result.display_name).toBe("Test Workspace");
expect(result.description).toBe("");
expect(result.billing_code).toBe("");
expect("description" in result).toBe(true);
expect("billing_code" in result).toBe(true);
});

it("should remove read-only fields", () => {
const data = {
display_name: "Test Workspace",
description: "A test workspace",
readonly_field: "This should be removed",
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect(result.display_name).toBe("Test Workspace");
expect(result.description).toBe("A test workspace");
expect("readonly_field" in result).toBe(false);
});

it("should remove fields not in template", () => {
const data = {
display_name: "Test Workspace",
description: "A test workspace",
extra_field: "This should be removed",
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect(result.display_name).toBe("Test Workspace");
expect(result.description).toBe("A test workspace");
expect("extra_field" in result).toBe(false);
});

it("should handle update scenario with cleared fields", () => {
// Simulate an update where a field that had a value is now cleared
const data = {
display_name: "Updated Workspace",
description: "Updated description",
billing_code: "", // User cleared this field
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect(result.display_name).toBe("Updated Workspace");
expect(result.description).toBe("Updated description");
expect(result.billing_code).toBe("");
expect("billing_code" in result).toBe(true); // Empty string should be kept
});

it("should keep all editable empty fields when updating", () => {
// Scenario: User cleared multiple fields
const data = {
display_name: "",
description: "",
billing_code: "",
};

const result = removeReadOnlyProps({ ...data }, mockTemplate);

expect("display_name" in result).toBe(true);
expect("description" in result).toBe(true);
expect("billing_code" in result).toBe(true);
expect(result.display_name).toBe("");
expect(result.description).toBe("");
expect(result.billing_code).toBe("");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (
) => {
const [template, setTemplate] = useState<any | null>(null);
const [formData, setFormData] = useState({});
const [originalFormData, setOriginalFormData] = useState({});
const [loading, setLoading] = useState(LoadingState.Loading as LoadingState);
const [sendingData, setSendingData] = useState(false);
const apiCall = useAuthApiCall();
Expand All @@ -51,6 +52,7 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (
// if it's an update, populate the form with the props that are available in the template
if (props.updateResource) {
setFormData(props.updateResource.properties);
setOriginalFormData(props.updateResource.properties);
}

const sanitisedTemplate = sanitiseTemplateForRJSF(templateResponse);
Expand Down Expand Up @@ -99,8 +101,27 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (
return data;
};

const getChangedProperties = (current: any, original: any): any => {
const changed: any = {};

// Find properties that have changed
for (const key in current) {
if (current[key] !== original[key]) {
changed[key] = current[key];
}
}

return changed;
};

const createUpdateResource = async (formData: any) => {
const data = removeReadOnlyProps(formData, template);
let data = removeReadOnlyProps(formData, template);

// For updates, only send properties that have changed from the original
if (props.updateResource) {
data = getChangedProperties(data, originalFormData);
}

console.log("parsed payload to send", data);

setSendingData(true);
Expand Down Expand Up @@ -167,6 +188,7 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (
) : (
<Form
omitExtraData={true}
liveOmit={false}
schema={template}
formData={formData}
uiSchema={uiSchema}
Expand All @@ -179,8 +201,6 @@ export const ResourceForm: React.FunctionComponent<ResourceFormProps> = (
);
case LoadingState.Error:
return <ExceptionLayout e={apiError} />;
case LoadingState.Error:
return <ExceptionLayout e={apiError} />;
default:
return (
<div style={{ marginTop: 20 }}>
Expand Down
Loading