diff --git a/dashboard/package.json b/dashboard/package.json index 7b4c8f23515..dc606132daa 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -50,9 +50,7 @@ "protobufjs": "^7.1.2", "qs": "^6.11.0", "react": "^17.0.2", - "react-compound-slider": "^3.4.0", "react-copy-to-clipboard": "^5.1.0", - "react-dom": "^17.0.2", "react-helmet": "^6.1.0", "react-intl": "^6.1.1", "react-markdown": "^8.0.3", @@ -61,9 +59,6 @@ "react-redux": "^7.2.9", "react-router-dom": "^5.3.0", "react-router-hash-link": "^2.4.3", - "react-switch": "^7.0.0", - "react-tabs": "^5.1.0", - "react-test-renderer": "^17.0.2", "react-tooltip": "^4.2.21", "react-transition-group": "^4.4.5", "redux": "^4.2.0", @@ -113,7 +108,9 @@ "postcss": "^8.4.16", "postcss-scss": "^4.0.5", "prettier": "^2.7.1", + "react-dom": "^17.0.2", "react-scripts": "^5.0.1", + "react-test-renderer": "^17.0.2", "redux-mock-store": "^1.5.4", "sass": "^1.55.0", "shx": "^0.3.4", diff --git a/dashboard/src/actions/installedpackages.ts b/dashboard/src/actions/installedpackages.ts index 3ad8503ae90..a4b56702e70 100644 --- a/dashboard/src/actions/installedpackages.ts +++ b/dashboard/src/actions/installedpackages.ts @@ -27,7 +27,7 @@ import { import { getPluginsSupportingRollback } from "shared/utils"; import { ActionType, deprecated } from "typesafe-actions"; import { InstalledPackage } from "../shared/InstalledPackage"; -import { validate } from "../shared/schema"; +import { validateSchema, validateValuesSchema } from "../shared/schema"; import { handleErrorAction } from "./auth"; const { createAction } = deprecated; @@ -234,11 +234,20 @@ export function installPackage( dispatch(requestInstallPackage()); try { if (values && schema) { - const validation = validate(values, schema); + const schemaValidation = validateSchema(schema); + if (!schemaValidation.valid) { + const errorText = schemaValidation?.errors + ?.map(e => ` - ${e.instancePath}: ${e.message}`) + .join("\n"); + throw new UnprocessableEntityError( + `The schema for this package is not valid. Please contact the package author. The following errors were found:\n${errorText}`, + ); + } + const validation = validateValuesSchema(values, schema); if (!validation.valid) { - const errorText = - validation.errors && - validation.errors.map(e => ` - ${e.instancePath}: ${e.message}`).join("\n"); + const errorText = validation?.errors + ?.map(e => ` - ${e.instancePath}: ${e.message}`) + .join("\n"); throw new UnprocessableEntityError( `The given values don't match the required format. The following errors were found:\n${errorText}`, ); @@ -283,11 +292,20 @@ export function updateInstalledPackage( dispatch(requestUpdateInstalledPackage()); try { if (values && schema) { - const validation = validate(values, schema); + const schemaValidation = validateSchema(schema); + if (!schemaValidation.valid) { + const errorText = schemaValidation?.errors + ?.map(e => ` - ${e.instancePath}: ${e.message}`) + .join("\n"); + throw new UnprocessableEntityError( + `The schema for this package is not valid. Please contact the package author. The following errors were found:\n${errorText}`, + ); + } + const validation = validateValuesSchema(values, schema); if (!validation.valid) { - const errorText = - validation.errors && - validation.errors.map(e => ` - ${e.instancePath}: ${e.message}`).join("\n"); + const errorText = validation?.errors + ?.map(e => ` - ${e.instancePath}: ${e.message}`) + .join("\n"); throw new UnprocessableEntityError( `The given values don't match the required format. The following errors were found:\n${errorText}`, ); diff --git a/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx b/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx index afe22898242..768471ae523 100644 --- a/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx +++ b/dashboard/src/components/AppUpgrade/AppUpgrade.test.tsx @@ -131,7 +131,7 @@ beforeEach(() => { })), }); - // mock the window.ResizeObserver, required by the MonacoEditor for the layout + // mock the window.ResizeObserver, required by the MonacoDiffEditor for the layout Object.defineProperty(window, "ResizeObserver", { writable: true, configurable: true, @@ -142,7 +142,7 @@ beforeEach(() => { })), }); - // mock the window.HTMLCanvasElement.getContext(), required by the MonacoEditor for the layout + // mock the window.HTMLCanvasElement.getContext(), required by the MonacoDiffEditor for the layout Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { writable: true, configurable: true, diff --git a/dashboard/src/components/AppView/AppView.tsx b/dashboard/src/components/AppView/AppView.tsx index 9e1c4f9787e..36933ae6e1d 100644 --- a/dashboard/src/components/AppView/AppView.tsx +++ b/dashboard/src/components/AppView/AppView.tsx @@ -404,6 +404,7 @@ export default function AppView() {
{ })), }); - // mock the window.ResizeObserver, required by the MonacoEditor for the layout + // mock the window.ResizeObserver, required by the MonacoDiffEditor for the layout Object.defineProperty(window, "ResizeObserver", { writable: true, configurable: true, @@ -90,7 +90,7 @@ beforeEach(() => { })), }); - // mock the window.HTMLCanvasElement.getContext(), required by the MonacoEditor for the layout + // mock the window.HTMLCanvasElement.getContext(), required by the MonacoDiffEditor for the layout Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { writable: true, configurable: true, diff --git a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx index 571f0a6d811..42946281a9f 100644 --- a/dashboard/src/components/DeploymentForm/DeploymentForm.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentForm.tsx @@ -10,6 +10,7 @@ import AvailablePackageDetailExcerpt from "components/Catalog/AvailablePackageDe import Alert from "components/js/Alert"; import Column from "components/js/Column"; import Row from "components/js/Row"; +import LoadingWrapper from "components/LoadingWrapper"; import PackageHeader from "components/PackageHeader/PackageHeader"; import { push } from "connected-react-router"; import { @@ -17,18 +18,16 @@ import { ReconciliationOptions, } from "gen/kubeappsapis/core/packages/v1alpha1/packages"; import { Plugin } from "gen/kubeappsapis/core/plugins/v1alpha1/plugins"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import * as ReactRouter from "react-router-dom"; -import "react-tabs/style/react-tabs.css"; import { Action } from "redux"; import { ThunkDispatch } from "redux-thunk"; import { Kube } from "shared/Kube"; import { FetchError, IStoreState } from "shared/types"; import * as url from "shared/url"; import { getPluginsRequiringSA, k8sObjectNameRegex } from "shared/utils"; -import DeploymentFormBody from "../DeploymentFormBody/DeploymentFormBody"; -import LoadingWrapper from "../LoadingWrapper/LoadingWrapper"; +import DeploymentFormBody from "./DeploymentFormBody"; interface IRouteParams { cluster: string; namespace: string; @@ -63,6 +62,7 @@ export default function DeploymentForm() { const [valuesModified, setValuesModified] = useState(false); const [serviceAccountList, setServiceAccountList] = useState([] as string[]); const [reconciliationOptions, setReconciliationOptions] = useState({} as ReconciliationOptions); + const formRef = useRef(null); const error = apps.error || selectedPackage.error; @@ -209,7 +209,7 @@ export default function DeploymentForm() { {error && An error occurred: {error.message}} -
+
diff --git a/dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.test.tsx similarity index 75% rename from dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.test.tsx rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.test.tsx index 0c6aad786ef..6faacb4c860 100644 --- a/dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.test.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.test.tsx @@ -1,7 +1,6 @@ // Copyright 2021-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 -import MonacoEditor from "react-monaco-editor"; import { SupportedThemes } from "shared/Config"; import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper"; import { IStoreState } from "shared/types"; @@ -24,7 +23,7 @@ beforeEach(() => { })), }); - // mock the window.ResizeObserver, required by the MonacoEditor for the layout + // mock the window.ResizeObserver, required by the MonacoDiffEditor for the layout Object.defineProperty(window, "ResizeObserver", { writable: true, configurable: true, @@ -35,7 +34,7 @@ beforeEach(() => { })), }); - // mock the window.HTMLCanvasElement.getContext(), required by the MonacoEditor for the layout + // mock the window.HTMLCanvasElement.getContext(), required by the MonacoDiffEditor for the layout Object.defineProperty(HTMLCanvasElement.prototype, "getContext", { writable: true, configurable: true, @@ -51,19 +50,24 @@ afterEach(() => { const defaultProps = { handleValuesChange: jest.fn(), + valuesFromTheDeployedPackage: "", + valuesFromTheAvailablePackage: "", + deploymentEvent: "", + valuesFromTheParentContainer: "", }; +// eslint-disable-next-line jest/no-focused-tests it("includes values", () => { const wrapper = mountWrapper( defaultStore, - , + , ); - expect(wrapper.find(MonacoEditor).prop("value")).toBe("foo: bar"); + expect(wrapper.find("MonacoDiffEditor").prop("value")).toBe("foo: bar"); }); it("sets light theme by default", () => { const wrapper = mountWrapper(defaultStore, ); - expect(wrapper.find(MonacoEditor).prop("theme")).toBe("light"); + expect(wrapper.find("MonacoDiffEditor").prop("theme")).toBe("light"); }); it("changes theme", () => { @@ -71,5 +75,5 @@ it("changes theme", () => { getStore({ config: { theme: SupportedThemes.dark } } as Partial), , ); - expect(wrapper.find(MonacoEditor).prop("theme")).toBe("vs-dark"); + expect(wrapper.find("MonacoDiffEditor").prop("theme")).toBe("vs-dark"); }); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.tsx new file mode 100644 index 00000000000..a823f43850b --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/AdvancedDeploymentForm.tsx @@ -0,0 +1,233 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsRadio, CdsRadioGroup } from "@cds/react/radio"; +import Column from "components/js/Column"; +import Row from "components/js/Row"; +import monaco from "monaco-editor/esm/vs/editor/editor.api"; // for types only +import { useEffect, useState } from "react"; +import { MonacoDiffEditor } from "react-monaco-editor"; +import { useSelector } from "react-redux"; +import { IStoreState } from "shared/types"; + +export interface IAdvancedDeploymentForm { + valuesFromTheParentContainer?: string; + handleValuesChange: (value: string) => void; + children?: JSX.Element; + valuesFromTheDeployedPackage: string; + valuesFromTheAvailablePackage: string; + deploymentEvent: string; +} + +export default function AdvancedDeploymentForm(props: IAdvancedDeploymentForm) { + const { + config: { theme }, + } = useSelector((state: IStoreState) => state); + const { + handleValuesChange, + valuesFromTheParentContainer, + valuesFromTheDeployedPackage, + valuesFromTheAvailablePackage, + deploymentEvent, + } = props; + + const [usePackageDefaults, setUsePackageDefaults] = useState( + deploymentEvent === "upgrade" ? false : true, + ); + const [useDiffEditor, setUseDiffEditor] = useState(true); + const [diffValues, setDiffValues] = useState(valuesFromTheAvailablePackage); + + const diffEditorOptions = { + renderSideBySide: false, + automaticLayout: true, + }; + + const onChange = (value: string | undefined, _ev: any) => { + // debouncing is not required as the diff calculation happens in a webworker + handleValuesChange(value || ""); + }; + + useEffect(() => { + if (!useDiffEditor) { + setDiffValues(valuesFromTheParentContainer || ""); + } else if (!usePackageDefaults) { + setDiffValues(valuesFromTheDeployedPackage); + } else { + setDiffValues(valuesFromTheAvailablePackage); + } + }, [ + usePackageDefaults, + useDiffEditor, + valuesFromTheAvailablePackage, + valuesFromTheDeployedPackage, + valuesFromTheParentContainer, + ]); + + const editorDidMount = (editor: monaco.editor.IStandaloneDiffEditor, m: typeof monaco) => { + // Add "go to the next change" action + editor.addAction({ + id: "goToNextChange", + label: "Go to the next change", + keybindings: [m.KeyMod.Alt | m.KeyCode.KeyG], + contextMenuGroupId: "9_cutcopypaste", + run: () => { + const lineChanges = editor?.getLineChanges() as monaco.editor.ILineChange[]; + lineChanges.some(lineChange => { + const currentPosition = editor?.getPosition() as monaco.Position; + if (currentPosition.lineNumber < lineChange.modifiedEndLineNumber) { + // Set the cursor to the next change + editor?.setPosition({ + lineNumber: lineChange.modifiedEndLineNumber, + column: 1, + }); + // Scroll to the next change + editor?.revealPositionInCenter({ + lineNumber: lineChange.modifiedEndLineNumber, + column: 1, + }); + // Return true to stop the loop + return true; + } + return false; + }); + }, + }); + // Add "go to the previous change" action + editor.addAction({ + id: "goToPreviousChange", + label: "Go to the previous change", + keybindings: [m.KeyMod.Alt | m.KeyCode.KeyF], + contextMenuGroupId: "9_cutcopypaste", + run: () => { + const lineChanges = editor?.getLineChanges() as monaco.editor.ILineChange[]; + lineChanges.some(lineChange => { + const currentPosition = editor?.getPosition() as monaco.Position; + if (currentPosition.lineNumber > lineChange.modifiedEndLineNumber) { + // Set the cursor to the next change + editor?.setPosition({ + lineNumber: lineChange.modifiedEndLineNumber, + column: 1, + }); + // Scroll to the next change + editor?.revealPositionInCenter({ + lineNumber: lineChange.modifiedEndLineNumber, + column: 1, + }); + // Return true to stop the loop + return true; + } + return false; + }); + }, + }); + + // Add the "toggle deployed/package default values" action + if (deploymentEvent === "upgrade") { + editor.addAction({ + id: "useDefaultsFalse", + label: "Use default values", + keybindings: [m.KeyMod.Alt | m.KeyCode.KeyD], + contextMenuGroupId: "9_cutcopypaste", + run: () => { + setUsePackageDefaults(false); + }, + }); + editor.addAction({ + id: "useDefaultsTrue", + label: "Use package values", + keybindings: [m.KeyMod.Alt | m.KeyCode.KeyV], + contextMenuGroupId: "9_cutcopypaste", + run: () => { + setUsePackageDefaults(true); + }, + }); + } + }; + + return ( +
+ <> + + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + { + setUseDiffEditor(e.target.checked); + }} + /> + + + + { + setUseDiffEditor(!e.target.checked); + }} + /> + + + + {deploymentEvent === "upgrade" ? ( + <> + + + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + + { + setUsePackageDefaults(e.target.checked); + }} + /> + + + + { + setUsePackageDefaults(!e.target.checked); + }} + /> + + + + + ) : ( + <> + )} + + +
+ +
+ ); +} diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/index.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/index.tsx new file mode 100644 index 00000000000..b37eb768eb1 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/AdvancedDeploymentForm/index.tsx @@ -0,0 +1,6 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import AdvancedDeploymentForm from "./AdvancedDeploymentForm"; + +export default AdvancedDeploymentForm; diff --git a/dashboard/src/components/Slider/index.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss similarity index 58% rename from dashboard/src/components/Slider/index.tsx rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss index d857987a910..8d0d0e36043 100644 --- a/dashboard/src/components/Slider/index.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss @@ -1,6 +1,7 @@ // Copyright 2019-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 -import Slider from "./Slider"; - -export default Slider; +.deployment-form { + margin-top: 1rem; + margin-bottom: 1rem; +} diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx new file mode 100644 index 00000000000..c32151daeb6 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx @@ -0,0 +1,397 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { mount } from "enzyme"; +import { DeploymentEvent, IBasicFormParam } from "shared/types"; +import BasicDeploymentForm, { IBasicDeploymentFormProps } from "./BasicDeploymentForm"; + +jest.useFakeTimers(); + +const defaultProps = { + deploymentEvent: "install" as DeploymentEvent, + handleBasicFormParamChange: jest.fn(() => jest.fn()), + saveAllChanges: jest.fn(), + isLoading: false, + paramsFromComponentState: [], +} as IBasicDeploymentFormProps; + +[ + { + description: "renders a basic deployment with a username", + params: [ + { + key: "wordpressUsername", + currentValue: "user", + defaultValue: "user", + deployedValue: "user", + hasProperties: false, + title: "Username", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a password", + params: [ + { + key: "wordpressPassword", + currentValue: "sserpdrow", + defaultValue: "sserpdrow", + deployedValue: "sserpdrow", + hasProperties: false, + title: "Password", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a email", + params: [ + { + key: "wordpressEmail", + currentValue: "user@example.com", + defaultValue: "user@example.com", + deployedValue: "user@example.com", + hasProperties: false, + title: "Email", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a generic string", + params: [ + { + key: "blogName", + currentValue: "my-blog", + defaultValue: "my-blog", + deployedValue: "my-blog", + hasProperties: false, + title: "Blog Name", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with custom configuration", + params: [ + { + key: "configuration", + currentValue: "First line\nSecond line", + defaultValue: "First line\nSecond line", + deployedValue: "First line\nSecond line", + hasProperties: false, + title: "Configuration", + schema: { + type: "object", + }, + type: "object", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a integer disk size", + params: [ + { + key: "size", + currentValue: 10, + defaultValue: 10, + deployedValue: 10, + hasProperties: false, + title: "Size", + schema: { + type: "integer", + }, + type: "integer", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a number disk size", + params: [ + { + key: "size", + currentValue: 10.0, + defaultValue: 10.0, + deployedValue: 10.0, + hasProperties: false, + title: "Size", + schema: { + type: "number", + }, + type: "number", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with slider parameters", + params: [ + { + key: "size", + currentValue: 10, + defaultValue: 10, + deployedValue: 10, + hasProperties: false, + title: "Size", + schema: { + type: "integer", + }, + type: "integer", + maximum: 100, + minimum: 1, + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with username, password, email and a generic string", + params: [ + { + key: "wordpressUsername", + currentValue: "user", + defaultValue: "user", + deployedValue: "user", + hasProperties: false, + title: "Username", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + { + key: "wordpressPassword", + currentValue: "sserpdrow", + defaultValue: "sserpdrow", + deployedValue: "sserpdrow", + hasProperties: false, + title: "Password", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + { + key: "wordpressEmail", + currentValue: "user@example.com", + defaultValue: "user@example.com", + deployedValue: "user@example.com", + hasProperties: false, + title: "Email", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + { + key: "blogName", + currentValue: "my-blog", + defaultValue: "my-blog", + deployedValue: "my-blog", + hasProperties: false, + title: "Blog Name", + schema: { + type: "string", + }, + type: "string", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a generic boolean", + params: [ + { + key: "enableMetrics", + currentValue: true, + defaultValue: true, + deployedValue: true, + hasProperties: false, + title: "Metrics", + schema: { + type: "boolean", + }, + type: "boolean", + } as IBasicFormParam, + ], + }, + { + description: "renders a basic deployment with a generic integer", + params: [ + { + key: "replicas", + currentValue: 10, + defaultValue: 10, + deployedValue: 10, + hasProperties: false, + title: "Replicas", + schema: { + type: "integer", + }, + type: "integer", + } as IBasicFormParam, + ], + }, + { + description: "renders an array of strings", + params: [ + { + key: "array of strings", + currentValue: '["element1"]', + defaultValue: "[element1]", + deployedValue: "[element1]", + hasProperties: false, + title: "string[]", + schema: { + type: "array", + items: { + type: "string", + }, + }, + type: "array", + } as IBasicFormParam, + ], + }, + { + description: "renders an array of numbers", + params: [ + { + key: "array of numbers", + currentValue: "[1]", + defaultValue: "[1]", + deployedValue: "[1]", + hasProperties: false, + title: "number[]", + schema: { + type: "array", + items: { + type: "number", + }, + }, + type: "array", + } as IBasicFormParam, + ], + }, + { + description: "renders an array of booleans", + params: [ + { + key: "array of booleans", + currentValue: "[true]", + defaultValue: "[true]", + deployedValue: "[true]", + hasProperties: false, + title: "boolean[]", + schema: { + type: "array", + items: { + type: "boolean", + }, + }, + type: "array", + } as IBasicFormParam, + ], + }, + { + description: "renders an array of objects", + params: [ + { + key: "array of objects", + currentValue: "[{}]", + defaultValue: "[{}]", + deployedValue: "[{}]", + hasProperties: false, + title: "object[]", + schema: { + type: "array", + items: { + type: "object", + }, + }, + type: "array", + } as IBasicFormParam, + ], + }, +].forEach(t => { + it(t.description, () => { + const onChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => onChange); + const wrapper = mount( + , + ); + + t.params.forEach((param, i) => { + const input = wrapper.find(`input#${param.key}`); + const inputNumText = wrapper.find(`input#${param.key}_text`); + const inputNumRange = wrapper.find(`input#${param.key}_range`); + switch (param.type) { + case "string": + if (param.key.match("Password")) { + expect(input.prop("type")).toBe("password"); + break; + } + expect(input.prop("type")).toBe("string"); + break; + case "boolean": + expect(input.prop("type")).toBe("checkbox"); + break; + case "number": + expect(inputNumText.prop("type")).toBe("number"); + expect(inputNumText.prop("step")).toBe(0.1); + expect(inputNumRange.prop("type")).toBe("range"); + expect(inputNumRange.prop("step")).toBe(0.1); + break; + case "integer": + expect(inputNumText.prop("type")).toBe("number"); + expect(inputNumText.prop("step")).toBe(1); + expect(inputNumRange.prop("type")).toBe("range"); + expect(inputNumRange.prop("step")).toBe(1); + break; + case "array": + if (param.schema.items.type !== "object") { + expect(wrapper.find("ArrayParam input").first()).toExist(); + } else { + expect(wrapper.find("textarea")).toExist(); + } + break; + case "object": + expect(wrapper.find(`textarea#${param.key}`)).toExist(); + break; + default: + break; + } + if (["integer", "number"].includes(param.type)) { + inputNumText.simulate("change", { target: { value: "" } }); + } else if (param.type === "array") { + if (param.schema.items.type !== "object") { + wrapper + .find("ArrayParam input") + .first() + .simulate("change", { target: { value: "" } }); + } else { + wrapper.find("textarea").simulate("change", { target: { value: "" } }); + } + } else if (param.type === "object") { + wrapper.find(`textarea#${param.key}`).simulate("change", { target: { value: "" } }); + } else { + input.simulate("change", { target: { value: "" } }); + } + expect(handleBasicFormParamChange).toHaveBeenCalledWith(param); + jest.runAllTimers(); + expect(onChange).toHaveBeenCalledTimes(i + 1); + }); + }); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx new file mode 100644 index 00000000000..bcc39c04808 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx @@ -0,0 +1,107 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CellContext, ColumnDef, createColumnHelper } from "@tanstack/react-table"; +import React, { useMemo, useState } from "react"; +import { DeploymentEvent, IBasicFormParam } from "shared/types"; +import "./BasicDeploymentForm.css"; +import TabularSchemaEditorTable from "./TabularSchemaEditorTable/TabularSchemaEditorTable"; +import { fuzzySort } from "./TabularSchemaEditorTable/TabularSchemaEditorTableHelpers"; +import { + renderConfigCurrentValuePro, + renderConfigDefaultValue, + renderConfigDeployedValue, + renderConfigDescription, + renderConfigKey, + renderConfigKeyHeader, + renderConfigType, +} from "./TabularSchemaEditorTable/TabularSchemaEditorTableRenderer"; + +export interface IBasicDeploymentFormProps { + handleBasicFormParamChange: ( + p: IBasicFormParam, + ) => (e: React.FormEvent) => void; + deploymentEvent: DeploymentEvent; + paramsFromComponentState: IBasicFormParam[]; + isLoading: boolean; + saveAllChanges: () => void; +} + +function BasicDeploymentForm(props: IBasicDeploymentFormProps) { + // Fetch data from the parent component + const { + handleBasicFormParamChange, + saveAllChanges, + deploymentEvent, + paramsFromComponentState, + isLoading, + } = props; + + // Component state + const [globalFilter, setGlobalFilter] = useState(""); + + // Column definitions + // use useMemo to avoid re-creating the columns on every render + const columnHelper = createColumnHelper(); + const columns = useMemo[]>(() => { + const cols = [ + columnHelper.accessor((row: IBasicFormParam) => row.key, { + id: "key", + cell: (info: CellContext) => + renderConfigKey(info.row.original, info.row, saveAllChanges), + header: info => renderConfigKeyHeader(info.table, saveAllChanges), + sortingFn: fuzzySort, + }), + columnHelper.accessor((row: IBasicFormParam) => row.type, { + id: "type", + cell: (info: CellContext) => renderConfigType(info.row.original), + header: () => Type, + }), + columnHelper.accessor((row: IBasicFormParam) => row.description, { + id: "description", + cell: (info: CellContext) => + renderConfigDescription(info.row.original), + header: () => Description, + }), + columnHelper.accessor((row: IBasicFormParam) => row.defaultValue, { + id: "defaultValue", + cell: (info: CellContext) => + renderConfigDefaultValue(info.row.original), + header: () => Default Value, + }), + columnHelper.accessor((row: IBasicFormParam) => row.currentValue, { + id: "currentValue", + cell: (info: CellContext) => { + return renderConfigCurrentValuePro(info.row.original, handleBasicFormParamChange); + }, + header: () => Current Value, + }), + ]; + if (deploymentEvent === "upgrade") { + cols.splice( + 4, + 0, + columnHelper.accessor((row: IBasicFormParam) => row.deployedValue, { + id: "deployedValue", + cell: (info: CellContext) => + renderConfigDeployedValue(info.row.original), + header: () => Deployed Value, + }), + ); + } + return cols; + }, [columnHelper, deploymentEvent, handleBasicFormParamChange, saveAllChanges]); + + return ( + + ); +} + +export default BasicDeploymentForm; diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.test.tsx new file mode 100644 index 00000000000..b1e46937612 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.test.tsx @@ -0,0 +1,23 @@ +// Copyright 2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import DebouncedInput from "./DebouncedInput"; + +jest.useFakeTimers(); + +it("should debounce a change in the input value", () => { + const onChange = jest.fn(); + + const wrapper = mount(); + + act(() => { + (wrapper.find("input").prop("onChange") as any)({ target: { value: "something" } }); + }); + wrapper.update(); + + expect(onChange).not.toHaveBeenCalled(); + jest.runAllTimers(); + expect(onChange).toHaveBeenCalledWith("something"); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.tsx new file mode 100644 index 00000000000..cd030a0835c --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/DebouncedInput.tsx @@ -0,0 +1,37 @@ +// Copyright 2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsInput } from "@cds/react/input"; +import { InputHTMLAttributes, useEffect, useState } from "react"; + +export default function DebouncedInput({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounce?: number; +} & Omit, "onChange">) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => clearTimeout(timeout); + }, [debounce, onChange, value]); + + return ( + + + setValue(e.target.value)} /> + + ); +} diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.test.tsx new file mode 100644 index 00000000000..8e74c931167 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.test.tsx @@ -0,0 +1,152 @@ +// Copyright 2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { mount } from "enzyme"; +import ArrayParam, { IArrayParamProps } from "./ArrayParam"; + +jest.useFakeTimers(); + +[ + { + description: "renders an array of numbers", + props: { + id: "array-numbers", + label: "label", + type: "number", + param: { + key: "array of numbers", + currentValue: "[1]", + defaultValue: "[1]", + deployedValue: "[1]", + hasProperties: false, + title: "number[]", + schema: { + type: "array", + items: { + type: "number", + }, + }, + type: "array", + }, + } as IArrayParamProps, + }, + { + description: "renders an array of strings", + props: { + id: "array-strings", + label: "label", + type: "string", + param: { + key: "array of numbers", + currentValue: '["element1"]', + defaultValue: "[element1]", + deployedValue: "[element1]", + hasProperties: false, + title: "string[]", + schema: { + type: "array", + items: { + type: "string", + }, + }, + type: "array", + }, + } as IArrayParamProps, + }, + { + description: "renders an array of booleans", + props: { + id: "array-boolean", + label: "label", + type: "boolean", + param: { + key: "array of booleans", + currentValue: "[1]", + defaultValue: "[1]", + deployedValue: "[1]", + hasProperties: false, + title: "boolean[]", + schema: { + type: "array", + items: { + type: "boolean", + }, + }, + type: "array", + }, + } as IArrayParamProps, + }, + { + description: "renders an array of objects", + props: { + id: "array-object", + label: "label", + type: "object", + param: { + key: "array of objects", + currentValue: "[1]", + defaultValue: "[1]", + deployedValue: "[1]", + hasProperties: false, + title: "object[]", + schema: { + type: "array", + items: { + type: "object", + }, + }, + type: "array", + }, + } as IArrayParamProps, + }, +].forEach(t => { + it(t.description, () => { + const onChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => onChange); + const wrapper = mount( + , + ); + const inputNumText = wrapper.find(`input#${t.props.id}-0_text`); + const inputNumRange = wrapper.find(`input#${t.props.id}-0_range`); + const input = wrapper.find("input"); + const arrayType = t.props.param.schema.items.type; + + switch (arrayType) { + case "string": + if (t.props.param.key.match("Password")) { + expect(input.prop("type")).toBe("password"); + break; + } + expect(input).toExist(); + break; + case "boolean": + expect(input.prop("type")).toBe("checkbox"); + break; + case "number": + expect(inputNumText.prop("type")).toBe("number"); + expect(inputNumText.prop("step")).toBe(0.1); + expect(inputNumRange.prop("type")).toBe("range"); + expect(inputNumRange.prop("step")).toBe(0.1); + break; + case "integer": + expect(inputNumText.prop("type")).toBe("number"); + expect(inputNumText.prop("step")).toBe(1); + expect(inputNumRange.prop("type")).toBe("range"); + expect(inputNumRange.prop("step")).toBe(1); + break; + case "object": + expect(input).toExist(); + break; + default: + break; + } + if (["integer", "number"].includes(arrayType)) { + inputNumText.simulate("change", { target: { value: "" } }); + } else { + input.simulate("change", { target: { value: "" } }); + } + expect(handleBasicFormParamChange).toHaveBeenCalledWith(t.props.param); + jest.runAllTimers(); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.tsx new file mode 100644 index 00000000000..f70801d596f --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/ArrayParam.tsx @@ -0,0 +1,164 @@ +// Copyright 2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsButton } from "@cds/react/button"; +import { CdsIcon } from "@cds/react/icon"; +import { CdsInput } from "@cds/react/input"; +import { CdsRange } from "@cds/react/range"; +import { CdsToggle } from "@cds/react/toggle"; +import Column from "components/js/Column"; +import Row from "components/js/Row"; +import { useState } from "react"; +import { IBasicFormParam } from "shared/types"; +import { basicFormsDebounceTime } from "shared/utils"; + +export interface IArrayParamProps { + id: string; + label: string; + type: string; + param: IBasicFormParam; + handleBasicFormParamChange: ( + param: IBasicFormParam, + ) => (e: React.FormEvent) => void; +} + +export default function ArrayParam(props: IArrayParamProps) { + const { id, label, type, param, handleBasicFormParamChange } = props; + + const [currentArrayItems, setCurrentArrayItems] = useState<(string | number | boolean)[]>( + param.currentValue ? JSON.parse(param.currentValue) : [], + ); + const [timeout, setThisTimeout] = useState({} as NodeJS.Timeout); + + const setArrayChangesInParam = () => { + clearTimeout(timeout); + const func = handleBasicFormParamChange(param); + // The reference to target get lost, so we need to keep a copy + const targetCopy = { + currentTarget: { + value: JSON.stringify(currentArrayItems), + type: "change", + }, + } as React.FormEvent; + setThisTimeout(setTimeout(() => func(targetCopy), basicFormsDebounceTime)); + }; + + const onChangeArrayItem = (index: number, value: string | number | boolean) => { + currentArrayItems[index] = value; + setCurrentArrayItems([...currentArrayItems]); + setArrayChangesInParam(); + }; + + const renderInput = (type: string, index: number) => { + switch (type) { + case "number": + case "integer": + return ( + <> + + onChangeArrayItem(index, Number(e.currentTarget.value))} + value={Number(currentArrayItems[index])} + step={param.schema?.type === "integer" ? 1 : 0.1} + /> + + + onChangeArrayItem(index, Number(e.currentTarget.value))} + value={Number(currentArrayItems[index])} + step={param.schema?.type === "integer" ? 1 : 0.1} + /> + + + ); + case "boolean": + return ( + + onChangeArrayItem(index, e.currentTarget.checked)} + checked={!!currentArrayItems[index]} + /> + + ); + + // TODO(agamez): handle enums and objects in arrays + default: + return ( + + onChangeArrayItem(index, e.currentTarget.value)} + /> + + ); + } + }; + + const onAddArrayItem = () => { + switch (type) { + case "number": + case "integer": + currentArrayItems.push(0); + break; + case "boolean": + currentArrayItems.push(false); + break; + default: + currentArrayItems.push(""); + break; + } + setCurrentArrayItems([...currentArrayItems]); + setArrayChangesInParam(); + }; + + const onDeleteArrayItem = (index: number) => { + currentArrayItems.splice(index, 1); + setCurrentArrayItems([...currentArrayItems]); + }; + + return ( + <> + + + Add + + {currentArrayItems?.map((_, index) => ( + + {renderInput(type, index)} + + onDeleteArrayItem(index)} + action="flat" + status="primary" + size="sm" + > + + + + + ))} + + ); +} diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.test.tsx new file mode 100644 index 00000000000..b840b5f0fbd --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.test.tsx @@ -0,0 +1,46 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import BooleanParam, { IBooleanParamProps } from "./BooleanParam"; + +const defaultProps = { + handleBasicFormParamChange: jest.fn(), + id: "foo", + label: "Enable Metrics", + param: { + title: "Enable Metrics", + type: "boolean", + currentValue: false, + defaultValue: false, + deployedValue: false, + hasProperties: false, + key: "enableMetrics", + schema: { + type: "boolean", + }, + }, +} as IBooleanParamProps; + +it("should render a boolean param with title and description", () => { + const wrapper = mount(); + const s = wrapper.find("input").findWhere(i => i.prop("type") === "checkbox"); + expect(s.prop("checked")).toBe(defaultProps.param.currentValue); +}); + +it("should send a checkbox event to handleBasicFormParamChange", () => { + const handler = jest.fn(); + const handleBasicFormParamChange = jest.fn().mockReturnValue(handler); + const wrapper = mount( + , + ); + const s = wrapper.find("input").findWhere(i => i.prop("type") === "checkbox"); + const event = { currentTarget: { checked: true } } as React.FormEvent; + act(() => { + (s.prop("onChange") as any)(event); + }); + s.update(); + expect(handleBasicFormParamChange).toHaveBeenCalledWith(defaultProps.param); + expect(handler).toHaveBeenCalledWith({ currentTarget: { type: "checkbox", value: "true" } }); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.tsx new file mode 100644 index 00000000000..bd53b451806 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/BooleanParam.tsx @@ -0,0 +1,70 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsControlMessage } from "@cds/react/forms"; +import { CdsToggle, CdsToggleGroup } from "@cds/react/toggle"; +import Column from "components/js/Column"; +import Row from "components/js/Row"; +import { useState } from "react"; +import { IBasicFormParam } from "shared/types"; + +export interface IBooleanParamProps { + id: string; + label: string; + param: IBasicFormParam; + handleBasicFormParamChange: ( + p: IBasicFormParam, + ) => (e: React.FormEvent) => void; +} +export default function BooleanParam(props: IBooleanParamProps) { + const { id, label, param, handleBasicFormParamChange } = props; + + const [currentValue, setCurrentValue] = useState(param.currentValue); + const [isValueModified, setIsValueModified] = useState(false); + + const onChange = (e: React.FormEvent) => { + // create an event that "getValueFromEvent" can process, + const event = { + currentTarget: { + //convert the boolean "checked" prop to a normal "value" string one + value: e.currentTarget?.checked?.toString(), + type: "checkbox", + }, + } as React.FormEvent; + setCurrentValue(e.currentTarget?.checked); + setIsValueModified(e.currentTarget?.checked !== param.currentValue); + handleBasicFormParamChange(param)(event); + }; + + const unsavedMessage = isValueModified ? "Unsaved" : ""; + + const isModified = + isValueModified || + (param.currentValue !== param.defaultValue && param.currentValue !== param.deployedValue); + + const input = ( + + + + + + {currentValue ? "true" : "false"} + + + {unsavedMessage} + + ); + + return ( + + {input} + + ); +} diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.test.tsx similarity index 85% rename from dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.test.tsx rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.test.tsx index cfaa63bf111..ade35844df6 100644 --- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.test.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.test.tsx @@ -1,24 +1,31 @@ // Copyright 2019-2022 the Kubeapps contributors. // SPDX-License-Identifier: Apache-2.0 +import { CustomComponent } from "RemoteComponent"; import { getStore, mountWrapper } from "shared/specs/mountWrapper"; import { IBasicFormParam, IStoreState } from "shared/types"; -import { CustomComponent } from "../../../RemoteComponent"; -import CustomFormComponentLoader from "./CustomFormParam"; +import CustomFormComponentLoader, { ICustomParamProps } from "./CustomFormParam"; -const param = { - path: "enableMetrics", - value: true, +const param: IBasicFormParam = { type: "boolean", customComponent: { className: "test", }, -} as IBasicFormParam; + currentValue: true, + defaultValue: true, + deployedValue: true, + hasProperties: false, + key: "enableMetrics", + schema: { + type: "boolean", + }, + title: "Enable Metrics", +}; const defaultProps = { param, handleBasicFormParamChange: jest.fn(), -}; +} as ICustomParamProps; const defaultState = { config: { remoteComponentsUrl: "" }, diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.tsx similarity index 94% rename from dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.tsx rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.tsx index e62ed9cfd3a..5ca570129f7 100644 --- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/CustomFormParam.tsx +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/CustomFormParam.tsx @@ -3,8 +3,9 @@ import React, { useMemo } from "react"; import { useSelector } from "react-redux"; +import { CustomComponent } from "RemoteComponent"; import { IBasicFormParam, IStoreState } from "shared/types"; -import { CustomComponent } from "../../../RemoteComponent"; + export interface ICustomParamProps { param: IBasicFormParam; handleBasicFormParamChange: ( @@ -16,12 +17,11 @@ export default function CustomFormComponentLoader({ param, handleBasicFormParamChange, }: ICustomParamProps) { - // Fetches the custom-component bundle served by the dashboard nginx - const { config: { remoteComponentsUrl }, } = useSelector((state: IStoreState) => state); + // Fetches the custom-component bundle served by the dashboard nginx const url = remoteComponentsUrl ? remoteComponentsUrl : `${window.location.origin}/custom_components.js`; diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.test.tsx new file mode 100644 index 00000000000..84c3f54b868 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.test.tsx @@ -0,0 +1,360 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { shallow } from "enzyme"; +import { act } from "react-dom/test-utils"; +import { IBasicFormParam } from "shared/types"; +import SliderParam, { ISliderParamProps } from "./SliderParam"; + +const defaultProps = { + id: "disk", + label: "Disk Size", + handleBasicFormParamChange: jest.fn(() => jest.fn()), + step: 1, + unit: "Gi", + param: {} as IBasicFormParam, +} as ISliderParamProps; + +const params = [ + { + key: "disk", + path: "disk", + type: "integer", + title: "Disk Size", + hasProperties: false, + currentValue: 10, + defaultValue: 10, + deployedValue: 10, + schema: { + type: "integer", + }, + }, + { + key: "disk", + path: "disk", + type: "number", + title: "Disk Size", + hasProperties: false, + currentValue: 10.0, + defaultValue: 10.0, + deployedValue: 10.0, + schema: { + type: "number", + }, + }, +] as IBasicFormParam[]; + +jest.useFakeTimers(); + +it("renders a disk size param with a default value", () => { + params.forEach(param => { + const wrapper = shallow(); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "number") + .prop("value"), + ).toBe(10); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + }); +}); + +it("uses the param minimum and maximum if defined", () => { + params.forEach(param => { + const wrapper = shallow( + , + ); + + const slider = wrapper.find("input").filterWhere(i => i.prop("type") === "range"); + expect(slider.prop("min")).toBe(5); + expect(slider.prop("max")).toBe(50); + }); +}); + +it("sets the param minimum to current value if less than min", () => { + params.forEach(param => { + const wrapper = shallow( + , + ); + + const slider = wrapper.find("input").filterWhere(i => i.prop("type") === "range"); + expect(slider.prop("min")).toBe(1); + expect(slider.prop("max")).toBe(100); + }); +}); + +it("sets the param maximum to current value if greater than", () => { + params.forEach(param => { + const wrapper = shallow( + , + ); + + const slider = wrapper.find("input").filterWhere(i => i.prop("type") === "range"); + expect(slider.prop("min")).toBe(100); + expect(slider.prop("max")).toBe(2000); + }); +}); + +it("defaults to the min if the value is undefined", () => { + params.forEach(param => { + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(5); + }); +}); + +describe("when changing the slide", () => { + it("changes the value of the string param", () => { + params.forEach(param => { + const valueChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => valueChange); + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + + const event = { currentTarget: { value: "20" } } as React.FormEvent; + act(() => { + ( + wrapper.find("input#disk_range").prop("onChange") as ( + e: React.FormEvent, + ) => void + )(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "number") + .prop("value"), + ).toBe(20); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(20); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(param); + expect(valueChange).toHaveBeenCalledWith(event); + }); + }); +}); + +describe("when changing the value in the input", () => { + it("parses a number and forwards it", () => { + params.forEach(param => { + const valueChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => valueChange); + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + + const event = { currentTarget: { value: "20" } } as React.FormEvent; + act(() => { + ( + wrapper.find("input#disk_text").prop("onChange") as ( + e: React.FormEvent, + ) => void + )(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "number") + .prop("value"), + ).toBe(20); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(20); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(param); + expect(valueChange).toHaveBeenCalledWith(event); + }); + }); + + it("ignores values in the input that are not digits", () => { + params.forEach(param => { + const valueChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => valueChange); + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + + const event = { currentTarget: { value: "foo20*#@$" } } as React.FormEvent; + act(() => { + ( + wrapper.find("input#disk_text").prop("onChange") as ( + e: React.FormEvent, + ) => void + )(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "number") + .prop("value"), + ).toBe(NaN); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(NaN); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(param); + expect(valueChange).toHaveBeenCalledWith(event); + }); + }); + + it("accept decimal values", () => { + params.forEach(param => { + const valueChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => valueChange); + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + + const event = { currentTarget: { value: "20.5" } } as React.FormEvent; + act(() => { + ( + wrapper.find("input#disk_text").prop("onChange") as ( + e: React.FormEvent, + ) => void + )(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "number") + .prop("value"), + ).toBe(20.5); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(20.5); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(param); + expect(valueChange).toHaveBeenCalledWith(event); + }); + }); + + it("modifies the max value of the slider if the input is greater than 100", () => { + params.forEach(param => { + const valueChange = jest.fn(); + const handleBasicFormParamChange = jest.fn(() => valueChange); + const wrapper = shallow( + , + ); + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(10); + + const event = { currentTarget: { value: "2000" } } as React.FormEvent; + act(() => { + ( + wrapper.find("input#disk_text").prop("onChange") as ( + e: React.FormEvent, + ) => void + )(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect( + wrapper + .find("input") + .filterWhere(i => i.prop("type") === "range") + .prop("value"), + ).toBe(2000); + const slider = wrapper.find("input").filterWhere(i => i.prop("type") === "range"); + expect(slider.prop("max")).toBe(2000); + }); + }); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.tsx new file mode 100644 index 00000000000..f3496b6e6bf --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/SliderParam.tsx @@ -0,0 +1,92 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsControlMessage } from "@cds/react/forms"; +import { CdsInput } from "@cds/react/input"; +import { CdsRange } from "@cds/react/range"; +import Column from "components/js/Column"; +import Row from "components/js/Row"; +import { toNumber } from "lodash"; +import { useState } from "react"; +import { IBasicFormParam } from "shared/types"; +import { basicFormsDebounceTime } from "shared/utils"; + +export interface ISliderParamProps { + id: string; + label: string; + param: IBasicFormParam; + unit: string; + step: number; + handleBasicFormParamChange: ( + p: IBasicFormParam, + ) => (e: React.FormEvent) => void; +} + +export default function SliderParam(props: ISliderParamProps) { + const { handleBasicFormParamChange, id, label, param, step } = props; + + const [currentValue, setCurrentValue] = useState(toNumber(param.currentValue) || param.minimum); + const [isValueModified, setIsValueModified] = useState(false); + const [timeout, setThisTimeout] = useState({} as NodeJS.Timeout); + + const onChange = (e: React.FormEvent) => { + setCurrentValue(toNumber(e.currentTarget.value)); + setIsValueModified(toNumber(e.currentTarget.value) !== param.currentValue); + // Gather changes before submitting + clearTimeout(timeout); + const func = handleBasicFormParamChange(param); + // The reference to target get lost, so we need to keep a copy + const targetCopy = { + currentTarget: { + value: e.currentTarget?.value, + type: e.currentTarget?.type, + }, + } as React.FormEvent; + setThisTimeout(setTimeout(() => func(targetCopy), basicFormsDebounceTime)); + }; + + const unsavedMessage = isValueModified ? "Unsaved" : ""; + const isModified = + isValueModified || + (param.currentValue !== param.defaultValue && param.currentValue !== param.deployedValue); + + const input = ( + + + {unsavedMessage} + + ); + + const inputText = ( +
+ + + +
+ ); + + return ( + + {inputText} + {input} + + ); +} diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.test.tsx new file mode 100644 index 00000000000..29cec854380 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.test.tsx @@ -0,0 +1,240 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { mount } from "enzyme"; +import { act } from "react-dom/test-utils"; +import { IBasicFormParam } from "shared/types"; +import TextParam, { ITextParamProps } from "./TextParam"; + +jest.useFakeTimers(); + +describe("param rendered as a input type text", () => { + const stringParam = { + currentValue: "foo", + defaultValue: "foo", + deployedValue: "foo", + hasProperties: false, + title: "Username", + schema: { + type: "string", + }, + type: "string", + key: "username", + } as IBasicFormParam; + const stringProps = { + id: "foo-string", + label: "Username", + inputType: "text", + param: stringParam, + handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()), + } as ITextParamProps; + + it("should render a string parameter with title and description", () => { + const wrapper = mount(); + const input = wrapper.find("input"); + expect(input.prop("value")).toBe(stringProps.param.currentValue); + }); + + it("should forward the proper value when using a string parameter", () => { + const handler = jest.fn(); + const handleBasicFormParamChange = jest.fn().mockReturnValue(handler); + const wrapper = mount( + , + ); + const input = wrapper.find("input"); + + const event = { currentTarget: { value: "" } } as React.FormEvent; + act(() => { + (input.prop("onChange") as any)(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(stringProps.param); + expect(handler).toHaveBeenCalledWith(event); + }); + + it("should set the input value as empty if a string parameter value is not defined", () => { + const wrapper = mount( + , + ); + const input = wrapper.find("input"); + expect(input.prop("value")).toBe(""); + }); + + it("a change in the param property should update the current value", () => { + const wrapper = mount( + , + ); + const input = wrapper.find("input"); + expect(input.prop("value")).toBe(""); + + const event = { currentTarget: { value: "foo" } } as React.FormEvent; + act(() => { + (input.prop("onChange") as any)(event); + }); + wrapper.update(); + expect(wrapper.find("input").prop("value")).toBe("foo"); + }); +}); + +// Note that, for numbers, SliderParam component is preferred +describe("param rendered as a input type number", () => { + const numberParam = { + type: "integer", + schema: { + type: "integer", + }, + key: "replicas", + title: "Replicas", + currentValue: 0, + defaultValue: 0, + deployedValue: 0, + hasProperties: false, + } as IBasicFormParam; + const numberProps = { + id: "foo-number", + label: "Replicas", + inputType: "number", + param: numberParam, + handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()), + } as ITextParamProps; + + it("should set the input type as number", () => { + const wrapper = mount(); + const input = wrapper.find("input"); + expect(input.prop("type")).toBe("number"); + }); + + it("a change in a number param property should update the current value", () => { + const wrapper = mount(); + const input = wrapper.find("input"); + expect(input.prop("value")).toBe("0"); + + const event = { currentTarget: { value: "1" } } as React.FormEvent; + act(() => { + (input.prop("onChange") as any)(event); + }); + wrapper.update(); + expect(wrapper.find("input").prop("value")).toBe("1"); + }); +}); + +describe("param rendered as a input type textarea", () => { + const textAreaParam = { + type: "string", + schema: { + type: "string", + }, + key: "configuration", + title: "Configuration", + currentValue: "First line\nSecond line", + defaultValue: "First line\nSecond line", + deployedValue: "First line\nSecond line", + hasProperties: false, + } as IBasicFormParam; + const textAreaProps = { + id: "foo-textarea", + label: "Configuration", + param: textAreaParam, + handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()), + inputType: "textarea", + } as ITextParamProps; + + it("should render a textArea parameter with title and description", () => { + const wrapper = mount(); + const input = wrapper.find("textarea"); + expect(input.prop("value")).toBe(textAreaProps.param.currentValue); + }); + + it("should forward the proper value when using a textArea parameter", () => { + const handler = jest.fn(); + const handleBasicFormParamChange = jest.fn().mockReturnValue(handler); + const wrapper = mount( + , + ); + const input = wrapper.find("textarea"); + + const event = { currentTarget: { value: "" } } as React.FormEvent; + act(() => { + (input.prop("onChange") as any)(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect(handleBasicFormParamChange).toHaveBeenCalledWith(textAreaParam); + expect(handler).toHaveBeenCalledWith(event); + }); + + it("should set the input value as empty if a textArea param value is not defined", () => { + const wrapper = mount( + , + ); + const input = wrapper.find("textarea"); + expect(input.prop("value")).toBe(""); + }); +}); + +describe("param rendered as a select", () => { + const enumParam = { + type: "string", + schema: { + type: "string", + enum: ["mariadb", "postgresql"], + }, + enum: ["mariadb", "postgresql"], + key: "databaseType", + title: "Database Type", + currentValue: "postgresql", + defaultValue: "postgresql", + deployedValue: "postgresql", + hasProperties: false, + } as IBasicFormParam; + const enumProps = { + id: "foo-enum", + name: "databaseType", + label: "Database Type", + param: enumParam, + handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()), + } as ITextParamProps; + + it("should render a string parameter as select with option tags", () => { + const wrapper = mount(); + const input = wrapper.find("select"); + + expect(wrapper.find("select").prop("value")).toBe(enumParam.currentValue); + if (enumParam.enum != null) { + const options = input.find("option"); + expect(options.length).toBe(enumParam.enum.length); + + for (let i = 0; i < enumParam.enum.length; i++) { + const option = options.at(i); + expect(option.text()).toBe(enumParam.enum[i]); + } + } + }); + + it("should forward the proper value when using a select", () => { + const handler = jest.fn(); + const handleBasicFormParamChange = jest.fn().mockReturnValue(handler); + const wrapper = mount( + , + ); + expect(wrapper.find("select").prop("value")).toBe(enumParam.currentValue); + + const event = { currentTarget: { value: "mariadb" } } as React.FormEvent; + act(() => { + (wrapper.find("select").prop("onChange") as any)(event); + }); + wrapper.update(); + jest.runAllTimers(); + + expect(wrapper.find("select").prop("value")).toBe(event.currentTarget.value); + expect(handleBasicFormParamChange).toHaveBeenCalledWith(enumProps.param); + expect(handler).toHaveBeenCalledWith(event); + }); +}); diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.tsx new file mode 100644 index 00000000000..75d5c316884 --- /dev/null +++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/Params/TextParam.tsx @@ -0,0 +1,153 @@ +// Copyright 2019-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +import { CdsControlMessage } from "@cds/react/forms"; +import { CdsInput } from "@cds/react/input"; +import { CdsSelect } from "@cds/react/select"; +import { CdsTextarea } from "@cds/react/textarea"; +import Column from "components/js/Column"; +import Row from "components/js/Row"; +import { isEmpty } from "lodash"; +import { useState } from "react"; +import { validateValuesSchema } from "shared/schema"; +import { IAjvValidateResult, IBasicFormParam } from "shared/types"; +import { basicFormsDebounceTime } from "shared/utils"; + +export interface ITextParamProps { + id: string; + label: string; + inputType?: "text" | "textarea" | string; + param: IBasicFormParam; + handleBasicFormParamChange: ( + param: IBasicFormParam, + ) => (e: React.FormEvent) => void; +} + +function getStringValue(param: IBasicFormParam, value?: any) { + if (["array", "object"].includes(param?.type)) { + return JSON.stringify(value || param?.currentValue); + } else { + return value?.toString() || param?.currentValue?.toString(); + } +} +function getValueFromString(param: IBasicFormParam, value: any) { + if (["array", "object"].includes(param?.type)) { + try { + return JSON.parse(value); + } catch (e) { + return value?.toString(); + } + } else { + return value?.toString(); + } +} + +function toStringValue(value: any) { + return JSON.stringify(value?.toString() || ""); +} + +export default function TextParam(props: ITextParamProps) { + const { id, label, inputType, param, handleBasicFormParamChange } = props; + + const [validated, setValidated] = useState(); + const [currentValue, setCurrentValue] = useState(getStringValue(param)); + const [isValueModified, setIsValueModified] = useState(false); + const [timeout, setThisTimeout] = useState({} as NodeJS.Timeout); + + const onChange = ( + e: React.FormEvent, + ) => { + setValidated(validateValuesSchema(e.currentTarget.value, param.schema)); + setCurrentValue(e.currentTarget.value); + setIsValueModified(toStringValue(e.currentTarget.value) !== toStringValue(param.currentValue)); + // Gather changes before submitting + clearTimeout(timeout); + const func = handleBasicFormParamChange(param); + // The reference to target get lost, so we need to keep a copy + const targetCopy = { + currentTarget: { + value: getValueFromString(param, e.currentTarget?.value), + type: e.currentTarget?.type, + }, + } as React.FormEvent; + setThisTimeout(setTimeout(() => func(targetCopy), basicFormsDebounceTime)); + }; + + const unsavedMessage = isValueModified ? "Unsaved" : ""; + const isDiffCurrentVsDefault = + toStringValue(param.currentValue) !== toStringValue(param.defaultValue); + const isDiffCurrentVsDeployed = + toStringValue(param.currentValue) !== toStringValue(param.defaultValue); + const isModified = + isValueModified || + (isDiffCurrentVsDefault && (!param.deployedValue || isDiffCurrentVsDeployed)); + + const renderControlMsg = () => + !validated?.valid && !isEmpty(validated?.errors) ? ( + <> + + {unsavedMessage} +
+ {validated?.errors?.map(e => e.message).join(", ")} +
+
+ + ) : ( + {unsavedMessage} + ); + + let input = ( + <> + + + {renderControlMsg()} + + + ); + if (inputType === "textarea") { + input = ( + +