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 = (
+
+
+ {renderControlMsg()}
+
+ );
+ } else if (!isEmpty(param.enum)) {
+ input = (
+ <>
+
+
+ {renderControlMsg()}
+
+ >
+ );
+ }
+
+ return (
+
+ {input}
+
+ );
+}
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.scss b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.scss
new file mode 100644
index 00000000000..c76aee72d9b
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.scss
@@ -0,0 +1,17 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+.breakable {
+ word-break: break-word;
+}
+
+.table-control {
+ max-width: 98%;
+ margin-top: 1em;
+ margin-bottom: 1em;
+}
+
+.table-button {
+ margin-right: "-0.75em";
+ margin-left: "-0.75em";
+}
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.test.tsx
new file mode 100644
index 00000000000..2dd29c6b38c
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.test.tsx
@@ -0,0 +1,27 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mount } from "enzyme";
+import TabularSchemaEditorTable, {
+ TabularSchemaEditorTableProps,
+} from "./TabularSchemaEditorTable";
+
+jest.useFakeTimers();
+
+const defaultProps = {
+ columns: [],
+ data: [],
+ globalFilter: "",
+ isLoading: false,
+ saveAllChanges: jest.fn(),
+ setGlobalFilter: jest.fn(),
+} as TabularSchemaEditorTableProps;
+
+it("should render all the components", () => {
+ const wrapper = mount();
+ expect(wrapper.find(".table-control")).toExist();
+ expect(wrapper.find(".pagination-buttons")).toHaveLength(2);
+ expect(wrapper.find("thead")).toExist();
+ expect(wrapper.find("tbody")).toExist();
+ expect(wrapper.find("tfoot")).toExist();
+});
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.tsx
new file mode 100644
index 00000000000..1980bfad69a
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTable.tsx
@@ -0,0 +1,251 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CdsButton } from "@cds/react/button";
+import { CdsSelect } from "@cds/react/select";
+import {
+ ColumnFiltersState,
+ ExpandedState,
+ flexRender,
+ getCoreRowModel,
+ getExpandedRowModel,
+ getFacetedMinMaxValues,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from "@tanstack/react-table";
+import Column from "components/js/Column";
+import Row from "components/js/Row";
+import LoadingWrapper from "components/LoadingWrapper";
+import { useState } from "react";
+import DebouncedInput from "./DebouncedInput";
+import { fuzzyFilter } from "./TabularSchemaEditorTableHelpers";
+import "./TabularSchemaEditorTable.css";
+import { IBasicFormParam } from "shared/types";
+
+export interface TabularSchemaEditorTableProps {
+ columns: any;
+ data: IBasicFormParam[];
+ globalFilter: any;
+ setGlobalFilter: any;
+ isLoading: boolean;
+ saveAllChanges: () => void;
+}
+
+export default function TabularSchemaEditorTable(props: TabularSchemaEditorTableProps) {
+ const { columns, data, globalFilter, setGlobalFilter, isLoading, saveAllChanges } = props;
+
+ // Component state
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [globalExpanded, setGlobalExpanded] = useState({});
+
+ const table = useReactTable({
+ data,
+ columns,
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ state: {
+ columnFilters,
+ globalFilter,
+ expanded: globalExpanded,
+ },
+ autoResetPageIndex: false,
+ getCoreRowModel: getCoreRowModel(),
+ getExpandedRowModel: getExpandedRowModel(),
+ getFacetedMinMaxValues: getFacetedMinMaxValues(),
+ getFacetedRowModel: getFacetedRowModel(),
+ getFacetedUniqueValues: getFacetedUniqueValues(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getSubRows: (row: IBasicFormParam) => row.params,
+ globalFilterFn: fuzzyFilter,
+ onColumnFiltersChange: setColumnFilters,
+ onExpandedChange: setGlobalExpanded,
+ onGlobalFilterChange: setGlobalFilter,
+ enableColumnResizing: true,
+ });
+
+ const paginationButtons = (
+ <>
+
+ {
+ saveAllChanges();
+ table.setPageIndex(0);
+ }}
+ disabled={!table.getCanPreviousPage()}
+ >
+ {"<<"}
+
+ {
+ saveAllChanges();
+ table.previousPage();
+ }}
+ disabled={!table.getCanPreviousPage()}
+ >
+ {"<"}
+
+
+
+
+ Page{" "}
+
+ {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
+
+
+
+ {
+ saveAllChanges();
+ table.nextPage();
+ }}
+ disabled={!table.getCanNextPage()}
+ >
+ {">"}
+
+ {
+ saveAllChanges();
+ table.setPageIndex(table.getPageCount() - 1);
+ }}
+ disabled={!table.getCanNextPage()}
+ >
+ {">>"}
+
+
+ >
+ );
+
+ const topButtons = (
+ <>
+
+
+ {paginationButtons}
+
+ {
+ saveAllChanges();
+ setGlobalFilter(String(value));
+ }}
+ placeholder="Type to search by key..."
+ />
+
+
+
+ >
+ );
+ const bottomButtons = (
+ <>
+
+
+ {paginationButtons}
+
+
+
+
+
+
+
+
+ >
+ );
+
+ const tableHeader = table.getHeaderGroups().map((headerGroup: any) => (
+
+ {headerGroup.headers.map((header: any) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+ |
+ ))}
+
+ ));
+
+ const tableBody = table.getRowModel().rows.map((row: any) => (
+
+ {row.getVisibleCells().map((cell: any) => (
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} |
+ ))}
+
+ ));
+
+ const tableFooter = table.getFooterGroups().map((footerGroup: any) => (
+
+ {footerGroup.headers.map((header: any) => (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.footer, header.getContext())}
+ |
+ ))}
+
+ ));
+
+ const tableObject = (
+
+ {tableHeader}
+ {tableBody}
+ {tableFooter}
+
+ );
+
+ return (
+
+ {topButtons}
+ {tableObject}
+ {bottomButtons}
+
+ );
+}
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableHelpers.ts b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableHelpers.ts
new file mode 100644
index 00000000000..7f6de524bf5
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableHelpers.ts
@@ -0,0 +1,27 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { compareItems, rankItem } from "@tanstack/match-sorter-utils";
+import { FilterFn, SortingFn, sortingFns } from "@tanstack/react-table";
+
+export const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => {
+ // Rank the item
+ const itemRank = rankItem(row.getValue(columnId), value);
+ // Store the itemRank info
+ addMeta({ itemRank });
+ // Return if the item should be filtered in/out
+ return itemRank.passed;
+};
+
+export const fuzzySort: SortingFn = (rowA: any, rowB: any, columnId: any) => {
+ let dir = 0;
+ // Only sort by rank if the column has ranking information
+ if (rowA.columnFiltersMeta[columnId]) {
+ dir = compareItems(
+ rowA.columnFiltersMeta[columnId]?.itemRank,
+ rowB.columnFiltersMeta[columnId]?.itemRank,
+ );
+ }
+ // Provide an alphanumeric fallback for when the item ranks are equal
+ return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir;
+};
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.test.tsx
new file mode 100644
index 00000000000..68ccaf464e1
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.test.tsx
@@ -0,0 +1,387 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { defaultStore, mountWrapper } from "shared/specs/mountWrapper";
+import { IBasicFormParam } from "shared/types";
+import { renderConfigCurrentValuePro } from "./TabularSchemaEditorTableRenderer";
+
+jest.useFakeTimers();
+
+[
+ {
+ 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);
+
+ t.params.forEach((param, i) => {
+ // eslint-disable-next-line testing-library/render-result-naming-convention
+ const wrapper = mountWrapper(
+ defaultStore,
+ renderConfigCurrentValuePro(param, handleBasicFormParamChange),
+ );
+ 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/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.tsx
new file mode 100644
index 00000000000..d7c768dfd5a
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/TabularSchemaEditorTableRenderer.tsx
@@ -0,0 +1,222 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CdsButton } from "@cds/react/button";
+import { CdsIcon } from "@cds/react/icon";
+import ReactTooltip from "react-tooltip";
+import { IBasicFormParam } from "shared/types";
+import ArrayParam from "./Params/ArrayParam";
+import BooleanParam from "./Params/BooleanParam";
+import CustomFormComponentLoader from "./Params/CustomFormParam";
+import SliderParam from "./Params/SliderParam";
+import TextParam from "./Params/TextParam";
+
+const MAX_LENGTH = 60;
+
+function renderCellWithTooltip(
+ value: IBasicFormParam,
+ property: string,
+ className = "",
+ trimFromBeginning = false,
+ maxLength = MAX_LENGTH,
+) {
+ // If the value is an object/array, we need to stringify it
+ const stringValue = ["string", "number"].includes(typeof value?.[property])
+ ? value?.[property] || ""
+ : JSON.stringify(value?.[property]);
+
+ if (stringValue?.length > maxLength) {
+ const trimmedString = trimFromBeginning
+ ? "..." + stringValue.substring(stringValue.length - maxLength, stringValue.length)
+ : stringValue.substring(0, maxLength - 1) + "...";
+
+ return (
+
+ {trimmedString}
+
+
+ );
+ } else {
+ return {stringValue};
+ }
+}
+
+export function renderConfigKeyHeader(table: any, _saveAllChanges: any) {
+ return (
+ <>
+
+ <>
+
+ {table.getIsAllRowsExpanded() ? (
+
+ ) : (
+
+ )}
+
+ Key
+ >
+
+ >
+ );
+}
+
+export function renderConfigKey(value: IBasicFormParam, row: any, _saveAllChanges: any) {
+ return (
+
+ <>
+
+
+ {row.getCanExpand() ? (
+ row.getIsExpanded() ? (
+
+ ) : (
+
+ )
+ ) : (
+ <>>
+ )}
+
+ {renderCellWithTooltip(value, "key", "breakable self-center", true, MAX_LENGTH / 1.5)}
+
+ >
+
+ );
+}
+
+export function renderConfigType(value: IBasicFormParam) {
+ return renderCellWithTooltip(value, "type", "italics");
+}
+
+export function renderConfigDescription(value: IBasicFormParam) {
+ return renderCellWithTooltip(value, "title", "breakable");
+}
+
+export function renderConfigDefaultValue(value: IBasicFormParam) {
+ return renderCellWithTooltip(value, "defaultValue", "breakable");
+}
+
+export function renderConfigDeployedValue(value: IBasicFormParam) {
+ return renderCellWithTooltip(value, "deployedValue");
+}
+
+export function renderConfigCurrentValuePro(
+ param: IBasicFormParam,
+ handleBasicFormParamChange: (
+ p: IBasicFormParam,
+ ) => (e: React.FormEvent) => void,
+) {
+ // early return if the value is marked as a custom form component
+ if (param.isCustomComponent) {
+ // TODO(agamez): consider using a modal window to display the full value
+ return (
+
+
+
+ );
+ }
+ // if the param has properties, each of them will be rendered as a row
+ if (param.hasProperties) {
+ return <>>;
+ }
+
+ // if it isn't a custom component or an with more properties, render an input
+ switch (param.type) {
+ case "string":
+ return (
+ s.match(/password/i)) ? "password" : "string"
+ }
+ handleBasicFormParamChange={handleBasicFormParamChange}
+ />
+ );
+
+ case "boolean":
+ return (
+
+ );
+
+ case "integer":
+ case "number":
+ return (
+
+ );
+ case "array":
+ if (param?.schema?.items?.type !== "object") {
+ return (
+
+ );
+ } else {
+ // TODO(agamez): render the object properties
+ return (
+
+ );
+ }
+ default:
+ return (
+
+ );
+ }
+}
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/index.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/index.tsx
new file mode 100644
index 00000000000..db385a75f26
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/TabularSchemaEditorTable/index.tsx
@@ -0,0 +1,6 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import TabularSchemaEditorTable from "./TabularSchemaEditorTable";
+
+export default TabularSchemaEditorTable;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/index.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/index.tsx
similarity index 100%
rename from dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/index.tsx
rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/BasicDeploymentForm/index.tsx
diff --git a/dashboard/src/components/DeploymentFormBody/DeploymentFormBody.test.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.test.tsx
similarity index 61%
rename from dashboard/src/components/DeploymentFormBody/DeploymentFormBody.test.tsx
rename to dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.test.tsx
index 099aeea5bac..ba179cb35ea 100644
--- a/dashboard/src/components/DeploymentFormBody/DeploymentFormBody.test.tsx
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.test.tsx
@@ -7,11 +7,11 @@ import {
PackageAppVersion,
} from "gen/kubeappsapis/core/packages/v1alpha1/packages";
import { act } from "react-dom/test-utils";
+import { MonacoDiffEditor } from "react-monaco-editor";
import { defaultStore, mountWrapper } from "shared/specs/mountWrapper";
import { IPackageState } from "shared/types";
import BasicDeploymentForm from "./BasicDeploymentForm";
-import DeploymenetFormBody, { IDeploymentFormBodyProps } from "./DeploymentFormBody";
-import DifferentialSelector from "./DifferentialSelector";
+import DeploymentFormBody, { IDeploymentFormBodyProps } from "./DeploymentFormBody";
beforeEach(() => {
// mock the window.matchMedia for selecting the theme
@@ -30,7 +30,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,
@@ -41,7 +41,49 @@ 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,
+ value: jest.fn().mockImplementation(() => ({
+ clearRect: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+beforeEach(() => {
+ // mock the window.matchMedia for selecting the theme
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ configurable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(),
+ removeListener: jest.fn(),
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+
+ // mock the window.ResizeObserver, required by the MonacoDiffEditor for the layout
+ Object.defineProperty(window, "ResizeObserver", {
+ writable: true,
+ configurable: true,
+ value: jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ })),
+ });
+
+ // mock the window.HTMLCanvasElement.getContext(), required by the MonacoDiffEditor for the layout
Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
writable: true,
configurable: true,
@@ -64,6 +106,7 @@ const defaultProps: IDeploymentFormBodyProps = {
appValues: "foo: bar\n",
setValues: jest.fn(),
setValuesModified: jest.fn(),
+ formRef: { current: null },
};
jest.useFakeTimers();
@@ -92,22 +135,26 @@ c: d
const wrapper = mountWrapper(
defaultStore,
- ,
+ ,
);
- expect(wrapper.find(DifferentialSelector).prop("defaultValues")).toBe(oldValues);
+ expect(wrapper.find(MonacoDiffEditor).prop("original")).toBe(oldValues);
// Trigger a change in the basic form and a YAML parse
- const input = wrapper.find(BasicDeploymentForm).find("input");
+ const input = wrapper
+ .find(BasicDeploymentForm)
+ .find("input")
+ .filterWhere(i => i.prop("id") === "a"); // the input for the property "a"
+
act(() => {
input.simulate("change", { currentTarget: "e" });
jest.advanceTimersByTime(500);
});
wrapper.update();
- // The original double empty line gets deleted
const expectedValues = `a: b
+
c: d
`;
- expect(wrapper.find(DifferentialSelector).prop("defaultValues")).toBe(expectedValues);
+ expect(wrapper.find(MonacoDiffEditor).prop("original")).toBe(expectedValues);
});
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.tsx
new file mode 100644
index 00000000000..210ac5f7317
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/DeploymentFormBody.tsx
@@ -0,0 +1,317 @@
+// Copyright 2019-2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CdsButton } from "@cds/react/button";
+import { CdsControlMessage } from "@cds/react/forms";
+import { CdsIcon } from "@cds/react/icon";
+import ConfirmDialog from "components/ConfirmDialog";
+import Alert from "components/js/Alert";
+import LoadingWrapper from "components/LoadingWrapper";
+import Tabs from "components/Tabs";
+import { isEmpty } from "lodash";
+import { FormEvent, RefObject, useCallback, useEffect, useState } from "react";
+import { retrieveBasicFormParams, updateCurrentConfigByKey } from "shared/schema";
+import { DeploymentEvent, IBasicFormParam, IPackageState } from "shared/types";
+import { getValueFromEvent } from "shared/utils";
+import { parseToYamlNode, setPathValueInYamlNode, toStringYamlNode } from "shared/yamlUtils";
+import YAML from "yaml";
+import AdvancedDeploymentForm from "./AdvancedDeploymentForm";
+import BasicDeploymentForm from "./BasicDeploymentForm/BasicDeploymentForm";
+
+export interface IDeploymentFormBodyProps {
+ deploymentEvent: DeploymentEvent;
+ packageId: string;
+ packageVersion: string;
+ deployedValues?: string;
+ packagesIsFetching: boolean;
+ selected: IPackageState["selected"];
+ appValues: string;
+ setValues: (values: string) => void;
+ setValuesModified: () => void;
+ formRef: RefObject;
+}
+
+function DeploymentFormBody({
+ deploymentEvent,
+ packageId,
+ packageVersion,
+ deployedValues: valuesFromTheDeployedPackage,
+ packagesIsFetching,
+ selected,
+ appValues: valuesFromTheParentContainer,
+ setValues: setValuesFromTheParentContainer,
+ setValuesModified,
+ formRef,
+}: IDeploymentFormBodyProps) {
+ const {
+ availablePackageDetail,
+ versions,
+ schema: schemaFromTheAvailablePackage,
+ values: valuesFromTheAvailablePackage,
+ pkgVersion,
+ error,
+ } = selected;
+
+ // Component state
+ const [paramsFromComponentState, setParamsFromComponentState] = useState([] as IBasicFormParam[]);
+ const [valuesFromTheAvailablePackageNodes, setValuesFromTheAvailablePackageNodes] = useState(
+ {} as YAML.Document.Parsed,
+ );
+ const [valuesFromTheDeployedPackageNodes, setValuesFromTheDeployedPackageNodes] = useState(
+ {} as YAML.Document.Parsed,
+ );
+ const [valuesFromTheParentContainerNodes, setValuesFromTheParentContainerNodes] = useState(
+ {} as YAML.Document.Parsed,
+ );
+ const [restoreModalIsOpen, setRestoreModalOpen] = useState(false);
+ const [isLoaded, setIsloaded] = useState(false);
+ const [isLoading, setIsloading] = useState(true);
+ const [unsavedChangesMap] = useState(new Map());
+
+ // setBasicFormParameters when basicFormParameters changes
+ useEffect(() => {
+ if (!isLoaded && schemaFromTheAvailablePackage && !isEmpty(valuesFromTheParentContainerNodes)) {
+ const initialParamsFromContainer = retrieveBasicFormParams(
+ valuesFromTheParentContainerNodes,
+ valuesFromTheAvailablePackageNodes,
+ schemaFromTheAvailablePackage,
+ deploymentEvent,
+ valuesFromTheDeployedPackageNodes,
+ );
+ setParamsFromComponentState(initialParamsFromContainer);
+ setIsloaded(true);
+ setIsloading(false);
+ }
+ }, [
+ deploymentEvent,
+ isLoaded,
+ paramsFromComponentState,
+ schemaFromTheAvailablePackage,
+ valuesFromTheAvailablePackageNodes,
+ valuesFromTheDeployedPackageNodes,
+ valuesFromTheParentContainerNodes,
+ ]);
+
+ // setDefaultValues when defaultValues changes
+ useEffect(() => {
+ if (valuesFromTheAvailablePackage) {
+ setValuesFromTheAvailablePackageNodes(parseToYamlNode(valuesFromTheAvailablePackage));
+ }
+ }, [isLoaded, valuesFromTheAvailablePackage]);
+
+ useEffect(() => {
+ if (valuesFromTheParentContainer) {
+ setValuesFromTheParentContainerNodes(parseToYamlNode(valuesFromTheParentContainer));
+ }
+ }, [isLoaded, valuesFromTheParentContainer]);
+
+ useEffect(() => {
+ if (valuesFromTheDeployedPackage) {
+ setValuesFromTheDeployedPackageNodes(parseToYamlNode(valuesFromTheDeployedPackage));
+ }
+ }, [isLoaded, valuesFromTheDeployedPackage, valuesFromTheParentContainer]);
+
+ const handleYAMLEditorChange = (value: string) => {
+ setValuesFromTheParentContainer(value);
+ setValuesModified();
+ };
+
+ const forceSubmit = useCallback(() => {
+ // the API was added recently, but should replace the manual dispatch of a submit event with bubbles:true (react>=17)
+ formRef?.current?.requestSubmit();
+ }, [formRef]);
+
+ const saveAllChanges = () => {
+ let newValuesFromTheParentContainer, newParamsFromComponentState;
+ unsavedChangesMap.forEach((value, key) => {
+ setIsloading(true);
+ setValuesModified();
+ const aa = updateCurrentConfigByKey(paramsFromComponentState, key, value);
+ newParamsFromComponentState = [...aa];
+ setParamsFromComponentState(newParamsFromComponentState);
+
+ newValuesFromTheParentContainer = toStringYamlNode(
+ setPathValueInYamlNode(valuesFromTheParentContainerNodes, key, value),
+ );
+ setValuesFromTheParentContainer(newValuesFromTheParentContainer);
+ });
+ unsavedChangesMap.clear();
+ setIsloading(false);
+ };
+
+ // save the pending changes and fire the submit event (via useEffect, to actually get the saved changes)
+ const handleDeployClick = () => {
+ saveAllChanges();
+ forceSubmit();
+ };
+
+ // re-build the table based on the new YAML
+ const refreshBasicParameters = () => {
+ if (schemaFromTheAvailablePackage && shouldRenderBasicForm(schemaFromTheAvailablePackage)) {
+ setParamsFromComponentState(
+ retrieveBasicFormParams(
+ valuesFromTheParentContainerNodes,
+ valuesFromTheAvailablePackageNodes,
+ schemaFromTheAvailablePackage,
+ deploymentEvent,
+ valuesFromTheDeployedPackageNodes,
+ ),
+ );
+ }
+ };
+
+ // a change in the table is just a new entry in the unsavedChangesMap for performance reasons
+ // later on, the changes will be saved in bulk
+ const handleTableChange = useCallback(
+ (value: IBasicFormParam) => {
+ return (e: FormEvent) => {
+ unsavedChangesMap.set(value.key, getValueFromEvent(e));
+ };
+ },
+ [unsavedChangesMap],
+ );
+
+ // The basic form should be rendered if there are params to show
+ const shouldRenderBasicForm = (schema: any) => {
+ return !isEmpty(schema?.properties);
+ };
+
+ const closeRestoreDefaultValuesModal = () => {
+ setRestoreModalOpen(false);
+ };
+
+ const openRestoreDefaultValuesModal = () => {
+ setRestoreModalOpen(true);
+ };
+
+ const restoreDefaultValues = () => {
+ setValuesFromTheParentContainer(valuesFromTheAvailablePackage || "");
+ if (schemaFromTheAvailablePackage) {
+ setParamsFromComponentState(
+ retrieveBasicFormParams(
+ valuesFromTheAvailablePackageNodes,
+ valuesFromTheAvailablePackageNodes,
+ schemaFromTheAvailablePackage,
+ deploymentEvent,
+ valuesFromTheDeployedPackageNodes,
+ ),
+ );
+ }
+ setRestoreModalOpen(false);
+ };
+
+ // early return if error
+ if (error) {
+ return (
+
+ Unable to fetch package "{packageId}" ({packageVersion}): Got {error.message}
+
+ );
+ }
+
+ // early return if loading
+ if (
+ packagesIsFetching ||
+ !availablePackageDetail ||
+ (!versions.length &&
+ shouldRenderBasicForm(schemaFromTheAvailablePackage) &&
+ !isEmpty(paramsFromComponentState) &&
+ !isEmpty(valuesFromTheAvailablePackageNodes))
+ ) {
+ return (
+
+ );
+ }
+
+ // creation of the each tab + its content
+ const tabColumns: JSX.Element[] = [];
+ const tabData: JSX.Element[] = [];
+
+ // Basic form creation
+ if (shouldRenderBasicForm(schemaFromTheAvailablePackage)) {
+ tabColumns.push(
+
+ Visual editor
+
,
+ );
+ tabData.push(
+ <>
+
+ >,
+ );
+ }
+
+ // Text editor creation
+ tabColumns.push(
+
+ YAML editor
+
,
+ );
+ tabData.push(
+ ,
+ );
+
+ return (
+
+
+
+
+ {/* eslint-disable jsx-a11y/anchor-is-valid */}
+
+ The unsaved changes will automatically be applied before deploying or when visualizing the
+ diff view. You can also{" "}
+
+ save the changes manually
+
+ .
+
+
+
+
+ Deploy {pkgVersion}
+
+
+ Restore Defaults
+
+
+
+ );
+}
+
+export default DeploymentFormBody;
diff --git a/dashboard/src/components/DeploymentForm/DeploymentFormBody/index.tsx b/dashboard/src/components/DeploymentForm/DeploymentFormBody/index.tsx
new file mode 100644
index 00000000000..c21e3b7463e
--- /dev/null
+++ b/dashboard/src/components/DeploymentForm/DeploymentFormBody/index.tsx
@@ -0,0 +1,6 @@
+// Copyright 2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import DeploymentFormBody from "./DeploymentFormBody";
+
+export default DeploymentFormBody;
diff --git a/dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.tsx b/dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.tsx
deleted file mode 100644
index 82d8ea865f1..00000000000
--- a/dashboard/src/components/DeploymentFormBody/AdvancedDeploymentForm.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import MonacoEditor from "react-monaco-editor";
-import { useSelector } from "react-redux";
-import { SupportedThemes } from "shared/Config";
-import { IStoreState } from "shared/types";
-
-export interface IAdvancedDeploymentForm {
- appValues?: string;
- handleValuesChange: (value: string) => void;
- children?: JSX.Element;
-}
-
-function AdvancedDeploymentForm(props: IAdvancedDeploymentForm) {
- let timeout: NodeJS.Timeout;
- const onChange = (value: string) => {
- // Gather changes before submitting
- clearTimeout(timeout);
- timeout = setTimeout(() => props.handleValuesChange(value), 500);
- };
- const {
- config: { theme },
- } = useSelector((state: IStoreState) => state);
-
- return (
-
-
- {props.children}
-
- );
-}
-
-export default AdvancedDeploymentForm;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss
deleted file mode 100644
index 8cde88ea6c9..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.scss
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-.description {
- display: block;
- color: var(--cds-global-typography-color-300, #454545);
- font-size: 0.9em;
-}
-
-.subsection {
- padding: 10px;
- border: 2px solid var(--cds-alias-object-border-color, #f1f1f1);
- border-radius: 5px;
-}
-
-.react-switch {
- margin: 0 10px 0 0;
- vertical-align: middle;
-}
-
-.block::before {
- display: inline-block;
- height: 100%;
- content: "";
- vertical-align: middle;
-}
-
-.centered {
- display: inline-block;
-}
-
-.basic-deployment-form-param {
- padding: 0.6rem 0;
-}
-
-.deployment-form {
- margin-top: 1rem;
- margin-bottom: 1rem;
-}
-
-.deployment-form-label {
- margin-bottom: 0.2rem;
- font-weight: 600;
-
- &-text-param {
- display: block;
- }
-}
-
-.deployment-form-text-input {
- min-width: 30vw;
-}
-
-.param-separator {
- border: 1px solid var(--cds-alias-object-border-color, #f1f1f1);
-}
-
-.slider-block {
- display: flex;
-
- .slider-input-and-unit {
- margin-left: 0.6rem;
- }
-
- .slider-content {
- width: 30vw;
- margin-left: 10px;
- }
-
- .slider-input {
- width: 50%;
- }
-}
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx
deleted file mode 100644
index 8a1cd327bdc..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.test.tsx
+++ /dev/null
@@ -1,485 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { mount } from "enzyme";
-import { Slider } from "react-compound-slider";
-import { DeploymentEvent, IBasicFormParam, IBasicFormSliderParam } from "shared/types";
-import BasicDeploymentForm from "./BasicDeploymentForm";
-import Subsection from "./Subsection";
-
-jest.useFakeTimers();
-
-const defaultProps = {
- deploymentEvent: "install" as DeploymentEvent,
- params: [],
- handleBasicFormParamChange: jest.fn(() => jest.fn()),
- appValues: "",
- handleValuesChange: jest.fn(),
-};
-
-[
- {
- description: "renders a basic deployment with a username",
- params: [{ path: "wordpressUsername", value: "user" } as IBasicFormParam],
- },
- {
- description: "renders a basic deployment with a password",
- params: [{ path: "wordpressPassword", value: "sserpdrow" } as IBasicFormParam],
- },
- {
- description: "renders a basic deployment with a email",
- params: [{ path: "wordpressEmail", value: "user@example.com" } as IBasicFormParam],
- },
- {
- description: "renders a basic deployment with a generic string",
- params: [{ path: "blogName", value: "my-blog", type: "string" } as IBasicFormParam],
- },
- {
- description: "renders a basic deployment with custom configuration",
- params: [
- {
- path: "configuration",
- value: "First line\nSecond line",
- render: "textArea",
- type: "string",
- } as IBasicFormParam,
- ],
- },
- {
- description: "renders a basic deployment with a disk size",
- params: [
- {
- path: "size",
- value: "10Gi",
- type: "string",
- render: "slider",
- } as IBasicFormParam,
- ],
- },
- {
- description: "renders a basic deployment with a integer disk size",
- params: [
- {
- path: "size",
- value: 10,
- type: "integer",
- render: "slider",
- } as IBasicFormParam,
- ],
- },
- {
- description: "renders a basic deployment with a number disk size",
- params: [
- {
- path: "size",
- value: 10.0,
- type: "number",
- render: "slider",
- } as IBasicFormParam,
- ],
- },
- {
- description: "renders a basic deployment with slider parameters",
- params: [
- {
- path: "size",
- value: "10Gi",
- type: "string",
- render: "slider",
- sliderMin: 1,
- sliderMax: 100,
- sliderStep: 1,
- sliderUnit: "Gi",
- } as IBasicFormSliderParam,
- ],
- },
- {
- description: "renders a basic deployment with username, password, email and a generic string",
- params: [
- { path: "wordpressUsername", value: "user" } as IBasicFormParam,
- { path: "wordpressPassword", value: "sserpdrow" } as IBasicFormParam,
- { path: "wordpressEmail", value: "user@example.com" } as IBasicFormParam,
- { path: "blogName", value: "my-blog", type: "string" } as IBasicFormParam,
- ],
- },
- {
- description: "renders a basic deployment with a generic boolean",
- params: [{ path: "enableMetrics", value: true, type: "boolean" } as IBasicFormParam],
- },
- {
- description: "renders a basic deployment with a generic number",
- params: [{ path: "replicas", value: 1, type: "integer" } as IBasicFormParam],
- },
-].forEach(t => {
- it(t.description, () => {
- const onChange = jest.fn();
- const handleBasicFormParamChange = jest.fn(() => onChange);
- const wrapper = mount(
- ,
- );
- expect(wrapper).toMatchSnapshot();
-
- t.params.forEach((param, i) => {
- let input = wrapper.find(`input#${param.path}-${i}`);
- switch (param.type) {
- case "number":
- case "integer":
- if (param.render === "slider") {
- expect(wrapper.find(Slider)).toExist();
- break;
- }
- expect(input.prop("type")).toBe("number");
- break;
- case "string":
- if (param.render === "slider") {
- expect(wrapper.find(Slider)).toExist();
- break;
- }
- if (param.render === "textArea") {
- input = wrapper.find(`textarea#${param.path}-${i}`);
- expect(input).toExist();
- break;
- }
- if (param.path.match("Password")) {
- expect(input.prop("type")).toBe("password");
- break;
- }
- expect(input.prop("type")).toBe("string");
- break;
- default:
- // Ignore the rest of cases
- }
- input.simulate("change");
- const mockCalls = handleBasicFormParamChange.mock.calls;
- expect(mockCalls[i]).toEqual([param]);
- jest.runAllTimers();
- expect(onChange.mock.calls.length).toBe(i + 1);
- });
- });
-});
-
-it("should render an external database section", () => {
- const params = [
- {
- path: "edbs",
- value: {},
- type: "object",
- children: [{ path: "mariadb.enabled", value: {}, type: "boolean" }],
- } as IBasicFormParam,
- ];
- const wrapper = mount();
-
- const dbsec = wrapper.find(Subsection);
- expect(dbsec).toExist();
-});
-
-it("should hide an element if it depends on a param (string)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: "bar",
- },
- {
- path: "bar",
- type: "boolean",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: true";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should hide an element if it depends on a single param (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- value: "enabled",
- path: "bar",
- },
- },
- {
- path: "bar",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: enabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should hide an element using hidden path and values even if it is not present in values.yaml (simple)", () => {
- const params = [
- {
- default: "a",
- enum: ["a", "b"],
- path: "dropdown",
- type: "string",
- value: "a",
- },
- {
- hidden: { path: "dropdown", value: "b" },
- path: "a",
- type: "string",
- },
- {
- hidden: { path: "dropdown", value: "a" },
- path: "b",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
- expect(hiddenParam.text()).toBe("b");
-});
-
-it("should hide an element using hidden path and values even if it is not present in values.yaml (different depth levels)", () => {
- const params = [
- {
- default: "a",
- enum: ["a", "b"],
- path: "dropdown",
- type: "string",
- value: "a",
- },
- {
- hidden: { path: "secondLevelProperties/2dropdown", value: "2b" },
- path: "a",
- type: "string",
- },
- {
- hidden: { path: "secondLevelProperties/2dropdown", value: "2a" },
- path: "b",
- type: "string",
- },
- {
- default: "2a",
- enum: ["2a", "2b"],
- path: "secondLevelProperties/2dropdown",
- type: "string",
- value: "2a",
- },
- {
- hidden: { path: "dropdown", value: "b" },
- path: "secondLevelProperties/2a",
- type: "string",
- },
- {
- hidden: { path: "dropdown", value: "a" },
- path: "secondLevelProperties/2b",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
- expect(hiddenParam.filterWhere(p => p.text().includes("b"))).toExist();
- expect(hiddenParam.filterWhere(p => p.text().includes("2b"))).toExist();
-});
-
-it("should hide an element if it depends on multiple params (AND) (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- conditions: [
- {
- value: "enabled",
- path: "bar",
- },
- {
- value: "disabled",
- path: "baz",
- },
- ],
- operator: "and",
- },
- },
- {
- path: "bar",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: enabled\nbaz: disabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should hide an element if it depends on multiple params (OR) (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- conditions: [
- {
- value: "enabled",
- path: "bar",
- },
- {
- value: "disabled",
- path: "baz",
- },
- ],
- operator: "or",
- },
- },
- {
- path: "bar",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: enabled\nbaz: enabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should hide an element if it depends on multiple params (NOR) (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- conditions: [
- {
- value: "enabled",
- path: "bar",
- },
- {
- value: "disabled",
- path: "baz",
- },
- ],
- operator: "nor",
- },
- },
- {
- path: "bar",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: disabled\nbaz: enabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should hide an element if it depends on the deploymentEvent (install | upgrade) (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- event: "upgrade",
- },
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: disabled\nbaz: enabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
-
-it("should NOT hide an element if it depends on the deploymentEvent (install | upgrade) (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- event: "upgrade",
- },
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: disabled\nbaz: enabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).not.toExist();
-});
-
-it("should hide an element if it depends on deploymentEvent (install | upgrade) combined with multiple params (object)", () => {
- const params = [
- {
- path: "foo",
- type: "string",
- hidden: {
- conditions: [
- {
- event: "upgrade",
- },
- {
- value: "enabled",
- path: "bar",
- },
- ],
- operator: "or",
- },
- },
- {
- path: "bar",
- type: "string",
- },
- ] as IBasicFormParam[];
- const appValues = "foo: 1\nbar: disabled";
- const wrapper = mount(
- ,
- );
-
- const hiddenParam = wrapper.find("div").filterWhere(p => p.prop("hidden") === true);
- expect(hiddenParam).toExist();
-});
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx
deleted file mode 100644
index 4d72c4decbb..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BasicDeploymentForm.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { DeploymentEvent, IBasicFormParam } from "shared/types";
-import "./BasicDeploymentForm.css";
-import Param from "./Param";
-
-export interface IBasicDeploymentFormProps {
- deploymentEvent: DeploymentEvent;
- params: IBasicFormParam[];
- handleBasicFormParamChange: (
- p: IBasicFormParam,
- ) => (e: React.FormEvent) => void;
- handleValuesChange: (value: string) => void;
- appValues: string;
-}
-
-function BasicDeploymentForm(props: IBasicDeploymentFormProps) {
- return (
-
- {props.params.map((param, i) => {
- const id = `${param.path}-${i}`;
- return (
-
- );
- })}
-
- );
-}
-
-export default BasicDeploymentForm;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.test.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.test.tsx
deleted file mode 100644
index 2845252161c..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.test.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { mount } from "enzyme";
-import { IBasicFormParam } from "shared/types";
-import BooleanParam from "./BooleanParam";
-
-const param = { path: "enableMetrics", value: true, type: "boolean" } as IBasicFormParam;
-const defaultProps = {
- id: "foo",
- label: "Enable Metrics",
- param,
- handleBasicFormParamChange: jest.fn(),
-};
-
-it("should render a boolean param with title and description", () => {
- const wrapper = mount();
- const s = wrapper.find(".react-switch").first();
- expect(s.prop("checked")).toBe(defaultProps.param.value);
- expect(wrapper).toMatchSnapshot();
-});
-
-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(".react-switch").first();
-
- (s.prop("onChange") as any)(false);
-
- expect(handleBasicFormParamChange.mock.calls[0][0]).toEqual({
- path: "enableMetrics",
- type: "boolean",
- value: true,
- });
- expect(handler.mock.calls[0][0]).toMatchObject({
- currentTarget: { value: "false", type: "checkbox", checked: false },
- });
-});
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.tsx
deleted file mode 100644
index b8ebe704656..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/BooleanParam.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import Switch from "react-switch";
-import { IBasicFormParam } from "shared/types";
-
-export interface IStringParamProps {
- id: string;
- label: string;
- param: IBasicFormParam;
- handleBasicFormParamChange: (
- p: IBasicFormParam,
- ) => (e: React.FormEvent) => void;
-}
-
-function BooleanParam({ id, param, label, handleBasicFormParamChange }: IStringParamProps) {
- // handleChange transform the event received by the Switch component to a checkbox event
- const handleChange = (checked: boolean) => {
- const event = {
- currentTarget: { value: String(checked), type: "checkbox", checked },
- } as React.FormEvent;
- handleBasicFormParamChange(param)(event);
- };
-
- return (
-
- );
-}
-
-export default BooleanParam;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Param.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Param.tsx
deleted file mode 100644
index a323f886ffc..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Param.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { isArray } from "lodash";
-import React from "react";
-import { getValue } from "shared/schema";
-import { DeploymentEvent, IBasicFormParam, IBasicFormSliderParam } from "shared/types";
-import BooleanParam from "./BooleanParam";
-import CustomFormComponentLoader from "./CustomFormParam";
-import SliderParam from "./SliderParam";
-import Subsection from "./Subsection";
-import TextParam from "./TextParam";
-
-interface IParamProps {
- appValues: string;
- param: IBasicFormParam;
- allParams: IBasicFormParam[];
- id: string;
- handleBasicFormParamChange: (
- p: IBasicFormParam,
- ) => (e: React.FormEvent) => void;
- handleValuesChange: (value: string) => void;
- deploymentEvent: DeploymentEvent;
-}
-
-export default function Param({
- appValues,
- param,
- allParams,
- id,
- handleBasicFormParamChange,
- handleValuesChange,
- deploymentEvent,
-}: IParamProps) {
- let paramComponent: JSX.Element = <>>;
-
- const isHidden = () => {
- const hidden = param.hidden;
- switch (typeof hidden) {
- case "string":
- // If hidden is a string, it points to the value that should be true
- return evalCondition(hidden);
- case "object":
- // Two type of supported objects
- // A single condition: {value: string, path: any}
- // An array of conditions: {conditions: Array<{value: string, path: any}, operator: string}
- if (hidden.conditions?.length > 0) {
- // If hidden is an object, a different logic should be applied
- // based on the operator
- switch (hidden.operator) {
- case "and":
- // Every value matches the referenced
- // value (via jsonpath) in all the conditions
- return hidden.conditions.every(c => evalCondition(c.path, c.value, c.event));
- case "or":
- // It is enough if the value matches the referenced
- // value (via jsonpath) in any of the conditions
- return hidden.conditions.some(c => evalCondition(c.path, c.value, c.event));
- case "nor":
- // Every value mismatches the referenced
- // value (via jsonpath) in any of the conditions
- return hidden.conditions.every(c => !evalCondition(c.path, c.value, c.event));
- default:
- // we consider 'and' as the default operator
- return hidden.conditions.every(c => evalCondition(c.path, c.value, c.event));
- }
- } else {
- return evalCondition(hidden.path, hidden.value, hidden.event);
- }
- case "undefined":
- return false;
- }
- };
-
- const getParamMatchingPath = (params: IBasicFormParam[], path: string): any => {
- let targetParam;
- for (const p of params) {
- if (p.path === path) {
- targetParam = p;
- break;
- } else if (p.children && p.children?.length > 0) {
- targetParam = getParamMatchingPath(p.children, path);
- }
- }
- return targetParam;
- };
-
- const evalCondition = (
- path: string,
- expectedValue?: any,
- paramDeploymentEvent?: DeploymentEvent,
- ): boolean => {
- if (paramDeploymentEvent == null) {
- let val = getValue(appValues, path);
- // retrieve the value that the property pointed by path should have to be hidden.
- // https://github.com/vmware-tanzu/kubeapps/issues/1913
- if (val === undefined) {
- const target = getParamMatchingPath(allParams, path);
- val = target?.value;
- }
- return val === (expectedValue ?? true);
- } else {
- return paramDeploymentEvent === deploymentEvent;
- }
- };
-
- // Return early for custom components
- if (param.customComponent) {
- return (
-
-
-
- );
- }
-
- // If the type of the param is an array, represent it as its first type
- const type = isArray(param.type) ? param.type[0] : param.type;
- if (type === "boolean") {
- paramComponent = (
-
- );
- } else if (type === "object") {
- paramComponent = (
-
- );
- } else if (param.render === "slider") {
- const p = param as IBasicFormSliderParam;
- paramComponent = (
-
- );
- } else if (param.render === "textArea") {
- paramComponent = (
-
- );
- } else {
- const label = param.title || param.path;
- let inputType = "string";
- if (type === "integer") {
- inputType = "number";
- }
- if (
- type === "string" &&
- (param.render === "password" || label.toLowerCase().includes("password"))
- ) {
- inputType = "password";
- }
- paramComponent = (
-
- );
- }
-
- return (
-
- {paramComponent}
-
- );
-}
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.test.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.test.tsx
deleted file mode 100644
index dd4bb8972fc..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.test.tsx
+++ /dev/null
@@ -1,304 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { shallow } from "enzyme";
-import React from "react";
-import { IBasicFormParam } from "shared/types";
-import Slider from "../../Slider";
-import SliderParam from "./SliderParam";
-
-const defaultProps = {
- id: "disk",
- label: "Disk Size",
- handleBasicFormParamChange: jest.fn(() => jest.fn()),
- min: 1,
- max: 100,
- step: 1,
- unit: "Gi",
-};
-
-const params: IBasicFormParam[] = [
- {
- value: "10Gi",
- type: "string",
- path: "disk",
- },
- {
- value: 10,
- type: "integer",
- path: "disk",
- },
- {
- value: 10.0,
- type: "number",
- path: "disk",
- },
-];
-
-it("renders a disk size param with a default value", () => {
- params.forEach(param => {
- const wrapper = shallow();
- expect(wrapper.find(Slider).prop("values")).toBe(10);
- expect(wrapper).toMatchSnapshot();
- });
-});
-
-describe("when changing the slide", () => {
- it("changes the value of the string param", () => {
- params.forEach(param => {
- const cloneParam = { ...param } as IBasicFormParam;
- const expected = param.type === "string" ? "20Gi" : 20;
-
- const handleBasicFormParamChange = jest.fn(() => {
- cloneParam.value = expected;
- return jest.fn();
- });
-
- const wrapper = shallow(
- ,
- );
-
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const slider = wrapper.find(Slider);
- (slider.prop("onChange") as (values: number[]) => void)([20]);
-
- expect(cloneParam.value).toBe(expected);
- expect(handleBasicFormParamChange.mock.calls[0]).toEqual([
- { value: expected, type: param.type, path: param.path },
- ]);
- });
- });
-
- it("changes the value of the string param without unit", () => {
- params.forEach(param => {
- const cloneParam = { ...param } as IBasicFormParam;
- const expected = param.type === "string" ? "20" : 20;
-
- const handleBasicFormParamChange = jest.fn(() => {
- cloneParam.value = expected;
- return jest.fn();
- });
-
- const wrapper = shallow(
- ,
- );
-
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const slider = wrapper.find(Slider);
- (slider.prop("onChange") as (values: number[]) => void)([20]);
-
- expect(cloneParam.value).toBe(expected);
- expect(handleBasicFormParamChange.mock.calls[0]).toEqual([
- { value: expected, type: param.type, path: param.path },
- ]);
- });
- });
-
- it("changes the value of the string param with the step defined", () => {
- params.forEach(param => {
- const cloneProps = { ...defaultProps, step: 10 };
- const cloneParam = { ...param } as IBasicFormParam;
- const expected = param.type === "string" ? "20Gi" : 20;
-
- const handleBasicFormParamChange = jest.fn(() => {
- cloneParam.value = expected;
- return jest.fn();
- });
-
- const wrapper = shallow(
- ,
- );
-
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const slider = wrapper.find(Slider);
- (slider.prop("onChange") as (values: number[]) => void)([2]);
-
- expect(cloneParam.value).toBe(expected);
- expect(handleBasicFormParamChange.mock.calls[0]).toEqual([
- { value: expected, type: param.type, path: param.path },
- ]);
- });
- });
-});
-
-it("updates state but does not change param value during slider update (only when dropped in a point)", () => {
- params.forEach(param => {
- const handleBasicFormParamChange = jest.fn();
- const wrapper = shallow(
- ,
- );
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const slider = wrapper.find(Slider);
- (slider.prop("onUpdate") as (values: number[]) => void)([20]);
-
- expect(wrapper.find(Slider).prop("values")).toBe(20);
- expect(handleBasicFormParamChange).not.toHaveBeenCalled();
- });
-});
-
-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(Slider).prop("values")).toBe(10);
-
- const input = wrapper.find("input#disk");
- const event = { currentTarget: { value: "20" } } as React.FormEvent;
- (input.prop("onChange") as (e: React.FormEvent) => void)(event);
-
- expect(wrapper.find(Slider).prop("values")).toBe(20);
-
- const expected = param.type === "string" ? "20Gi" : 20;
- expect(valueChange.mock.calls[0]).toEqual([{ currentTarget: { value: expected } }]);
- });
- });
-
- it("parses a number and forwards it without unit", () => {
- params.forEach(param => {
- const valueChange = jest.fn();
- const handleBasicFormParamChange = jest.fn(() => valueChange);
- const wrapper = shallow(
- ,
- );
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const input = wrapper.find("input#disk");
- const event = { currentTarget: { value: "20" } } as React.FormEvent;
- (input.prop("onChange") as (e: React.FormEvent) => void)(event);
-
- expect(wrapper.find(Slider).prop("values")).toBe(20);
-
- const expected = param.type === "string" ? "20" : 20;
- expect(valueChange.mock.calls[0]).toEqual([{ currentTarget: { value: expected } }]);
- });
- });
-
- 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(Slider).prop("values")).toBe(10);
-
- const input = wrapper.find("input#disk");
- const event = { currentTarget: { value: "foo20*#@$" } } as React.FormEvent;
- (input.prop("onChange") as (e: React.FormEvent) => void)(event);
-
- expect(wrapper.find(Slider).prop("values")).toBe(20);
-
- const expected = param.type === "string" ? "20Gi" : 20;
- expect(valueChange.mock.calls[0]).toEqual([{ currentTarget: { value: expected } }]);
- });
- });
-
- it("accept decimal values", () => {
- params.forEach(param => {
- const valueChange = jest.fn();
- const handleBasicFormParamChange = jest.fn(() => valueChange);
- const wrapper = shallow(
- ,
- );
- expect(wrapper.find(Slider).prop("values")).toBe(10);
-
- const input = wrapper.find("input#disk");
- const event = { currentTarget: { value: "20.5" } } as React.FormEvent;
- (input.prop("onChange") as (e: React.FormEvent) => void)(event);
-
- expect(wrapper.find(Slider).prop("values")).toBe(20.5);
-
- const expected = param.type === "string" ? "20.5Gi" : 20.5;
- expect(valueChange.mock.calls[0]).toEqual([{ currentTarget: { value: expected } }]);
- });
- });
-
- 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(Slider).prop("values")).toBe(10);
-
- const input = wrapper.find("input#disk");
- const event = { currentTarget: { value: "200" } } as React.FormEvent;
- (input.prop("onChange") as (e: React.FormEvent) => void)(event);
-
- expect(wrapper.find(Slider).prop("values")).toBe(200);
- const slider = wrapper.find(Slider);
- expect(slider.prop("max")).toBe(200);
- });
- });
-});
-
-it("uses the param minimum and maximum if defined", () => {
- params.forEach(param => {
- const clonedParam = { ...param } as IBasicFormParam;
- clonedParam.minimum = 5;
- clonedParam.maximum = 50;
-
- const wrapper = shallow();
-
- const slider = wrapper.find(Slider);
- expect(slider.prop("min")).toBe(5);
- expect(slider.prop("max")).toBe(50);
- });
-});
-
-it("defaults to the min if the value is undefined", () => {
- params.forEach(param => {
- const cloneParam = { ...param } as IBasicFormParam;
- cloneParam.value = undefined;
-
- const wrapper = shallow();
- expect(wrapper.find(Slider).prop("values")).toBe(5);
- });
-});
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.tsx
deleted file mode 100644
index a8b95b6d531..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/SliderParam.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { useEffect, useState } from "react";
-import { IBasicFormParam } from "shared/types";
-import Slider from "../../Slider";
-
-export interface ISliderParamProps {
- id: string;
- label: string;
- param: IBasicFormParam;
- unit: string;
- min: number;
- max: number;
- step: number;
- handleBasicFormParamChange: (
- p: IBasicFormParam,
- ) => (e: React.FormEvent) => void;
-}
-
-export interface ISliderParamState {
- value: number;
-}
-
-function toNumber(value: string | number) {
- // Force to return a Number from a string removing any character that is not a digit
- return typeof value === "number" ? value : Number(value.replace(/[^\d.]/g, ""));
-}
-
-function getDefaultValue(min: number, value?: string) {
- return (value && toNumber(value)) || min;
-}
-
-function SliderParam({
- id,
- label,
- param,
- unit,
- min,
- max,
- step,
- handleBasicFormParamChange,
-}: ISliderParamProps) {
- const [value, setValue] = useState(getDefaultValue(min, param.value));
-
- useEffect(() => {
- setValue(getDefaultValue(min, param.value));
- }, [param, min]);
-
- const handleParamChange = (newValue: number) => {
- handleBasicFormParamChange(param)({
- currentTarget: {
- value: param.type === "string" ? `${newValue}${unit}` : newValue,
- },
- } as React.FormEvent);
- };
-
- // onChangeSlider is run when the slider is dropped at one point
- // at that point we update the parameter
- const onChangeSlider = (values: readonly number[]) => {
- handleParamChange(values[0]);
- };
-
- // onUpdateSlider is run when dragging the slider
- // we just update the state here for a faster response
- const onUpdateSlider = (values: readonly number[]) => {
- setValue(values[0]);
- };
-
- const onChangeInput = (e: React.FormEvent) => {
- const numberValue = toNumber(e.currentTarget.value);
- setValue(numberValue);
- handleParamChange(numberValue);
- };
-
- return (
-
-
-
- );
-}
-
-export default SliderParam;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.test.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.test.tsx
deleted file mode 100644
index 5babd4a7faa..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { shallow } from "enzyme";
-import { IBasicFormParam } from "shared/types";
-import Subsection, { ISubsectionProps } from "./Subsection";
-
-const defaultProps = {
- label: "Enable an external database",
- param: {
- children: [
- {
- path: "externalDatabase.database",
- type: "string",
- value: "bitnami_wordpress",
- },
- { path: "externalDatabase.host", type: "string", value: "localhost" },
- { path: "externalDatabase.password", type: "string" },
- { path: "externalDatabase.port", type: "integer", value: 3306 },
- {
- path: "externalDatabase.user",
- type: "string",
- value: "bn_wordpress",
- },
- {
- path: "mariadb.enabled",
- title: "Enable External Database",
- type: "boolean",
- value: true,
- } as IBasicFormParam,
- ],
- path: "externalDatabase",
- title: "External Database Details",
- description: "description of the param",
- type: "object",
- } as IBasicFormParam,
- allParams: [],
- appValues: "externalDatabase: {}",
- deploymentEvent: "install",
- handleValuesChange: jest.fn(),
-} as ISubsectionProps;
-
-it("should render a external database section", () => {
- const wrapper = shallow();
- expect(wrapper).toMatchSnapshot();
-});
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.tsx
deleted file mode 100644
index 2f7159a0a9f..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/Subsection.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { setValue } from "shared/schema";
-import { DeploymentEvent, IBasicFormParam } from "shared/types";
-import { getValueFromEvent } from "shared/utils";
-import Param from "./Param";
-
-export interface ISubsectionProps {
- label: string;
- param: IBasicFormParam;
- allParams: IBasicFormParam[];
- appValues: string;
- deploymentEvent: DeploymentEvent;
- handleValuesChange: (value: string) => void;
-}
-
-function Subsection({
- label,
- param,
- allParams,
- appValues,
- deploymentEvent,
- handleValuesChange,
-}: ISubsectionProps) {
- const handleChildrenParamChange = (childrenParam: IBasicFormParam) => {
- return (e: React.FormEvent) => {
- const value = getValueFromEvent(e);
- param.children = param.children!.map(p =>
- p.path === childrenParam.path ? { ...childrenParam, value } : p,
- );
- handleValuesChange(setValue(appValues, childrenParam.path, value));
- };
- };
-
- return (
-
-
-
- {param.description && (
- <>
-
- {param.description}
- >
- )}
-
- {param.children &&
- param.children.map((childrenParam, i) => {
- const id = `${childrenParam.path}-${i}`;
- return (
-
- );
- })}
-
- );
-}
-
-export default Subsection;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.test.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.test.tsx
deleted file mode 100644
index 641b1299f01..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.test.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { mount } from "enzyme";
-import React from "react";
-import { act } from "react-dom/test-utils";
-import { IBasicFormParam } from "shared/types";
-import TextParam from "./TextParam";
-
-jest.useFakeTimers();
-
-const stringParam = { path: "username", value: "user", type: "string" } as IBasicFormParam;
-const stringProps = {
- id: "foo",
- label: "Username",
- param: stringParam,
- handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()),
-};
-
-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.value);
- expect(wrapper).toMatchSnapshot();
-});
-
-it("should set the input type as number", () => {
- const wrapper = mount();
- const input = wrapper.find("input");
- expect(input.prop("type")).toBe("number");
-});
-
-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({
- path: "username",
- type: "string",
- value: "user",
- });
- expect(handler).toHaveBeenCalledWith(event);
-});
-
-it("should set the input value as empty if a string parameter value is not defined", () => {
- const tparam = { path: "username", type: "string" } as IBasicFormParam;
- const tprops = {
- id: "foo",
- name: "username",
- label: "Username",
- param: tparam,
- handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()),
- };
- const wrapper = mount();
- const input = wrapper.find("input");
- expect(input.prop("value")).toBe("");
-});
-
-const textAreaParam = {
- path: "configuration",
- value: "First line\nSecond line",
- type: "string",
-} as IBasicFormParam;
-const textAreaProps = {
- id: "bar",
- label: "Configuration",
- param: textAreaParam,
- handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()),
- inputType: "textarea",
-};
-
-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.value);
- expect(wrapper).toMatchSnapshot();
-});
-
-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({
- path: "configuration",
- type: "string",
- value: "First line\nSecond line",
- });
- expect(handler).toHaveBeenCalledWith(event);
-});
-
-it("should set the input value as empty if a textArea param value is not defined", () => {
- const tparam = { path: "configuration", type: "string" } as IBasicFormParam;
- const tprops = {
- id: "foo",
- name: "configuration",
- label: "Configuration",
- param: tparam,
- handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()),
- inputType: "textarea",
- };
- const wrapper = mount();
- const input = wrapper.find("textarea");
- expect(input.prop("value")).toBe("");
-});
-
-it("should render a string parameter as select with option tags", () => {
- const tparam = {
- path: "databaseType",
- value: "postgresql",
- type: "string",
- enum: ["mariadb", "postgresql"],
- } as IBasicFormParam;
- const tprops = {
- id: "foo",
- name: "databaseType",
- label: "databaseType",
- param: tparam,
- handleBasicFormParamChange: jest.fn().mockReturnValue(jest.fn()),
- };
- const wrapper = mount();
- const input = wrapper.find("select");
-
- expect(wrapper.find("select").prop("value")).toBe(tparam.value);
- if (tparam.enum != null) {
- const options = input.find("option");
- expect(options.length).toBe(tparam.enum.length);
-
- for (let i = 0; i < tparam.enum.length; i++) {
- const option = options.at(i);
- expect(option.text()).toBe(tparam.enum[i]);
- }
- }
-});
-
-it("should forward the proper value when using a select", () => {
- const tparam = {
- path: "databaseType",
- value: "postgresql",
- type: "string",
- enum: ["mariadb", "postgresql"],
- } as IBasicFormParam;
- const tprops = {
- id: "foo",
- name: "databaseType",
- label: "databaseType",
- param: tparam,
- };
- const handler = jest.fn();
- const handleBasicFormParamChange = jest.fn().mockReturnValue(handler);
- const wrapper = mount(
- ,
- );
- const input = wrapper.find("select");
-
- const event = { currentTarget: {} } as React.FormEvent;
- act(() => {
- (input.prop("onChange") as any)(event);
- });
-
- expect(handleBasicFormParamChange.mock.calls[0][0]).toEqual({
- path: "databaseType",
- type: "string",
- value: "postgresql",
- enum: ["mariadb", "postgresql"],
- });
- expect(handler.mock.calls[0][0]).toMatchObject(event);
-});
-
-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("");
-
- wrapper.setProps({
- param: {
- ...stringParam,
- value: "foo",
- },
- });
- wrapper.update();
- expect(wrapper.find("input").prop("value")).toBe("foo");
-});
-
-it("a change in a number param property should update the current value", () => {
- const numberParam = { path: "replicas", value: 0, type: "number" } as IBasicFormParam;
- const wrapper = mount();
- const input = wrapper.find("input");
- expect(input.prop("value")).toBe(0);
-
- wrapper.setProps({
- param: {
- ...numberParam,
- value: 1,
- },
- });
- wrapper.update();
- expect(wrapper.find("input").prop("value")).toBe(1);
-});
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.tsx b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.tsx
deleted file mode 100644
index 148d7fe63ca..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/TextParam.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { isEmpty, isNumber } from "lodash";
-import { useEffect, useState } from "react";
-import { IBasicFormParam } from "shared/types";
-
-export interface IStringParamProps {
- id: string;
- label: string;
- inputType?: string;
- param: IBasicFormParam;
- handleBasicFormParamChange: (
- param: IBasicFormParam,
- ) => (e: React.FormEvent) => void;
-}
-
-function TextParam({ id, param, label, inputType, handleBasicFormParamChange }: IStringParamProps) {
- const [value, setValue] = useState((param.value || "") as any);
- const [valueModified, setValueModified] = useState(false);
- const [timeout, setThisTimeout] = useState({} as NodeJS.Timeout);
- const onChange = (
- e: React.FormEvent,
- ) => {
- setValue(e.currentTarget.value);
- setValueModified(true);
- // 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), 500));
- };
-
- useEffect(() => {
- if ((isNumber(param.value) || !isEmpty(param.value)) && !valueModified) {
- setValue(param.value);
- }
- }, [valueModified, param.value]);
-
- let input = (
-
- );
- if (inputType === "textarea") {
- input = ;
- } else if (param.enum != null && param.enum.length > 0) {
- input = (
-
- );
- }
- return (
-
-
- {input}
- {param.description && {param.description}}
-
- );
-}
-
-export default TextParam;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BasicDeploymentForm.test.tsx.snap b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BasicDeploymentForm.test.tsx.snap
deleted file mode 100644
index 13d2161f8b4..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BasicDeploymentForm.test.tsx.snap
+++ /dev/null
@@ -1,2703 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders a basic deployment with a disk size 1`] = `
-
-
-
-`;
-
-exports[`renders a basic deployment with a email 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with a generic boolean 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with a generic number 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with a generic string 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with a integer disk size 1`] = `
-
-
-
-`;
-
-exports[`renders a basic deployment with a number disk size 1`] = `
-
-
-
-`;
-
-exports[`renders a basic deployment with a password 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with a username 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with custom configuration 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`renders a basic deployment with slider parameters 1`] = `
-
-
-
-`;
-
-exports[`renders a basic deployment with username, password, email and a generic string 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BooleanParam.test.tsx.snap b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BooleanParam.test.tsx.snap
deleted file mode 100644
index da39fc8c1a4..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/BooleanParam.test.tsx.snap
+++ /dev/null
@@ -1,140 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render a boolean param with title and description 1`] = `
-
-
-
-`;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/SliderParam.test.tsx.snap b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/SliderParam.test.tsx.snap
deleted file mode 100644
index dbf408d04c0..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/SliderParam.test.tsx.snap
+++ /dev/null
@@ -1,157 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`renders a disk size param with a default value 1`] = `
-
-`;
-
-exports[`renders a disk size param with a default value 2`] = `
-
-`;
-
-exports[`renders a disk size param with a default value 3`] = `
-
-`;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/Subsection.test.tsx.snap b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/Subsection.test.tsx.snap
deleted file mode 100644
index a6e32ebe9b8..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/Subsection.test.tsx.snap
+++ /dev/null
@@ -1,117 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render a external database section 1`] = `
-
-
-
-
-
- description of the param
-
-
-
-
-
-
-
-
-
-`;
diff --git a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/TextParam.test.tsx.snap b/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/TextParam.test.tsx.snap
deleted file mode 100644
index 82dd850aa71..00000000000
--- a/dashboard/src/components/DeploymentFormBody/BasicDeploymentForm/__snapshots__/TextParam.test.tsx.snap
+++ /dev/null
@@ -1,64 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render a string parameter with title and description 1`] = `
-
-
-
-
-
-
-`;
-
-exports[`should render a textArea parameter with title and description 1`] = `
-
-
-
-
-
-
-`;
diff --git a/dashboard/src/components/DeploymentFormBody/DeploymentFormBody.tsx b/dashboard/src/components/DeploymentFormBody/DeploymentFormBody.tsx
deleted file mode 100644
index 9a27e62b1d1..00000000000
--- a/dashboard/src/components/DeploymentFormBody/DeploymentFormBody.tsx
+++ /dev/null
@@ -1,200 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { CdsButton } from "@cds/react/button";
-import { CdsIcon } from "@cds/react/icon";
-import Alert from "components/js/Alert";
-import Tabs from "components/Tabs";
-import { isEqual } from "lodash";
-import { useEffect, useState } from "react";
-import { parseValues, retrieveBasicFormParams, setValue } from "../../shared/schema";
-import { DeploymentEvent, IBasicFormParam, IPackageState } from "../../shared/types";
-import { getValueFromEvent } from "../../shared/utils";
-import ConfirmDialog from "../ConfirmDialog/ConfirmDialog";
-import LoadingWrapper from "../LoadingWrapper/LoadingWrapper";
-import AdvancedDeploymentForm from "./AdvancedDeploymentForm";
-import BasicDeploymentForm from "./BasicDeploymentForm/BasicDeploymentForm";
-import DifferentialSelector from "./DifferentialSelector";
-import DifferentialTab from "./DifferentialTab";
-
-export interface IDeploymentFormBodyProps {
- deploymentEvent: DeploymentEvent;
- packageId: string;
- packageVersion: string;
- deployedValues?: string;
- packagesIsFetching: boolean;
- selected: IPackageState["selected"];
- appValues: string;
- setValues: (values: string) => void;
- setValuesModified: () => void;
-}
-
-function DeploymentFormBody({
- deploymentEvent,
- packageId,
- packageVersion,
- deployedValues,
- packagesIsFetching,
- selected,
- appValues,
- setValues,
- setValuesModified,
-}: IDeploymentFormBodyProps) {
- const [basicFormParameters, setBasicFormParameters] = useState([] as IBasicFormParam[]);
- const [restoreModalIsOpen, setRestoreModalOpen] = useState(false);
- const [defaultValues, setDefaultValues] = useState("");
-
- const { availablePackageDetail, versions, schema, values, pkgVersion, error } = selected;
-
- useEffect(() => {
- const params = retrieveBasicFormParams(appValues, schema);
- if (!isEqual(params, basicFormParameters)) {
- setBasicFormParameters(params);
- }
- }, [setBasicFormParameters, schema, appValues, basicFormParameters]);
-
- useEffect(() => {
- setDefaultValues(values || "");
- }, [values]);
-
- const handleValuesChange = (value: string) => {
- setValues(value);
- setValuesModified();
- };
- const refreshBasicParameters = () => {
- setBasicFormParameters(retrieveBasicFormParams(appValues, schema));
- };
-
- const handleBasicFormParamChange = (param: IBasicFormParam) => {
- const parsedDefaultValues = parseValues(defaultValues);
- return (e: React.FormEvent) => {
- setValuesModified();
- if (parsedDefaultValues !== defaultValues) {
- setDefaultValues(parsedDefaultValues);
- }
- const value = getValueFromEvent(e);
- setBasicFormParameters(
- basicFormParameters.map(p => (p.path === param.path ? { ...param, value } : p)),
- );
- // Change raw values
- setValues(setValue(appValues, param.path, value));
- };
- };
-
- // The basic form should be rendered if there are params to show
- const shouldRenderBasicForm = () => {
- return Object.keys(basicFormParameters).length > 0;
- };
-
- const closeRestoreDefaultValuesModal = () => {
- setRestoreModalOpen(false);
- };
-
- const openRestoreDefaultValuesModal = () => {
- setRestoreModalOpen(true);
- };
-
- const restoreDefaultValues = () => {
- if (values) {
- setValues(values);
- setBasicFormParameters(retrieveBasicFormParams(values, schema));
- }
- setRestoreModalOpen(false);
- };
- if (error) {
- return (
-
- Unable to fetch package "{packageId}" ({packageVersion}): Got {error.message}
-
- );
- }
- if (packagesIsFetching || !availablePackageDetail || !versions.length) {
- return (
-
- );
- }
- const tabColumns = [
- "YAML",
- ,
- ] as Array;
- const tabData = [
-
-
- Note: Only comments from the original package values will be preserved.
-
- ,
- ,
- ];
- if (shouldRenderBasicForm()) {
- tabColumns.unshift(
-
- Form
- ,
- );
- tabData.unshift(
- ,
- );
- }
-
- return (
-
-
-
-
-
-
-
- Deploy {pkgVersion}
-
- {/* TODO(andresmgot): CdsButton "type" property doesn't work, so we need to use a normal
-
- );
-}
-
-export default DeploymentFormBody;
diff --git a/dashboard/src/components/DeploymentFormBody/Differential.scss b/dashboard/src/components/DeploymentFormBody/Differential.scss
deleted file mode 100644
index 7653e0f0f61..00000000000
--- a/dashboard/src/components/DeploymentFormBody/Differential.scss
+++ /dev/null
@@ -1,14 +0,0 @@
-// Copyright 2020-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-.diff {
- overflow: auto;
- height: 500px;
- margin-bottom: 0.6rem;
-}
-
-.diff pre {
- border: none;
- font: 15px / normal, Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace;
- line-height: 12px !important;
-}
diff --git a/dashboard/src/components/DeploymentFormBody/Differential.test.tsx b/dashboard/src/components/DeploymentFormBody/Differential.test.tsx
deleted file mode 100644
index e50c686dd86..00000000000
--- a/dashboard/src/components/DeploymentFormBody/Differential.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { MonacoDiffEditor } from "react-monaco-editor";
-import { SupportedThemes } from "shared/Config";
-import { defaultStore, getStore, mountWrapper } from "shared/specs/mountWrapper";
-import { IStoreState } from "shared/types";
-import Differential from "./Differential";
-
-beforeEach(() => {
- // mock the window.matchMedia for selecting the theme
- Object.defineProperty(window, "matchMedia", {
- writable: true,
- configurable: true,
- value: jest.fn().mockImplementation(query => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- dispatchEvent: jest.fn(),
- })),
- });
-
- // mock the window.ResizeObserver, required by the MonacoEditor for the layout
- Object.defineProperty(window, "ResizeObserver", {
- writable: true,
- configurable: true,
- value: jest.fn().mockImplementation(() => ({
- observe: jest.fn(),
- unobserve: jest.fn(),
- disconnect: jest.fn(),
- })),
- });
-
- // mock the window.HTMLCanvasElement.getContext(), required by the MonacoEditor for the layout
- Object.defineProperty(HTMLCanvasElement.prototype, "getContext", {
- writable: true,
- configurable: true,
- value: jest.fn().mockImplementation(() => ({
- clearRect: jest.fn(),
- })),
- });
-});
-
-afterEach(() => {
- jest.restoreAllMocks();
-});
-
-it("should render a diff between two strings", () => {
- const wrapper = mountWrapper(
- defaultStore,
- empty} />,
- );
- expect(wrapper.find(MonacoDiffEditor).prop("value")).toBe("bar");
- expect(wrapper.find(MonacoDiffEditor).prop("original")).toBe("foo");
-});
-
-it("should print the emptyDiffText if there are no changes", () => {
- const wrapper = mountWrapper(
- defaultStore,
- No differences!}
- />,
- );
- expect(wrapper.text()).toMatch("No differences!");
- expect(wrapper.text()).not.toMatch("foo");
-});
-
-it("sets light theme by default", () => {
- const wrapper = mountWrapper(
- defaultStore,
- empty} />,
- );
- expect(wrapper.find(MonacoDiffEditor).prop("theme")).toBe("light");
-});
-
-it("changes theme", () => {
- const wrapper = mountWrapper(
- getStore({ config: { theme: SupportedThemes.dark } } as Partial),
- empty} />,
- );
- expect(wrapper.find(MonacoDiffEditor).prop("theme")).toBe("vs-dark");
-});
diff --git a/dashboard/src/components/DeploymentFormBody/Differential.tsx b/dashboard/src/components/DeploymentFormBody/Differential.tsx
deleted file mode 100644
index 0f939dd49b0..00000000000
--- a/dashboard/src/components/DeploymentFormBody/Differential.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { MonacoDiffEditor } from "react-monaco-editor";
-import { useSelector } from "react-redux";
-import { SupportedThemes } from "shared/Config";
-import { IStoreState } from "shared/types";
-import "./Differential.css";
-
-export interface IDifferentialProps {
- oldValues: string;
- newValues: string;
- emptyDiffElement: JSX.Element;
-}
-
-function Differential(props: IDifferentialProps) {
- const { oldValues, newValues, emptyDiffElement } = props;
- const {
- config: { theme },
- } = useSelector((state: IStoreState) => state);
-
- return (
-
- {oldValues === newValues ? (
- emptyDiffElement
- ) : (
-
- )}
-
- );
-}
-
-export default Differential;
diff --git a/dashboard/src/components/DeploymentFormBody/DifferentialSelector.test.tsx b/dashboard/src/components/DeploymentFormBody/DifferentialSelector.test.tsx
deleted file mode 100644
index 37f31f1d5a2..00000000000
--- a/dashboard/src/components/DeploymentFormBody/DifferentialSelector.test.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright 2020-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { shallow } from "enzyme";
-import Differential from "./Differential";
-import DifferentialSelector from "./DifferentialSelector";
-
-it("should use default values when first deploying", () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper.find(Differential).props()).toMatchObject({
- oldValues: "foo",
- newValues: "bar",
- });
-});
-
-it("should use deployed values when upgrading", () => {
- const wrapper = shallow(
- ,
- );
- expect(wrapper.find(Differential).props()).toMatchObject({
- oldValues: "foobar",
- newValues: "bar",
- });
-});
diff --git a/dashboard/src/components/DeploymentFormBody/DifferentialSelector.tsx b/dashboard/src/components/DeploymentFormBody/DifferentialSelector.tsx
deleted file mode 100644
index cb9882520f5..00000000000
--- a/dashboard/src/components/DeploymentFormBody/DifferentialSelector.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright 2020-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { DeploymentEvent } from "shared/types";
-import Differential from "./Differential";
-
-interface IDifferentialSelectorProps {
- deploymentEvent: DeploymentEvent;
- deployedValues: string;
- defaultValues: string;
- appValues: string;
-}
-
-export default function DifferentialSelector({
- deploymentEvent,
- deployedValues,
- defaultValues,
- appValues,
-}: IDifferentialSelectorProps) {
- let oldValues = "";
- let emptyDiffElement = <>>;
- if (deploymentEvent === "upgrade") {
- // If there are already some deployed values (upgrade scenario)
- // We compare the values from the old release and the new one
- oldValues = deployedValues;
- emptyDiffElement = (
-
-
- The values you have entered to upgrade this package with are identical to the currently
- deployed ones.
-
-
- If you want to restore the default values provided by the package, click on the{" "}
- Restore defaults button below.
-
-
- );
- } else {
- // If it's a new deployment, we show the different from the default
- // values for the selected version
- oldValues = defaultValues || "";
- emptyDiffElement = No changes detected from the package defaults.;
- }
- return (
-
- );
-}
diff --git a/dashboard/src/components/DeploymentFormBody/DifferentialTab.test.tsx b/dashboard/src/components/DeploymentFormBody/DifferentialTab.test.tsx
deleted file mode 100644
index 2b2d4a37218..00000000000
--- a/dashboard/src/components/DeploymentFormBody/DifferentialTab.test.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright 2020-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { CdsIcon } from "@cds/react/icon";
-import { mount } from "enzyme";
-import { act } from "react-dom/test-utils";
-import DifferentialTab from "./DifferentialTab";
-
-describe("when installing", () => {
- it("should show the changes icon if the values change", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(false);
- });
-
- it("should hide the changes icon if the values are the same", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(true);
- });
-
- it("clicking the tab removes the icon", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(false);
- act(() => {
- wrapper.find("div").simulate("click");
- });
- wrapper.update();
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(true);
- });
-
- it("setting default values removes the icon", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(false);
- act(() => {
- wrapper.setProps({ appValues: "foo" });
- });
- wrapper.update();
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(true);
- });
-});
-
-describe("when upgrading", () => {
- it("should show the changes icon if the values change", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(false);
- });
-
- it("should hide the changes icon if the values are the same", () => {
- const wrapper = mount(
- ,
- );
- expect(wrapper.find(CdsIcon).prop("hidden")).toBe(true);
- });
-});
diff --git a/dashboard/src/components/DeploymentFormBody/DifferentialTab.tsx b/dashboard/src/components/DeploymentFormBody/DifferentialTab.tsx
deleted file mode 100644
index 6e92bf94639..00000000000
--- a/dashboard/src/components/DeploymentFormBody/DifferentialTab.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright 2020-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-import { CdsIcon } from "@cds/react/icon";
-import { useEffect, useState } from "react";
-import { DeploymentEvent } from "shared/types";
-
-interface IDifferentialSelectorProps {
- deploymentEvent: DeploymentEvent;
- deployedValues: string;
- defaultValues: string;
- appValues: string;
-}
-
-export default function DifferentialTab({
- deploymentEvent,
- deployedValues,
- defaultValues,
- appValues,
-}: IDifferentialSelectorProps) {
- const [newChanges, setNewChanges] = useState(false);
- const [oldValues, setOldValues] = useState("");
- const setNewChangesFalse = () => setNewChanges(false);
-
- useEffect(() => {
- if (deploymentEvent === "upgrade") {
- // If there are already some deployed values (upgrade scenario)
- // We compare the values from the previously deployed release and the new one
- setOldValues(deployedValues);
- } else {
- // If it's a new deployment, we show the difference from the default
- // values for the selected version
- setOldValues(defaultValues || "");
- }
- }, [deployedValues, defaultValues, deploymentEvent]);
- useEffect(() => {
- if (oldValues !== "") {
- if (oldValues !== appValues) {
- setNewChanges(true);
- } else {
- setNewChanges(false);
- }
- }
- }, [oldValues, appValues]);
- return (
-
- Changes
-
-
- );
-}
diff --git a/dashboard/src/components/Layout/Clarity.tsx b/dashboard/src/components/Layout/Clarity.tsx
index 72cc962431f..918758a84d3 100644
--- a/dashboard/src/components/Layout/Clarity.tsx
+++ b/dashboard/src/components/Layout/Clarity.tsx
@@ -23,9 +23,11 @@ import {
helpIcon,
infoCircleIcon,
libraryIcon,
+ minusIcon,
moonIcon,
networkGlobeIcon,
plusCircleIcon,
+ plusIcon,
refreshIcon,
rewindIcon,
searchIcon,
@@ -36,10 +38,12 @@ import {
trashIcon,
uploadCloudIcon,
} from "@cds/core/icon";
-import "@cds/core/icon/register.js";
import "@cds/core/accordion/register.js";
-import "@clr/ui/clr-ui.min.css"; // light clarity UI theme
+import "@cds/core/badge/register.js";
import "@cds/core/checkbox/register.js";
+import "@cds/core/icon/register.js";
+import "@cds/core/range/register.js";
+import "@clr/ui/clr-ui.min.css"; // light clarity UI theme
Icons.addIcons(
angleIcon,
@@ -62,9 +66,11 @@ Icons.addIcons(
helpIcon,
infoCircleIcon,
libraryIcon,
+ minusIcon,
moonIcon,
networkGlobeIcon,
plusCircleIcon,
+ plusIcon,
refreshIcon,
rewindIcon,
searchIcon,
diff --git a/dashboard/src/components/Layout/Layout.scss b/dashboard/src/components/Layout/Layout.scss
index 8893ffe67fa..e7fcbd2972d 100644
--- a/dashboard/src/components/Layout/Layout.scss
+++ b/dashboard/src/components/Layout/Layout.scss
@@ -33,6 +33,18 @@
justify-content: center;
}
+.self-center {
+ align-self: center;
+}
+
+.bolder {
+ font-weight: bolder;
+}
+
+.left-align {
+ text-align: left;
+}
+
.flex-h-center {
display: flex;
width: 100%;
@@ -44,6 +56,14 @@
align-items: center;
}
+.italics {
+ font-style: italic;
+}
+
+.hidden {
+ visibility: hidden;
+}
+
.notification-icon {
cds-icon {
--color: var(--cds-alias-status-warning, #efc006);
@@ -82,7 +102,7 @@
.deployment-form {
&-tabs {
- margin-bottom: 0.6rem;
+ margin-bottom: 1rem;
&-data {
margin-top: 0.6rem;
diff --git a/dashboard/src/components/Slider/Slider.tsx b/dashboard/src/components/Slider/Slider.tsx
deleted file mode 100644
index 2c36245934b..00000000000
--- a/dashboard/src/components/Slider/Slider.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-// CODE ADAPTED FROM: https://sghall.github.io/react-compound-slider/#/slider-demos/horizontal
-import React from "react";
-import { Handles, Rail, Slider as ReactSlider, Tracks } from "react-compound-slider";
-import { Handle, Track } from "./components"; // example render components
-
-const sliderStyle: React.CSSProperties = {
- margin: "1.2em",
- position: "relative",
- width: "90%",
-};
-
-const railStyle: React.CSSProperties = {
- position: "absolute",
- width: "100%",
- height: 14,
- borderRadius: 7,
- cursor: "pointer",
- backgroundColor: "rgb(155,155,155)",
-};
-
-export interface ISliderProps {
- min: number;
- max: number;
- default: number;
- step: number;
- values: number;
- sliderStyle?: React.CSSProperties;
- onChange: (values: readonly number[]) => void;
- onUpdate: (values: readonly number[]) => void;
-}
-
-class Slider extends React.Component {
- public render() {
- const { min, max, step, values, onUpdate, onChange } = this.props;
- const domain = [min, max];
- return (
-
- {({ getRailProps }) => }
-
- {({ handles, getHandleProps }) => (
-
- {handles.map(handle => (
-
- ))}
-
- )}
-
-
- {({ tracks, getTrackProps }) => (
-
- {tracks.map(({ id, source, target }) => (
-
- ))}
-
- )}
-
-
- );
- }
-}
-
-export default Slider;
diff --git a/dashboard/src/components/Slider/components.tsx b/dashboard/src/components/Slider/components.tsx
deleted file mode 100644
index 2d157a7a894..00000000000
--- a/dashboard/src/components/Slider/components.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright 2019-2022 the Kubeapps contributors.
-// SPDX-License-Identifier: Apache-2.0
-
-// CODE FROM: https://sghall.github.io/react-compound-slider/#/slider-demos/horizontal
-import { GetHandleProps, GetTrackProps, SliderItem } from "react-compound-slider";
-
-// *******************************************************
-// HANDLE COMPONENT
-// *******************************************************
-interface IHandleProps {
- domain: number[];
- handle: SliderItem;
- getHandleProps: GetHandleProps;
-}
-
-/* eslint-disable react/prop-types */
-export const Handle: React.FC = ({
- domain: [min, max],
- handle: { id, value, percent },
- getHandleProps,
-}) => (
-
-);
-
-// *******************************************************
-// TRACK COMPONENT
-// *******************************************************
-interface ITrackProps {
- source: SliderItem;
- target: SliderItem;
- getTrackProps: GetTrackProps;
-}
-
-export const Track: React.FC = ({ source, target, getTrackProps }) => (
-
-);
diff --git a/dashboard/src/components/Tabs/Tabs.scss b/dashboard/src/components/Tabs/Tabs.scss
index 478955d5468..f4420becc03 100644
--- a/dashboard/src/components/Tabs/Tabs.scss
+++ b/dashboard/src/components/Tabs/Tabs.scss
@@ -3,6 +3,7 @@
.tabs {
max-width: 100%;
+ margin-bottom: 1em;
overflow-x: auto;
overflow-y: hidden;
diff --git a/dashboard/src/components/UpgradeForm/UpgradeForm.test.tsx b/dashboard/src/components/UpgradeForm/UpgradeForm.test.tsx
index 6ee9787d95c..128e74f8811 100644
--- a/dashboard/src/components/UpgradeForm/UpgradeForm.test.tsx
+++ b/dashboard/src/components/UpgradeForm/UpgradeForm.test.tsx
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import actions from "actions";
-import DeploymentFormBody from "components/DeploymentFormBody/DeploymentFormBody";
+import DeploymentFormBody from "components/DeploymentForm/DeploymentFormBody";
import Alert from "components/js/Alert";
import LoadingWrapper from "components/LoadingWrapper/LoadingWrapper";
import PackageHeader from "components/PackageHeader/PackageHeader";
@@ -133,7 +133,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,
@@ -144,7 +144,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/UpgradeForm/UpgradeForm.tsx b/dashboard/src/components/UpgradeForm/UpgradeForm.tsx
index 38806e114b5..d706fa8e655 100644
--- a/dashboard/src/components/UpgradeForm/UpgradeForm.tsx
+++ b/dashboard/src/components/UpgradeForm/UpgradeForm.tsx
@@ -4,28 +4,29 @@
import { CdsFormGroup } from "@cds/react/forms";
import actions from "actions";
import AvailablePackageDetailExcerpt from "components/Catalog/AvailablePackageDetailExcerpt";
+import DeploymentFormBody from "components/DeploymentForm/DeploymentFormBody";
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 PackageVersionSelector from "components/PackageHeader/PackageVersionSelector";
import { push } from "connected-react-router";
import * as jsonpatch from "fast-json-patch";
import * as yaml from "js-yaml";
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Action } from "redux";
import { ThunkDispatch } from "redux-thunk";
-import { deleteValue, setValue } from "../../shared/schema";
-import { IStoreState } from "../../shared/types";
-import * as url from "../../shared/url";
-import DeploymentFormBody from "../DeploymentFormBody/DeploymentFormBody";
-import LoadingWrapper from "../LoadingWrapper/LoadingWrapper";
+import { IStoreState } from "shared/types";
+import { deleteValue, setValue } from "shared/yamlUtils";
+import * as url from "shared/url";
export interface IUpgradeFormProps {
version?: string;
}
+// TODO(agamez): Use the YAML-node based functions to avoid re-parse the yaml again and again
function applyModifications(mods: jsonpatch.Operation[], values: string) {
// And we add any possible change made to the original version
if (mods.length) {
@@ -65,6 +66,7 @@ function UpgradeForm(props: IUpgradeFormProps) {
const [deployedValues, setDeployedValues] = useState("");
const [isDeploying, setIsDeploying] = useState(false);
const [valuesModified, setValuesModified] = useState(false);
+ const formRef = useRef(null);
useEffect(() => {
// This block just will be run once, given that populating
@@ -105,6 +107,7 @@ function UpgradeForm(props: IUpgradeFormProps) {
useEffect(() => {
if (installedAppAvailablePackageDetail?.defaultValues && !modifications) {
// Calculate modifications from the default values
+ //TODO(agamez): stop using this yaml.dump/load
const defaultValuesObj = yaml.load(installedAppAvailablePackageDetail?.defaultValues) || {};
const deployedValuesObj =
yaml.load(installedAppInstalledPackageDetail?.valuesApplied || "") || {};
@@ -223,7 +226,7 @@ function UpgradeForm(props: IUpgradeFormProps) {
-
diff --git a/dashboard/src/shared/schema.test.ts b/dashboard/src/shared/schema.test.ts
index c9a041371a5..f29a9eef090 100644
--- a/dashboard/src/shared/schema.test.ts
+++ b/dashboard/src/shared/schema.test.ts
@@ -1,8 +1,8 @@
// Copyright 2019-2022 the Kubeapps contributors.
// SPDX-License-Identifier: Apache-2.0
-import { deleteValue, getValue, retrieveBasicFormParams, setValue, validate } from "./schema";
-import { IBasicFormParam } from "./types";
+import { retrieveBasicFormParams, validateValuesSchema } from "./schema";
+import { parseToYamlNode } from "./yamlUtils";
describe("retrieveBasicFormParams", () => {
[
@@ -14,9 +14,15 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "user",
- value: "andres",
- } as IBasicFormParam,
+ type: "string",
+ form: true,
+ title: "user",
+ key: "user",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "andres",
+ },
],
},
{
@@ -27,8 +33,14 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "user",
- } as IBasicFormParam,
+ type: "string",
+ form: true,
+ title: "user",
+ key: "user",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ },
],
},
{
@@ -39,9 +51,17 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "user",
- value: "michael",
- } as IBasicFormParam,
+ type: "string",
+ form: true,
+ default: "michael",
+ title: "user",
+ key: "user",
+ schema: { type: "string", form: true, default: "michael" },
+ hasProperties: false,
+ deployedValue: "",
+ defaultValue: "michael",
+ currentValue: "michael",
+ },
],
},
{
@@ -52,9 +72,17 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "user",
- value: "foo",
- } as IBasicFormParam,
+ type: "string",
+ form: true,
+ default: "bar",
+ title: "user",
+ key: "user",
+ schema: { type: "string", form: true, default: "bar" },
+ hasProperties: false,
+ deployedValue: "",
+ defaultValue: "bar",
+ currentValue: "foo",
+ },
],
},
{
@@ -65,9 +93,17 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "user",
- value: "andres",
- } as IBasicFormParam,
+ type: "string",
+ form: true,
+ default: "andres",
+ title: "user",
+ key: "user",
+ schema: { type: "string", form: true, default: "andres" },
+ hasProperties: false,
+ deployedValue: "",
+ defaultValue: "andres",
+ currentValue: "andres",
+ },
],
},
{
@@ -83,9 +119,28 @@ describe("retrieveBasicFormParams", () => {
} as any,
result: [
{
- path: "credentials/user",
- value: "andres",
- } as IBasicFormParam,
+ type: "object",
+ properties: { user: { type: "string", form: true } },
+ title: "credentials",
+ key: "credentials",
+ schema: { type: "object", properties: { user: { type: "string", form: true } } },
+ hasProperties: true,
+ params: [
+ {
+ type: "string",
+ form: true,
+ title: "user",
+ key: "credentials/user",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "andres",
+ },
+ ],
+ deployedValue: "",
+ defaultValue: "",
+ currentValue: "",
+ },
],
},
{
@@ -123,17 +178,98 @@ service: ClusterIP
} as any,
result: [
{
- path: "credentials/admin/user",
- value: "andres",
- } as IBasicFormParam,
+ type: "object",
+ properties: {
+ admin: {
+ type: "object",
+ properties: {
+ user: { type: "string", form: true },
+ pass: { type: "string", form: true },
+ },
+ },
+ },
+ title: "credentials",
+ key: "credentials",
+ schema: {
+ type: "object",
+ properties: {
+ admin: {
+ type: "object",
+ properties: {
+ user: { type: "string", form: true },
+ pass: { type: "string", form: true },
+ },
+ },
+ },
+ },
+ hasProperties: true,
+ params: [
+ {
+ type: "object",
+ properties: {
+ user: { type: "string", form: true },
+ pass: { type: "string", form: true },
+ },
+ title: "admin",
+ key: "credentials/admin",
+ schema: {
+ type: "object",
+ properties: {
+ user: { type: "string", form: true },
+ pass: { type: "string", form: true },
+ },
+ },
+ hasProperties: true,
+ params: [
+ {
+ type: "string",
+ form: true,
+ title: "user",
+ key: "credentials/admin/user",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "andres",
+ },
+ {
+ type: "string",
+ form: true,
+ title: "pass",
+ key: "credentials/admin/pass",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "myPassword",
+ },
+ ],
+ deployedValue: "",
+ defaultValue: "",
+ currentValue: "",
+ },
+ ],
+ deployedValue: "",
+ defaultValue: "",
+ currentValue: "",
+ },
{
- path: "credentials/admin/pass",
- value: "myPassword",
- } as IBasicFormParam,
+ type: "number",
+ form: true,
+ title: "replicas",
+ key: "replicas",
+ schema: { type: "number", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: 1,
+ },
{
- path: "replicas",
- value: 1,
- } as IBasicFormParam,
+ type: "string",
+ title: "service",
+ key: "service",
+ schema: { type: "string" },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "ClusterIP",
+ },
],
},
{
@@ -151,12 +287,21 @@ service: ClusterIP
} as any,
result: [
{
- path: "blogName",
type: "string",
- value: "myBlog",
+ form: true,
title: "Blog Name",
description: "Title of the blog",
- } as IBasicFormParam,
+ key: "blogName",
+ schema: {
+ type: "string",
+ form: true,
+ title: "Blog Name",
+ description: "Title of the blog",
+ },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "myBlog",
+ },
],
},
{
@@ -180,19 +325,49 @@ externalDatabase:
} as any,
result: [
{
- path: "externalDatabase",
type: "object",
- children: [
+ form: true,
+ properties: {
+ name: { type: "string", form: true },
+ port: { type: "integer", form: true },
+ },
+ title: "externalDatabase",
+ key: "externalDatabase",
+ schema: {
+ type: "object",
+ form: true,
+ properties: {
+ name: { type: "string", form: true },
+ port: { type: "integer", form: true },
+ },
+ },
+ hasProperties: true,
+ params: [
{
- path: "externalDatabase/name",
type: "string",
+ form: true,
+ title: "name",
+ key: "externalDatabase/name",
+ schema: { type: "string", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "foo",
},
{
- path: "externalDatabase/port",
type: "integer",
+ form: true,
+ title: "port",
+ key: "externalDatabase/port",
+ schema: { type: "integer", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: 3306,
},
],
- } as IBasicFormParam,
+ deployedValue: "",
+ defaultValue: "",
+ currentValue: "",
+ },
],
},
{
@@ -203,7 +378,18 @@ externalDatabase:
foo: { type: "boolean", form: true },
},
} as any,
- result: [{ path: "foo", type: "boolean", value: false } as IBasicFormParam],
+ result: [
+ {
+ type: "boolean",
+ form: true,
+ title: "foo",
+ key: "foo",
+ schema: { type: "boolean", form: true },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: false,
+ },
+ ],
},
{
description: "should retrieve a param with enum values",
@@ -219,233 +405,32 @@ externalDatabase:
} as any,
result: [
{
- path: "databaseType",
type: "string",
- value: "postgresql",
+ form: true,
enum: ["mariadb", "postgresql"],
- } as IBasicFormParam,
+ title: "databaseType",
+ key: "databaseType",
+ schema: { type: "string", form: true, enum: ["mariadb", "postgresql"] },
+ hasProperties: false,
+ deployedValue: "",
+ currentValue: "postgresql",
+ },
],
},
].forEach(t => {
it(t.description, () => {
- expect(retrieveBasicFormParams(t.values, t.schema)).toMatchObject(t.result);
- });
- });
-});
-
-describe("getValue", () => {
- [
- {
- description: "should return a value",
- values: "foo: bar",
- path: "foo",
- result: "bar",
- },
- {
- description: "should return a nested value",
- values: "foo:\n bar: foobar",
- path: "foo/bar",
- result: "foobar",
- },
- {
- description: "should return a deeply nested value",
- values: "foo:\n bar:\n foobar: barfoo",
- path: "foo/bar/foobar",
- result: "barfoo",
- },
- {
- description: "should ignore an invalid path",
- values: "foo:\n bar:\n foobar: barfoo",
- path: "nope",
- result: undefined,
- },
- {
- description: "should ignore an invalid path (nested)",
- values: "foo:\n bar:\n foobar: barfoo",
- path: "not/exists",
- result: undefined,
- },
- {
- description: "should return the default value if the path is not valid",
- values: "foo: bar",
- path: "foobar",
- default: '"BAR"',
- result: '"BAR"',
- },
- {
- description: "should return a value with slashes in the key",
- values: "foo/bar: value",
- path: "foo~1bar",
- result: "value",
- },
- {
- description: "should return a value with slashes and dots in the key",
- values: "kubernetes.io/ingress.class: nginx",
- path: "kubernetes.io~1ingress.class",
- result: "nginx",
- },
- ].forEach(t => {
- it(t.description, () => {
- expect(getValue(t.values, t.path, t.default)).toEqual(t.result);
- });
- });
-});
-
-describe("setValue", () => {
- [
- {
- description: "should set a value",
- values: 'foo: "bar"',
- path: "foo",
- newValue: "BAR",
- result: 'foo: "BAR"\n',
- },
- {
- description: "should set a value preserving the existing scalar quotation (simple)",
- values: "foo: 'bar'",
- path: "foo",
- newValue: "BAR",
- result: "foo: 'BAR'\n",
- },
- {
- description: "should set a value preserving the existing scalar quotation (double)",
- values: 'foo: "bar"',
- path: "foo",
- newValue: "BAR",
- result: 'foo: "BAR"\n',
- },
- {
- description: "should set a value preserving the existing scalar quotation (none)",
- values: "foo: bar",
- path: "foo",
- newValue: "BAR",
- result: "foo: BAR\n",
- },
- {
- description: "should set a nested value",
- values: 'foo:\n bar: "foobar"',
- path: "foo/bar",
- newValue: "FOOBAR",
- result: 'foo:\n bar: "FOOBAR"\n',
- },
- {
- description: "should set a deeply nested value",
- values: 'foo:\n bar:\n foobar: "barfoo"',
- path: "foo/bar/foobar",
- newValue: "BARFOO",
- result: 'foo:\n bar:\n foobar: "BARFOO"\n',
- },
- {
- description: "should add a new value",
- values: "foo: bar",
- path: "new",
- newValue: "value",
- result: 'foo: bar\nnew: "value"\n',
- },
- {
- description: "should add a new nested value",
- values: "foo: bar",
- path: "this/new",
- newValue: 1,
- result: "foo: bar\nthis:\n new: 1\n",
- error: false,
- },
- {
- description: "should add a new deeply nested value",
- values: "foo: bar",
- path: "this/new/value",
- newValue: 1,
- result: "foo: bar\nthis:\n new:\n value: 1\n",
- error: false,
- },
- {
- description: "Adding a value for a path partially defined (null)",
- values: "foo: bar\nthis:\n",
- path: "this/new/value",
- newValue: 1,
- result: "foo: bar\nthis:\n new:\n value: 1\n",
- error: false,
- },
- {
- description: "Adding a value for a path partially defined (object)",
- values: "foo: bar\nthis: {}\n",
- path: "this/new/value",
- newValue: 1,
- result: "foo: bar\nthis: { new: { value: 1 } }\n",
- error: false,
- },
- {
- description: "Adding a value in an empty doc",
- values: "",
- path: "foo",
- newValue: "bar",
- result: 'foo: "bar"\n',
- error: false,
- },
- {
- description: "should add a value with slashes in the key",
- values: 'foo/bar: "test"',
- path: "foo~1bar",
- newValue: "value",
- result: 'foo/bar: "value"\n',
- },
- {
- description: "should add a value with slashes and dots in the key",
- values: 'kubernetes.io/ingress.class: "default"',
- path: "kubernetes.io~1ingress.class",
- newValue: "nginx",
- result: 'kubernetes.io/ingress.class: "nginx"\n',
- },
- ].forEach(t => {
- it(t.description, () => {
- if (t.error) {
- expect(() => setValue(t.values, t.path, t.newValue)).toThrow();
- } else {
- expect(setValue(t.values, t.path, t.newValue)).toEqual(t.result);
- }
- });
- });
-});
-
-describe("deleteValue", () => {
- [
- {
- description: "should delete a value",
- values: "foo: bar\nbar: foo\n",
- path: "bar",
- result: "foo: bar\n",
- },
- {
- description: "should delete a value from an array",
- values: `foo:
- - bar
- - foobar
-`,
- path: "foo/0",
- result: `foo:
- - foobar
-`,
- },
- {
- description: "should leave the document empty",
- values: "foo: bar",
- path: "foo",
- result: "\n",
- },
- {
- description: "noop when trying to delete a missing property",
- values: "foo: bar\nbar: foo\n",
- path: "var",
- result: "foo: bar\nbar: foo\n",
- },
- ].forEach(t => {
- it(t.description, () => {
- expect(deleteValue(t.values, t.path)).toEqual(t.result);
+ const result = retrieveBasicFormParams(
+ parseToYamlNode(t.values),
+ parseToYamlNode(""),
+ t.schema,
+ "install",
+ );
+ expect(result).toMatchObject(t.result);
});
});
});
-describe("validate", () => {
+describe("validateValuesSchema", () => {
[
{
description: "Should validate a valid object",
@@ -475,7 +460,7 @@ describe("validate", () => {
},
].forEach(t => {
it(t.description, () => {
- const res = validate(t.values, t.schema);
+ const res = validateValuesSchema(t.values, t.schema);
expect(res.valid).toBe(t.valid);
expect(res.errors).toEqual(t.errors);
});
diff --git a/dashboard/src/shared/schema.ts b/dashboard/src/shared/schema.ts
index 87f7a33c4e0..0ca1ecda6b2 100644
--- a/dashboard/src/shared/schema.ts
+++ b/dashboard/src/shared/schema.ts
@@ -2,166 +2,133 @@
// SPDX-License-Identifier: Apache-2.0
import Ajv, { ErrorObject, JSONSchemaType } from "ajv";
-import * as jsonpatch from "fast-json-patch";
+// TODO(agamez): check if we can replace this package by js-yaml or vice-versa
import * as yaml from "js-yaml";
-import { isEmpty, set } from "lodash";
+import { findIndex, isEmpty, set } from "lodash";
+import { DeploymentEvent, IAjvValidateResult, IBasicFormParam } from "shared/types";
// TODO(agamez): check if we can replace this package by js-yaml or vice-versa
-import YAML, { Scalar, ToStringOptions } from "yaml";
-import { IBasicFormParam } from "./types";
+import YAML from "yaml";
+import { getPathValueInYamlNode, getPathValueInYamlNodeWithDefault } from "./yamlUtils";
const ajv = new Ajv({ strict: false });
-const toStringOptions: ToStringOptions = {
- defaultKeyType: "PLAIN",
- defaultStringType: Scalar.QUOTE_DOUBLE, // Preserving double quotes in scalars (see https://github.com/vmware-tanzu/kubeapps/issues/3621)
- nullStr: "", // Avoid to explicitly add "null" when an element is not defined
-};
+const IS_CUSTOM_COMPONENT_PROP_NAME = "x-is-custom-component";
-// retrieveBasicFormParams iterates over a JSON Schema properties looking for `form` keys
-// It uses the raw yaml to setup default values.
-// It returns a key:value map for easier handling.
export function retrieveBasicFormParams(
- defaultValues: string,
- schema?: JSONSchemaType,
+ currentValues: YAML.Document.Parsed,
+ packageValues: YAML.Document.Parsed,
+ schema: JSONSchemaType,
+ deploymentEvent: DeploymentEvent,
+ deployedValues?: YAML.Document.Parsed,
parentPath?: string,
): IBasicFormParam[] {
let params: IBasicFormParam[] = [];
-
if (schema?.properties && !isEmpty(schema.properties)) {
const properties = schema.properties;
Object.keys(properties).forEach(propertyKey => {
+ const schemaProperty = properties[propertyKey] as JSONSchemaType;
// The param path is its parent path + the object key
const itemPath = `${parentPath || ""}${propertyKey}`;
- const { type, form } = properties[propertyKey];
- // If the property has the key "form", it's a basic parameter
- if (form) {
- // Use the default value either from the JSON schema or the default values
- const value = getValue(defaultValues, itemPath, properties[propertyKey].default);
- const param: IBasicFormParam = {
- ...properties[propertyKey],
- path: itemPath,
- type,
- value,
- enum: properties[propertyKey].enum?.map(
- (item: { toString: () => any }) => item?.toString() ?? "",
+ const isUpgrading = deploymentEvent === "upgrade" && deployedValues;
+ const isLeaf = !schemaProperty?.properties;
+
+ const param: IBasicFormParam = {
+ ...schemaProperty,
+ title: schemaProperty.title || propertyKey,
+ key: itemPath,
+ schema: schemaProperty,
+ hasProperties: Boolean(schemaProperty?.properties),
+ params: schemaProperty?.properties
+ ? retrieveBasicFormParams(
+ currentValues,
+ packageValues,
+ schemaProperty,
+ deploymentEvent,
+ deployedValues,
+ `${itemPath}/`,
+ )
+ : undefined,
+ enum: schemaProperty?.enum?.map((item: { toString: () => any }) => item?.toString() ?? ""),
+ // If exists, the value that is currently deployed
+ deployedValue: isLeaf
+ ? isUpgrading
+ ? getPathValueInYamlNode(deployedValues, itemPath)
+ : ""
+ : "",
+ // The default is the value comming from the package values or the one defined in the schema,
+ // or vice-verse, which one shoulf take precedence?
+ defaultValue: isLeaf
+ ? getPathValueInYamlNodeWithDefault(packageValues, itemPath, schemaProperty.default)
+ : "",
+ // same as default value, but this one will be later overwritten by the user input
+ currentValue: isLeaf
+ ? getPathValueInYamlNodeWithDefault(currentValues, itemPath, schemaProperty.default)
+ : "",
+ isCustomComponent:
+ schemaProperty?.customComponent || schemaProperty?.[IS_CUSTOM_COMPONENT_PROP_NAME],
+ };
+ params = params.concat(param);
+
+ if (!schemaProperty?.properties) {
+ params = params.concat(
+ retrieveBasicFormParams(
+ currentValues,
+ packageValues,
+ schemaProperty,
+ deploymentEvent,
+ deployedValues,
+ `${itemPath}/`,
),
- children:
- properties[propertyKey].type === "object"
- ? retrieveBasicFormParams(defaultValues, properties[propertyKey], `${itemPath}/`)
- : undefined,
- };
- params = params.concat(param);
- } else {
- // If the property is an object, iterate recursively
- if (schema.properties![propertyKey].type === "object") {
- params = params.concat(
- retrieveBasicFormParams(defaultValues, properties[propertyKey], `${itemPath}/`),
- );
- }
+ );
}
});
}
return params;
}
-function getDefinedPath(allElementsButTheLast: string[], doc: YAML.Document) {
- let currentPath: string[] = [];
- let foundUndefined = false;
- allElementsButTheLast.forEach(p => {
- // Iterate over the path until finding an element that is not defined
- if (!foundUndefined) {
- const pathToEvaluate = currentPath.concat(p);
- const elem = (doc as any).getIn(pathToEvaluate);
- if (elem === undefined || elem === null) {
- foundUndefined = true;
- } else {
- currentPath = pathToEvaluate;
- }
- }
- });
- return currentPath;
-}
-
-function splitPath(path: string): string[] {
- return (
- (path ?? "")
- // ignore the first slash, if exists
- .replace(/^\//, "")
- // split by slashes
- .split("/")
- );
-}
-
-function unescapePath(path: string[]): string[] {
- // jsonpath escapes slashes to not mistake then with objects so we need to revert that
- return path.map(p => jsonpatch.unescapePathComponent(p));
-}
-
-function parsePath(path: string): string[] {
- return unescapePath(splitPath(path));
-}
-
-function parsePathAndValue(doc: YAML.Document, path: string, value?: any) {
- if (isEmpty(doc.contents)) {
- // If the doc is empty we have an special case
- return { value: set({}, path.replace(/^\//, ""), value), splittedPath: [] };
- }
- let splittedPath = splitPath(path);
- // If the path is not defined (the parent nodes are undefined)
- // We need to change the path and the value to set to avoid accessing
- // the undefined node. For example, if a.b is undefined:
- // path: a.b.c, value: 1 ==> path: a.b, value: {c: 1}
- // TODO(andresmgot): In the future, this may be implemented in the YAML library itself
- // https://github.com/eemeli/yaml/issues/131
- const allElementsButTheLast = splittedPath.slice(0, splittedPath.length - 1);
- const parentNode = (doc as any).getIn(allElementsButTheLast);
- if (parentNode === undefined) {
- const definedPath = getDefinedPath(allElementsButTheLast, doc);
- const remainingPath = splittedPath.slice(definedPath.length + 1);
- value = set({}, remainingPath.join("."), value);
- splittedPath = splittedPath.slice(0, definedPath.length + 1);
+export function updateCurrentConfigByKey(
+ paramsList: IBasicFormParam[],
+ key: string,
+ value: any,
+ depth = 1,
+): any {
+ if (!paramsList) {
+ return [];
}
- return { splittedPath: unescapePath(splittedPath), value };
-}
-
-// setValue modifies the current values (text) based on a path
-export function setValue(values: string, path: string, newValue: any) {
- const doc = YAML.parseDocument(values, { toStringDefaults: toStringOptions });
- const { splittedPath, value } = parsePathAndValue(doc, path, newValue);
- (doc as any).setIn(splittedPath, value);
- return doc.toString(toStringOptions);
-}
-
-// parseValues returns a processed version of the values without modifying anything
-export function parseValues(values: string) {
- return YAML.parseDocument(values, {
- toStringDefaults: toStringOptions,
- }).toString(toStringOptions);
-}
-export function deleteValue(values: string, path: string) {
- const doc = YAML.parseDocument(values, { toStringDefaults: toStringOptions });
- const { splittedPath } = parsePathAndValue(doc, path);
- (doc as any).deleteIn(splittedPath);
- // If the document is empty after the deletion instead of returning {}
- // we return an empty line "\n"
- return doc.contents && !isEmpty((doc.contents as any).items)
- ? doc.toString(toStringOptions)
- : "\n";
-}
-
-// getValue returns the current value of an object based on YAML text and its path
-export function getValue(values: string, path: string, defaultValue?: any) {
- const doc = YAML.parseDocument(values, { toStringDefaults: toStringOptions });
- const splittedPath = parsePath(path);
- const value = (doc as any).getIn(splittedPath);
- return value === undefined || value === null ? defaultValue : value;
+ // Find item index using findIndex
+ const indexLeaf = findIndex(paramsList, { key: key });
+ // is it a leaf node?
+ if (!paramsList?.[indexLeaf]) {
+ const a = key.split("/").slice(0, depth).join("/");
+ const index = findIndex(paramsList, { key: a });
+ if (paramsList?.[index]?.params) {
+ set(
+ paramsList[index],
+ "currentValue",
+ updateCurrentConfigByKey(paramsList?.[index]?.params || [], key, value, depth + 1),
+ );
+ return paramsList;
+ }
+ }
+ // Replace item at index using native splice
+ paramsList?.splice(indexLeaf, 1, {
+ ...paramsList[indexLeaf],
+ currentValue: value,
+ });
+ return paramsList;
}
-export function validate(
+// TODO(agamez): stop loading the yaml values with the yaml.load function.
+export function validateValuesSchema(
values: string,
schema: JSONSchemaType | any,
): { valid: boolean; errors: ErrorObject[] | null | undefined } {
const valid = ajv.validate(schema, yaml.load(values));
- return { valid: !!valid, errors: ajv.errors };
+ return { valid: !!valid, errors: ajv.errors } as IAjvValidateResult;
+}
+
+export function validateSchema(schema: JSONSchemaType): IAjvValidateResult {
+ const valid = ajv.validateSchema(schema);
+ return { valid: valid, errors: ajv.errors } as IAjvValidateResult;
}
diff --git a/dashboard/src/shared/types.ts b/dashboard/src/shared/types.ts
index 1b0eec27e24..40b4e5a2c9a 100644
--- a/dashboard/src/shared/types.ts
+++ b/dashboard/src/shared/types.ts
@@ -1,7 +1,7 @@
// Copyright 2018-2022 the Kubeapps contributors.
// SPDX-License-Identifier: Apache-2.0
-import { JSONSchemaType } from "ajv";
+import { JSONSchemaType, ErrorObject } from "ajv";
import { RouterState } from "connected-react-router";
import {
AvailablePackageDetail,
@@ -398,39 +398,30 @@ export interface IKubeState {
kindsError?: Error;
}
-export interface IBasicFormParam {
- path: string;
- type?: "string" | "number" | "integer" | "boolean" | "object" | "array" | "null" | "any";
- value?: any;
- title?: string;
- minimum?: number;
- maximum?: number;
- render?: string;
- description?: string;
- customComponent?: object;
+// We extend the JSONSchema properties to include the default/deployed values as well as
+// other useful information for rendering each param in the UI
+export type IBasicFormParam = JSONSchemaType & {
+ key: string;
+ title: string;
+ hasProperties: boolean;
+ params?: IBasicFormParam[];
enum?: string[];
- hidden?:
- | {
- event: DeploymentEvent;
- path: string;
- value: string;
- conditions: Array<{
- event: DeploymentEvent;
- path: string;
- value: string;
- }>;
- operator: string;
- }
- | string;
- children?: IBasicFormParam[];
-}
-export interface IBasicFormSliderParam extends IBasicFormParam {
- sliderMin?: number;
- sliderMax?: number;
- sliderStep?: number;
- sliderUnit?: string;
-}
-
+ defaultValue: any;
+ deployedValue: any;
+ currentValue: any;
+ schema: JSONSchemaType;
+ isCustomComponent?: boolean;
+};
+
+// this type is encapsulating the result of a schema validation,
+// including the errors returned by the library
+export interface IAjvValidateResult {
+ valid: boolean;
+ errors: ErrorObject[] | null | undefined;
+}
+
+// type for handling Helm installed packages, which includes the revision,
+// a field not present in other packages
export interface CustomInstalledPackageDetail extends InstalledPackageDetail {
revision: number;
}
@@ -446,12 +437,14 @@ export enum RepositoryStorageTypes {
PACKAGE_REPOSITORY_STORAGE_CARVEL_GIT = "git",
}
+// enum for the current plugin names
export enum PluginNames {
PACKAGES_HELM = "helm.packages",
PACKAGES_FLUX = "fluxv2.packages",
PACKAGES_KAPP = "kapp_controller.packages",
}
+// type holding the data used in the package repository form
export interface IPkgRepoFormData {
authMethod: PackageRepositoryAuth_PackageRepositoryAuthType;
// kubeapps-managed secrets
diff --git a/dashboard/src/shared/utils.ts b/dashboard/src/shared/utils.ts
index e7a3fcdb71e..718e832edd9 100644
--- a/dashboard/src/shared/utils.ts
+++ b/dashboard/src/shared/utils.ts
@@ -33,6 +33,7 @@ import {
} from "./types";
export const k8sObjectNameRegex = "[a-z0-9]([-a-z0-9]*[a-z0-9])?(.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*";
+export const basicFormsDebounceTime = 500;
export function escapeRegExp(str: string) {
return str.replace(/[-[\]/{}()*+?.\\^$|]/g, "\\$&");
diff --git a/dashboard/src/shared/yamlUtils.test.ts b/dashboard/src/shared/yamlUtils.test.ts
new file mode 100644
index 00000000000..9ee42b15083
--- /dev/null
+++ b/dashboard/src/shared/yamlUtils.test.ts
@@ -0,0 +1,223 @@
+// Copyright 2019-2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ deleteValue,
+ getPathValueInYamlNodeWithDefault,
+ parseToYamlNode,
+ setValue,
+} from "./yamlUtils";
+
+describe("getPathValueInYamlNodeWithDefault", () => {
+ [
+ {
+ description: "should return a value",
+ values: "foo: bar",
+ path: "foo",
+ result: "bar",
+ },
+ {
+ description: "should return a nested value",
+ values: "foo:\n bar: foobar",
+ path: "foo/bar",
+ result: "foobar",
+ },
+ {
+ description: "should return a deeply nested value",
+ values: "foo:\n bar:\n foobar: barfoo",
+ path: "foo/bar/foobar",
+ result: "barfoo",
+ },
+ {
+ description: "should ignore an invalid path",
+ values: "foo:\n bar:\n foobar: barfoo",
+ path: "nope",
+ result: undefined,
+ },
+ {
+ description: "should ignore an invalid path (nested)",
+ values: "foo:\n bar:\n foobar: barfoo",
+ path: "not/exists",
+ result: undefined,
+ },
+ {
+ description: "should return the default value if the path is not valid",
+ values: "foo: bar",
+ path: "foobar",
+ default: '"BAR"',
+ result: '"BAR"',
+ },
+ {
+ description: "should return a value with slashes in the key",
+ values: "foo/bar: value",
+ path: "foo~1bar",
+ result: "value",
+ },
+ {
+ description: "should return a value with slashes and dots in the key",
+ values: "kubernetes.io/ingress.class: nginx",
+ path: "kubernetes.io~1ingress.class",
+ result: "nginx",
+ },
+ ].forEach(t => {
+ it(t.description, () => {
+ expect(
+ getPathValueInYamlNodeWithDefault(parseToYamlNode(t.values), t.path, t.default),
+ ).toEqual(t.result);
+ });
+ });
+});
+
+describe("setValue", () => {
+ [
+ {
+ description: "should set a value",
+ values: 'foo: "bar"',
+ path: "foo",
+ newValue: "BAR",
+ result: 'foo: "BAR"\n',
+ },
+ {
+ description: "should set a value preserving the existing scalar quotation (simple)",
+ values: "foo: 'bar'",
+ path: "foo",
+ newValue: "BAR",
+ result: "foo: 'BAR'\n",
+ },
+ {
+ description: "should set a value preserving the existing scalar quotation (double)",
+ values: 'foo: "bar"',
+ path: "foo",
+ newValue: "BAR",
+ result: 'foo: "BAR"\n',
+ },
+ {
+ description: "should set a value preserving the existing scalar quotation (none)",
+ values: "foo: bar",
+ path: "foo",
+ newValue: "BAR",
+ result: "foo: BAR\n",
+ },
+ {
+ description: "should set a nested value",
+ values: 'foo:\n bar: "foobar"',
+ path: "foo/bar",
+ newValue: "FOOBAR",
+ result: 'foo:\n bar: "FOOBAR"\n',
+ },
+ {
+ description: "should set a deeply nested value",
+ values: 'foo:\n bar:\n foobar: "barfoo"',
+ path: "foo/bar/foobar",
+ newValue: "BARFOO",
+ result: 'foo:\n bar:\n foobar: "BARFOO"\n',
+ },
+ {
+ description: "should add a new value",
+ values: "foo: bar",
+ path: "new",
+ newValue: "value",
+ result: 'foo: bar\nnew: "value"\n',
+ },
+ {
+ description: "should add a new nested value",
+ values: "foo: bar",
+ path: "this/new",
+ newValue: 1,
+ result: "foo: bar\nthis:\n new: 1\n",
+ error: false,
+ },
+ {
+ description: "should add a new deeply nested value",
+ values: "foo: bar",
+ path: "this/new/value",
+ newValue: 1,
+ result: "foo: bar\nthis:\n new:\n value: 1\n",
+ error: false,
+ },
+ {
+ description: "Adding a value for a path partially defined (null)",
+ values: "foo: bar\nthis:\n",
+ path: "this/new/value",
+ newValue: 1,
+ result: "foo: bar\nthis:\n new:\n value: 1\n",
+ error: false,
+ },
+ {
+ description: "Adding a value for a path partially defined (object)",
+ values: "foo: bar\nthis: {}\n",
+ path: "this/new/value",
+ newValue: 1,
+ result: "foo: bar\nthis: { new: { value: 1 } }\n",
+ error: false,
+ },
+ {
+ description: "Adding a value in an empty doc",
+ values: "",
+ path: "foo",
+ newValue: "bar",
+ result: 'foo: "bar"\n',
+ error: false,
+ },
+ {
+ description: "should add a value with slashes in the key",
+ values: 'foo/bar: "test"',
+ path: "foo~1bar",
+ newValue: "value",
+ result: 'foo/bar: "value"\n',
+ },
+ {
+ description: "should add a value with slashes and dots in the key",
+ values: 'kubernetes.io/ingress.class: "default"',
+ path: "kubernetes.io~1ingress.class",
+ newValue: "nginx",
+ result: 'kubernetes.io/ingress.class: "nginx"\n',
+ },
+ ].forEach(t => {
+ it(t.description, () => {
+ if (t.error) {
+ expect(() => setValue(t.values, t.path, t.newValue)).toThrow();
+ } else {
+ expect(setValue(t.values, t.path, t.newValue)).toEqual(t.result);
+ }
+ });
+ });
+});
+
+describe("deleteValue", () => {
+ [
+ {
+ description: "should delete a value",
+ values: "foo: bar\nbar: foo\n",
+ path: "bar",
+ result: "foo: bar\n",
+ },
+ {
+ description: "should delete a value from an array",
+ values: `foo:
+ - bar
+ - foobar
+`,
+ path: "foo/0",
+ result: `foo:
+ - foobar
+`,
+ },
+ {
+ description: "should leave the document empty",
+ values: "foo: bar",
+ path: "foo",
+ result: "\n",
+ },
+ {
+ description: "noop when trying to delete a missing property",
+ values: "foo: bar\nbar: foo\n",
+ path: "var",
+ result: "foo: bar\nbar: foo\n",
+ },
+ ].forEach(t => {
+ it(t.description, () => {
+ expect(deleteValue(t.values, t.path)).toEqual(t.result);
+ });
+ });
+});
diff --git a/dashboard/src/shared/yamlUtils.ts b/dashboard/src/shared/yamlUtils.ts
new file mode 100644
index 00000000000..7c4ba5a76af
--- /dev/null
+++ b/dashboard/src/shared/yamlUtils.ts
@@ -0,0 +1,128 @@
+// Copyright 2019-2022 the Kubeapps contributors.
+// SPDX-License-Identifier: Apache-2.0
+
+import { unescapePathComponent } from "fast-json-patch";
+import { isEmpty, set } from "lodash";
+import YAML, { Scalar, ToStringOptions } from "yaml";
+
+const toStringOptions: ToStringOptions = {
+ defaultKeyType: "PLAIN",
+ defaultStringType: Scalar.QUOTE_DOUBLE, // Preserving double quotes in scalars (see https://github.com/vmware-tanzu/kubeapps/issues/3621)
+ nullStr: "", // Avoid to explicitly add "null" when an element is not defined
+};
+
+export function parseToYamlNode(string: string) {
+ return YAML.parseDocument(string, { toStringDefaults: toStringOptions });
+}
+
+export function toStringYamlNode(valuesNode: YAML.Document.Parsed) {
+ return valuesNode.toString(toStringOptions);
+}
+
+export function setPathValueInYamlNode(
+ valuesNode: YAML.Document.Parsed,
+ path: string,
+ newValue: any,
+) {
+ const { splittedPath, value } = parsePathAndValue(valuesNode, path, newValue);
+ valuesNode.setIn(splittedPath, value);
+ return valuesNode;
+}
+
+function parsePathAndValue(doc: YAML.Document, path: string, value?: any) {
+ if (isEmpty(doc.contents)) {
+ // If the doc is empty we have an special case
+ return { value: set({}, path.replace(/^\//, ""), value), splittedPath: [] };
+ }
+ let splittedPath = splitPath(path);
+ // If the path is not defined (the parent nodes are undefined)
+ // We need to change the path and the value to set to avoid accessing
+ // the undefined node. For example, if a.b is undefined:
+ // path: a.b.c, value: 1 ==> path: a.b, value: {c: 1}
+ // TODO(andresmgot): In the future, this may be implemented in the YAML library itself
+ // https://github.com/eemeli/yaml/issues/131
+ const allElementsButTheLast = splittedPath.slice(0, splittedPath.length - 1);
+ const parentNode = (doc as any).getIn(allElementsButTheLast);
+ if (parentNode === undefined) {
+ const definedPath = getDefinedPath(allElementsButTheLast, doc);
+ const remainingPath = splittedPath.slice(definedPath.length + 1);
+ value = set({}, remainingPath.join("."), value);
+ splittedPath = splittedPath.slice(0, definedPath.length + 1);
+ }
+ return { splittedPath: unescapePath(splittedPath), value };
+}
+
+function getDefinedPath(allElementsButTheLast: string[], doc: YAML.Document) {
+ let currentPath: string[] = [];
+ let foundUndefined = false;
+ allElementsButTheLast.forEach(p => {
+ // Iterate over the path until finding an element that is not defined
+ if (!foundUndefined) {
+ const pathToEvaluate = currentPath.concat(p);
+ const elem = (doc as any).getIn(pathToEvaluate);
+ if (elem === undefined || elem === null) {
+ foundUndefined = true;
+ } else {
+ currentPath = pathToEvaluate;
+ }
+ }
+ });
+ return currentPath;
+}
+
+export function getPathValueInYamlNodeWithDefault(
+ values: YAML.Document.Parsed,
+ path: string,
+ defaultValue?: any,
+) {
+ const value = getPathValueInYamlNode(values, path);
+
+ return value === undefined || value === null ? defaultValue : value;
+}
+
+export function getPathValueInYamlNode(
+ values: YAML.Document.Parsed,
+ path: string,
+) {
+ const splittedPath = parsePath(path);
+ const value = values?.getIn(splittedPath);
+ return value;
+}
+
+function parsePath(path: string): string[] {
+ return unescapePath(splitPath(path));
+}
+
+function unescapePath(path: string[]): string[] {
+ // jsonpath escapes slashes to not mistake then with objects so we need to revert that
+ return path.map(p => unescapePathComponent(p));
+}
+
+function splitPath(path: string): string[] {
+ return (
+ (path ?? "")
+ // ignore the first slash, if exists
+ .replace(/^\//, "")
+ // split by slashes
+ .split("/")
+ );
+}
+
+export function deleteValue(values: string, path: string) {
+ const doc = YAML.parseDocument(values, { toStringDefaults: toStringOptions });
+ const { splittedPath } = parsePathAndValue(doc, path);
+ (doc as any).deleteIn(splittedPath);
+ // If the document is empty after the deletion instead of returning {}
+ // we return an empty line "\n"
+ return doc.contents && !isEmpty((doc.contents as any).items)
+ ? doc.toString(toStringOptions)
+ : "\n";
+}
+
+// setValue modifies the current values (text) based on a path
+export function setValue(values: string, path: string, newValue: any) {
+ const doc = YAML.parseDocument(values, { toStringDefaults: toStringOptions });
+ const { splittedPath, value } = parsePathAndValue(doc, path, newValue);
+ (doc as any).setIn(splittedPath, value);
+ return doc.toString(toStringOptions);
+}
diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock
index c41194dbf06..1c37b95b08a 100644
--- a/dashboard/yarn.lock
+++ b/dashboard/yarn.lock
@@ -1032,9 +1032,9 @@
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
- version "7.19.0"
- resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
- integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
+ version "7.18.9"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a"
+ integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==
dependencies:
regenerator-runtime "^0.13.4"
@@ -4047,9 +4047,9 @@ class-utils@^0.3.5:
static-extend "^0.1.1"
classnames@^2.3.1:
- version "2.3.2"
- resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
- integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
+ integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
clean-css@^5.2.2:
version "5.3.1"
@@ -4067,11 +4067,6 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
-clsx@^1.1.0:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
- integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
-
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -4649,13 +4644,6 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9"
integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==
-d3-array@^2.8.0:
- version "2.12.1"
- resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81"
- integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==
- dependencies:
- internmap "^1.0.0"
-
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -6896,11 +6884,6 @@ internal-slot@^1.0.3:
has "^1.0.3"
side-channel "^1.0.4"
-internmap@^1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
- integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
-
interpret@^1.0.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
@@ -10729,7 +10712,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
-prop-types@^15.0.0, prop-types@^15.5.0, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.1:
+prop-types@^15.0.0, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.0, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -10959,15 +10942,6 @@ react-app-polyfill@^3.0.0:
regenerator-runtime "^0.13.9"
whatwg-fetch "^3.6.2"
-react-compound-slider@^3.4.0:
- version "3.4.0"
- resolved "https://registry.yarnpkg.com/react-compound-slider/-/react-compound-slider-3.4.0.tgz#0367befe1367bb7968b38d0cbf07db6192b3c57e"
- integrity sha512-KSje/rB0xSvvcb7YV0+82hkiXTV5ljSS7axKrNiXLf9AEO+rrr1Xq4MJWA+6v030YNNo/RoSoEB6D6fnoy+8ng==
- dependencies:
- "@babel/runtime" "^7.12.5"
- d3-array "^2.8.0"
- warning "^4.0.3"
-
react-copy-to-clipboard@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.4.tgz#42ec519b03eb9413b118af92d1780c403a5f19bf"
@@ -11257,13 +11231,6 @@ react-side-effect@^2.1.0:
resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.2.tgz#dc6345b9e8f9906dc2eeb68700b615e0b4fe752a"
integrity sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==
-react-switch@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/react-switch/-/react-switch-7.0.0.tgz#400990bb9822864938e343ed24f13276a617bdc0"
- integrity sha512-KkDeW+cozZXI6knDPyUt3KBN1rmhoVYgAdCJqAh7st7tk8YE6N0iR89zjCWO8T8dUTeJGTR0KU+5CHCRMRffiA==
- dependencies:
- prop-types "^15.7.2"
-
react-syntax-highlighter@^15.4.5:
version "15.5.0"
resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz#4b3eccc2325fa2ec8eff1e2d6c18fa4a9e07ab20"
@@ -11275,14 +11242,6 @@ react-syntax-highlighter@^15.4.5:
prismjs "^1.27.0"
refractor "^3.6.0"
-react-tabs@^5.1.0:
- version "5.1.0"
- resolved "https://registry.yarnpkg.com/react-tabs/-/react-tabs-5.1.0.tgz#5ef8fad015c71c23b0fff65bd9b3bd419219c27b"
- integrity sha512-jsPVEPuhG7JljTo8Q4ujz4UKRpG90nHlDClAdvV5KrLxCHU+MT/kg7dmhq8fDv8+frciDtaYeFFlTVRLm4N5AQ==
- dependencies:
- clsx "^1.1.0"
- prop-types "^15.5.0"
-
react-test-renderer@^17.0.0, react-test-renderer@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-17.0.2.tgz#4cd4ae5ef1ad5670fc0ef776e8cc7e1231d9866c"
@@ -13707,13 +13666,6 @@ walker@^1.0.7, walker@~1.0.5:
dependencies:
makeerror "1.0.12"
-warning@^4.0.3:
- version "4.0.3"
- resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
- integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
- dependencies:
- loose-envify "^1.0.0"
-
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
diff --git a/integration/tests/main/03-create-private-package-repository.spec.js b/integration/tests/main/03-create-private-package-repository.spec.js
index f3477932092..a4d78b66251 100644
--- a/integration/tests/main/03-create-private-package-repository.spec.js
+++ b/integration/tests/main/03-create-private-package-repository.spec.js
@@ -99,7 +99,7 @@ test("Create a new private package repository successfully", async ({ page }) =>
await page.waitForSelector('select[name="package-versions"]');
const newPackageVersionValue = await page.inputValue('select[name="package-versions"]');
expect(newPackageVersionValue).toEqual("8.6.3");
- await page.click('li:has-text("Changes")');
+ await page.click('li:has-text("YAML editor")');
// Use the built-in search function in monaco to find the text we are looking for
// so that it get loaded in the DOM when using the toContainText assert
@@ -109,7 +109,7 @@ test("Create a new private package repository successfully", async ({ page }) =>
await page.locator('[aria-label="Type to narrow down results\\."]').fill(">find");
await page.locator('label:has-text("FindCtrl+F")').click();
await page.locator('[aria-label="Find"]').fill("tag: 2.4.48");
- // Note the U+200C , which is a zero-width non-joiner, character instead of a space
+ // Note the U+200C, which is a zero-width non-joiner, character instead of a space
await expect(page.locator(".editor.modified")).toContainText("tag:·2.4.48-debian-10-r75");
// Deploy upgrade
diff --git a/integration/tests/main/07-upgrade.spec.js b/integration/tests/main/07-upgrade.spec.js
index b83f324215d..5faaf55811f 100644
--- a/integration/tests/main/07-upgrade.spec.js
+++ b/integration/tests/main/07-upgrade.spec.js
@@ -33,9 +33,13 @@ test("Upgrades an application", async ({ page }) => {
// Deploy package
await page.click('cds-button:has-text("Deploy") >> nth=0');
- // Set replicas
- await page.locator("input[type='number']").fill("2");
- await page.click('li:has-text("Changes")');
+ // Increase replicas
+ await page.locator('input[id^="replicaCount_text"]').fill("2");
+
+ // Wait until changes are applied (due to the debounce in the input)
+ await page.waitForTimeout(1000);
+ await page.locator('li:has-text("YAML editor")').click();
+ await page.waitForTimeout(1000);
// Use the built-in search function in monaco to find the text we are looking for
// so that it get loaded in the DOM when using the toContainText assert
@@ -45,7 +49,7 @@ test("Upgrades an application", async ({ page }) => {
await page.locator('[aria-label="Type to narrow down results\\."]').fill(">find");
await page.locator('label:has-text("FindCtrl+F")').click();
await page.locator('[aria-label="Find"]').fill("replicaCount: ");
- // Note the U+200C , which is a zero-width non-joiner, character instead of a space
+ // Note the U+200C, which is a zero-width non-joiner, character instead of a space
await expect(page.locator(".editor.modified")).toContainText("replicaCount:·2");
// Set release name
diff --git a/integration/tests/main/08-rollback.spec.js b/integration/tests/main/08-rollback.spec.js
index c73c05da566..e4e23b67a0e 100644
--- a/integration/tests/main/08-rollback.spec.js
+++ b/integration/tests/main/08-rollback.spec.js
@@ -55,8 +55,12 @@ test("Rolls back an application", async ({ page }) => {
await page.locator('cds-button:has-text("Upgrade")').click();
// Increase replicas
- await page.locator("input[type='number']").fill("2");
- await page.click('li:has-text("Changes")');
+ await page.locator('input[id^="replicaCount_text"]').fill("2");
+
+ // Wait until changes are applied (due to the debounce in the input)
+ await page.waitForTimeout(1000);
+ await page.locator('li:has-text("YAML editor")').click();
+ await page.waitForTimeout(1000);
// Use the built-in search function in monaco to find the text we are looking for
// so that it get loaded in the DOM when using the toContainText assert
@@ -66,7 +70,7 @@ test("Rolls back an application", async ({ page }) => {
await page.locator('[aria-label="Type to narrow down results\\."]').fill(">find");
await page.locator('label:has-text("FindCtrl+F")').click();
await page.locator('[aria-label="Find"]').fill("replicaCount: ");
- // Note the U+200C , which is a zero-width non-joiner, character instead of a space
+ // Note the U+200C, which is a zero-width non-joiner, character instead of a space
await expect(page.locator(".editor.modified")).toContainText("replicaCount:·2");
await page.locator('cds-button:has-text("Deploy")').click();