diff --git a/src/pages/profiles/EditProfile.tsx b/src/pages/profiles/EditProfile.tsx index 8fdd3c71b..6557d1b2d 100644 --- a/src/pages/profiles/EditProfile.tsx +++ b/src/pages/profiles/EditProfile.tsx @@ -15,22 +15,18 @@ import { queryKeys } from "util/queryKeys"; import { dump as dumpYaml } from "js-yaml"; import { yamlToObject } from "util/yaml"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { FormDeviceValues, formDeviceToPayload } from "util/formDevices"; +import { FormDeviceValues } from "util/formDevices"; import SecurityPoliciesForm, { SecurityPoliciesFormValues, - securityPoliciesPayload, } from "components/forms/SecurityPoliciesForm"; import InstanceSnapshotsForm, { SnapshotFormValues, - snapshotsPayload, } from "components/forms/InstanceSnapshotsForm"; import CloudInitForm, { CloudInitFormValues, - cloudInitPayload, } from "components/forms/CloudInitForm"; import ResourceLimitsForm, { ResourceLimitsFormValues, - resourceLimitsPayload, } from "components/forms/ResourceLimitsForm"; import YamlForm, { YamlFormValues } from "components/forms/YamlForm"; import { updateProfile } from "api/profiles"; @@ -50,17 +46,15 @@ import { updateMaxHeight } from "util/updateMaxHeight"; import DiskDeviceForm from "components/forms/DiskDeviceForm"; import NetworkDevicesForm from "components/forms/NetworkDevicesForm"; import ProfileDetailsForm, { - profileDetailPayload, ProfileDetailsFormValues, } from "pages/profiles/forms/ProfileDetailsForm"; -import { getUnhandledKeyValues } from "util/formFields"; -import { getProfileConfigKeys } from "util/instanceConfigFields"; import { getProfileEditValues } from "util/instanceEdit"; import { slugify } from "util/slugify"; import { hasDiskError, hasNetworkError } from "util/instanceValidation"; import FormFooterLayout from "components/forms/FormFooterLayout"; import { useToastNotification } from "context/toastNotificationProvider"; import { useDocs } from "context/useDocs"; +import { getProfilePayload } from "util/profileEdit"; export type EditProfileFormValues = ProfileDetailsFormValues & FormDeviceValues & @@ -106,7 +100,9 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => { validationSchema: ProfileSchema, onSubmit: (values) => { const profilePayload = ( - values.yaml ? yamlToObject(values.yaml) : getPayload(values) + values.yaml + ? yamlToObject(values.yaml) + : getProfilePayload(profile, values) ) as LxdProfile; // ensure the etag is set (it is missing on the yaml) @@ -129,24 +125,6 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => { }, }); - const getPayload = (values: EditProfileFormValues) => { - const handledConfigKeys = getProfileConfigKeys(); - const handledKeys = new Set(["name", "description", "devices", "config"]); - - return { - ...profileDetailPayload(values), - devices: formDeviceToPayload(values.devices), - config: { - ...resourceLimitsPayload(values), - ...securityPoliciesPayload(values), - ...snapshotsPayload(values), - ...cloudInitPayload(values), - ...getUnhandledKeyValues(profile.config, handledConfigKeys), - }, - ...getUnhandledKeyValues(profile, handledKeys), - }; - }; - const updateSection = (newSection: string) => { const baseUrl = `/ui/project/${project}/profile/${profile.name}/configuration`; newSection === MAIN_CONFIGURATION @@ -160,9 +138,9 @@ const EditProfile: FC = ({ profile, featuresProfiles }) => { const getYaml = () => { const exclude = new Set(["used_by", "etag"]); - const profile = getPayload(formik.values); + const profilePayload = getProfilePayload(profile, formik.values); const bareProfile = Object.fromEntries( - Object.entries(profile).filter((e) => !exclude.has(e[0])), + Object.entries(profilePayload).filter((e) => !exclude.has(e[0])), ); return dumpYaml(bareProfile); }; diff --git a/src/pages/projects/EditProject.tsx b/src/pages/projects/EditProject.tsx index afee14d5a..3955dc249 100644 --- a/src/pages/projects/EditProject.tsx +++ b/src/pages/projects/EditProject.tsx @@ -4,26 +4,15 @@ import { updateProject } from "api/projects"; import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import { PROJECT_DETAILS } from "pages/projects/forms/ProjectFormMenu"; -import { - projectDetailPayload, - projectDetailRestrictionPayload, -} from "pages/projects/forms/ProjectDetailsForm"; import { useFormik } from "formik"; import { ProjectFormValues } from "pages/projects/CreateProject"; import * as Yup from "yup"; import { LxdProject } from "types/project"; import { updateMaxHeight } from "util/updateMaxHeight"; import useEventListener from "@use-it/event-listener"; -import { resourceLimitsPayload } from "pages/projects/forms/ProjectResourceLimitsForm"; -import { clusterRestrictionPayload } from "pages/projects/forms/ClusterRestrictionForm"; -import { instanceRestrictionPayload } from "pages/projects/forms/InstanceRestrictionForm"; -import { deviceUsageRestrictionPayload } from "pages/projects/forms/DeviceUsageRestrictionForm"; -import { networkRestrictionPayload } from "pages/projects/forms/NetworkRestrictionForm"; -import { getProjectEditValues } from "util/projectEdit"; +import { getProjectEditValues, getProjectPayload } from "util/projectEdit"; import { FormikProps } from "formik/dist/types"; import ProjectForm from "pages/projects/forms/ProjectForm"; -import { getUnhandledKeyValues } from "util/formFields"; -import { getProjectConfigKeys } from "util/projectConfigFields"; import ProjectConfigurationHeader from "pages/projects/ProjectConfigurationHeader"; import { useAuth } from "context/auth"; import CustomLayout from "components/CustomLayout"; @@ -71,7 +60,7 @@ const EditProject: FC = ({ project }) => { values.features_storage_buckets = undefined; } - const projectPayload = getPayload(values) as LxdProject; + const projectPayload = getProjectPayload(project, values) as LxdProject; projectPayload.etag = project.etag; @@ -92,29 +81,6 @@ const EditProject: FC = ({ project }) => { }, }); - const getPayload = (values: ProjectFormValues) => { - const handledConfigKeys = getProjectConfigKeys(); - const handledKeys = new Set(["name", "description", "config"]); - - return { - ...projectDetailPayload(values), - config: { - ...projectDetailRestrictionPayload(values), - ...resourceLimitsPayload(values), - ...(values.restricted - ? { - ...clusterRestrictionPayload(values), - ...instanceRestrictionPayload(values), - ...deviceUsageRestrictionPayload(values), - ...networkRestrictionPayload(values), - } - : {}), - ...getUnhandledKeyValues(project.config, handledConfigKeys), - }, - ...getUnhandledKeyValues(project, handledKeys), - }; - }; - const setSection = (newSection: string) => { const baseUrl = `/ui/project/${project.name}/configuration`; newSection === PROJECT_DETAILS diff --git a/src/util/instanceEdit.spec.ts b/src/util/instanceEdit.spec.ts new file mode 100644 index 000000000..1dcc98aa2 --- /dev/null +++ b/src/util/instanceEdit.spec.ts @@ -0,0 +1,67 @@ +import { getInstanceEditValues, getInstancePayload } from "./instanceEdit"; +import { LxdInstance } from "types/instance"; + +describe("conversion to form values and back with getInstanceEditValues and getInstancePayload", () => { + it("preserves custom top level instance setting field", () => { + type CustomPayload = LxdInstance & { "custom-key": string }; + const instance = { + config: {}, + devices: {}, + "custom-key": "custom-value", + } as unknown as LxdInstance; + + const formValues = getInstanceEditValues(instance); + const payload = getInstancePayload(instance, formValues) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom config level instance setting field", () => { + const instance = { + devices: {}, + config: { + "custom-config-key": "custom-config-value", + }, + } as unknown as LxdInstance; + + const formValues = getInstanceEditValues(instance); + const payload = getInstancePayload(instance, formValues); + + expect(payload.config["custom-config-key"]).toBe("custom-config-value"); + }); + + it("preserves limits on instance settings", () => { + const instance = { + devices: {}, + config: { + "limits.memory": "2GB", + "limits.cpu": "2-3", + }, + } as unknown as LxdInstance; + + const formValues = getInstanceEditValues(instance); + const payload = getInstancePayload(instance, formValues); + + expect(payload.config["limits.cpu"]).toBe("2-3"); + expect(payload.config["limits.memory"]).toBe("2GB"); + }); + + it("preserves custom devices on instance settings", () => { + type DevicePayload = LxdInstance & { + devices: { grafananat: { connect: string } }; + }; + const instance = { + config: {}, + devices: { + grafananat: { + connect: "tcp:1.2.3.4:3000", + }, + }, + } as unknown as LxdInstance; + + const formValues = getInstanceEditValues(instance); + const payload = getInstancePayload(instance, formValues) as DevicePayload; + + expect(payload.devices.grafananat.connect).toBe("tcp:1.2.3.4:3000"); + }); +}); diff --git a/src/util/networkForm.spec.ts b/src/util/networkForm.spec.ts new file mode 100644 index 000000000..be2a9ef4b --- /dev/null +++ b/src/util/networkForm.spec.ts @@ -0,0 +1,32 @@ +import { toNetwork } from "pages/networks/forms/NetworkForm"; +import { LxdNetwork } from "types/network"; +import { toNetworkFormValues } from "util/networkForm"; + +describe("conversion to form values and back with toNetworkFormValues and toNetwork", () => { + it("preserves custom top level network setting field", () => { + type CustomPayload = LxdNetwork & { "custom-key": string }; + const network = { + config: {}, + "custom-key": "custom-value", + } as unknown as LxdNetwork; + + const formValues = toNetworkFormValues(network); + const payload = toNetwork(formValues) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom config level network setting field", () => { + const network = { + devices: {}, + config: { + "user.key": "custom-config-value", + }, + } as unknown as LxdNetwork; + + const formValues = toNetworkFormValues(network); + const payload = toNetwork(formValues); + + expect(payload.config?.["user.key"]).toBe("custom-config-value"); + }); +}); diff --git a/src/util/profileEdit.spec.ts b/src/util/profileEdit.spec.ts new file mode 100644 index 000000000..ec1e943bc --- /dev/null +++ b/src/util/profileEdit.spec.ts @@ -0,0 +1,68 @@ +import { getProfileEditValues } from "./instanceEdit"; +import { getProfilePayload } from "util/profileEdit"; +import { LxdProfile } from "types/profile"; + +describe("conversion to form values and back with getProfileEditValues and getProfilePayload", () => { + it("preserves custom top level profile setting field", () => { + type CustomPayload = LxdProfile & { "custom-key": string }; + const profile = { + config: {}, + devices: {}, + "custom-key": "custom-value", + } as unknown as LxdProfile; + + const formValues = getProfileEditValues(profile); + const payload = getProfilePayload(profile, formValues) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom top level profile setting field", () => { + const Profile = { + devices: {}, + config: { + "custom-config-key": "custom-config-value", + }, + } as unknown as LxdProfile; + + const formValues = getProfileEditValues(Profile); + const payload = getProfilePayload(Profile, formValues); + + expect(payload.config["custom-config-key"]).toBe("custom-config-value"); + }); + + it("preserves limits on profile settings", () => { + const Profile = { + devices: {}, + config: { + "limits.memory": "2GB", + "limits.cpu": "2-3", + }, + } as unknown as LxdProfile; + + const formValues = getProfileEditValues(Profile); + const payload = getProfilePayload(Profile, formValues); + + expect(payload.config["limits.cpu"]).toBe("2-3"); + expect(payload.config["limits.memory"]).toBe("2GB"); + }); + + it("preserves custom devices on profile settings", () => { + type DevicePayload = LxdProfile & { + devices: { grafananat: { connect: string } }; + }; + const Profile = { + config: {}, + devices: { + grafananat: { + connect: "tcp:1.2.3.4:3000", + }, + }, + } as unknown as LxdProfile; + + const formValues = getProfileEditValues(Profile); + const payload = getProfilePayload(Profile, formValues) as DevicePayload; + + expect(payload.devices.grafananat.connect).toBe("tcp:1.2.3.4:3000"); + }); +}); diff --git a/src/util/profileEdit.tsx b/src/util/profileEdit.tsx new file mode 100644 index 000000000..272414b27 --- /dev/null +++ b/src/util/profileEdit.tsx @@ -0,0 +1,31 @@ +import { getProfileConfigKeys } from "util/instanceConfigFields"; +import { profileDetailPayload } from "pages/profiles/forms/ProfileDetailsForm"; +import { formDeviceToPayload } from "util/formDevices"; +import { resourceLimitsPayload } from "components/forms/ResourceLimitsForm"; +import { securityPoliciesPayload } from "components/forms/SecurityPoliciesForm"; +import { snapshotsPayload } from "components/forms/InstanceSnapshotsForm"; +import { cloudInitPayload } from "components/forms/CloudInitForm"; +import { getUnhandledKeyValues } from "util/formFields"; +import { EditProfileFormValues } from "pages/profiles/EditProfile"; +import { LxdProfile } from "types/profile"; + +export const getProfilePayload = ( + profile: LxdProfile, + values: EditProfileFormValues, +) => { + const handledConfigKeys = getProfileConfigKeys(); + const handledKeys = new Set(["name", "description", "devices", "config"]); + + return { + ...profileDetailPayload(values), + devices: formDeviceToPayload(values.devices), + config: { + ...resourceLimitsPayload(values), + ...securityPoliciesPayload(values), + ...snapshotsPayload(values), + ...cloudInitPayload(values), + ...getUnhandledKeyValues(profile.config, handledConfigKeys), + }, + ...getUnhandledKeyValues(profile, handledKeys), + }; +}; diff --git a/src/util/projectEdit.spec.ts b/src/util/projectEdit.spec.ts new file mode 100644 index 000000000..77f3708c7 --- /dev/null +++ b/src/util/projectEdit.spec.ts @@ -0,0 +1,31 @@ +import { LxdProject } from "types/project"; +import { getProjectEditValues, getProjectPayload } from "util/projectEdit"; + +describe("conversion to form values and back with getProjectEditValues and getProjectPayload", () => { + it("preserves custom main level field", () => { + type CustomPayload = LxdProject & { "custom-key": string }; + const project = { + config: {}, + "custom-key": "custom-value", + } as unknown as LxdProject; + + const formValues = getProjectEditValues(project); + const payload = getProjectPayload(project, formValues) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom config field", () => { + const project = { + devices: {}, + config: { + "user.key": "custom-config-value", + }, + } as unknown as LxdProject; + + const formValues = getProjectEditValues(project); + const payload = getProjectPayload(project, formValues); + + expect(payload.config?.["user.key"]).toBe("custom-config-value"); + }); +}); diff --git a/src/util/projectEdit.tsx b/src/util/projectEdit.tsx index dae44f4d3..6f131b543 100644 --- a/src/util/projectEdit.tsx +++ b/src/util/projectEdit.tsx @@ -1,6 +1,17 @@ import { LxdProject } from "types/project"; import { ProjectFormValues } from "pages/projects/CreateProject"; import { isProjectWithProfiles } from "./projects"; +import { getProjectConfigKeys } from "util/projectConfigFields"; +import { + projectDetailPayload, + projectDetailRestrictionPayload, +} from "pages/projects/forms/ProjectDetailsForm"; +import { resourceLimitsPayload } from "pages/projects/forms/ProjectResourceLimitsForm"; +import { clusterRestrictionPayload } from "pages/projects/forms/ClusterRestrictionForm"; +import { instanceRestrictionPayload } from "pages/projects/forms/InstanceRestrictionForm"; +import { deviceUsageRestrictionPayload } from "pages/projects/forms/DeviceUsageRestrictionForm"; +import { networkRestrictionPayload } from "pages/projects/forms/NetworkRestrictionForm"; +import { getUnhandledKeyValues } from "util/formFields"; export const getProjectEditValues = ( project: LxdProject, @@ -83,3 +94,29 @@ export const getProjectEditValues = ( restricted_network_zones: project.config["restricted.networks.zones"], }; }; + +export const getProjectPayload = ( + project: LxdProject, + values: ProjectFormValues, +) => { + const handledConfigKeys = getProjectConfigKeys(); + const handledKeys = new Set(["name", "description", "config"]); + + return { + ...projectDetailPayload(values), + config: { + ...projectDetailRestrictionPayload(values), + ...resourceLimitsPayload(values), + ...(values.restricted + ? { + ...clusterRestrictionPayload(values), + ...instanceRestrictionPayload(values), + ...deviceUsageRestrictionPayload(values), + ...networkRestrictionPayload(values), + } + : {}), + ...getUnhandledKeyValues(project.config, handledConfigKeys), + }, + ...getUnhandledKeyValues(project, handledKeys), + }; +}; diff --git a/src/util/storagePoolForm.spec.ts b/src/util/storagePoolForm.spec.ts new file mode 100644 index 000000000..dc86c0903 --- /dev/null +++ b/src/util/storagePoolForm.spec.ts @@ -0,0 +1,32 @@ +import { LxdStoragePool } from "types/storage"; +import { toStoragePoolFormValues } from "util/storagePoolForm"; +import { toStoragePool } from "pages/storage/forms/StoragePoolForm"; + +describe("conversion to form values and back with toStoragePoolFormValues and toStoragePool", () => { + it("preserves custom top level storage pool setting field", () => { + type CustomPayload = LxdStoragePool & { "custom-key": string }; + const pool = { + config: {}, + "custom-key": "custom-value", + } as unknown as LxdStoragePool; + + const formValues = toStoragePoolFormValues(pool); + const payload = toStoragePool(formValues) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom config level storage pool setting field", () => { + const pool = { + devices: {}, + config: { + "user.key": "custom-config-value", + }, + } as unknown as LxdStoragePool; + + const formValues = toStoragePoolFormValues(pool); + const payload = toStoragePool(formValues); + + expect(payload.config?.["user.key"]).toBe("custom-config-value"); + }); +}); diff --git a/src/util/storageVolumeForm.spec.ts b/src/util/storageVolumeForm.spec.ts new file mode 100644 index 000000000..e38efe758 --- /dev/null +++ b/src/util/storageVolumeForm.spec.ts @@ -0,0 +1,36 @@ +import { getStorageVolumeEditValues } from "util/storageVolumeEdit"; +import { volumeFormToPayload } from "pages/storage/forms/StorageVolumeForm"; +import { LxdStorageVolume } from "types/storage"; + +describe("conversion to form values and back with getStorageVolumeEditValues and volumeFormToPayload", () => { + it("preserves custom top level storage volume setting field", () => { + type CustomPayload = LxdStorageVolume & { "custom-key": string }; + const volume = { + config: {}, + "custom-key": "custom-value", + } as unknown as LxdStorageVolume; + + const formValues = getStorageVolumeEditValues(volume); + const payload = volumeFormToPayload( + formValues, + "project-foo", + volume, + ) as CustomPayload; + + expect(payload["custom-key"]).toBe("custom-value"); + }); + + it("preserves custom config level storage volume setting field", () => { + const volume = { + devices: {}, + config: { + "user.key": "custom-config-value", + }, + } as unknown as LxdStorageVolume; + + const formValues = getStorageVolumeEditValues(volume); + const payload = volumeFormToPayload(formValues, "project-foo", volume); + + expect(payload.config?.["user.key"]).toBe("custom-config-value"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 01a16a96c..9ca1618a6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default mergeConfig( sourcemap: "inline", }, test: { + environment: "jsdom", globals: true, include: ["./src/**/*.spec.{ts,tsx}"], coverage: {