Skip to content

Commit

Permalink
Add remaining schema renderers (#5534)
Browse files Browse the repository at this point in the history
### Description of the change

In #5436 we mentioned that several fields were still pending, namely

- 1st level: object (at least, as array of tuples <string, any> for
defining each property.
-  Array level:  enum, arrays, objects

WRT the top-level objects, it is not an actual problem: if they have
`properties`, they will get rendered as any other nested param more. It
is only the case when it lacks the `properties` fields that we need to
handle.
- Solution: allow any serialized object like `{"foo": 1234}` and add
more validation messages.

WRT the array-level properties:
- Solution for enum: use the same approach as in the top-level enum.
- Solution for arrays and objects: use the same approach as in the
top-level objects: render a text field but enforce validation.

Additionally, the array now enforces `maxItems` and `minItems`

### Benefits

Every json schema datatype is now supported.

### Possible drawbacks

N/A

### Applicable issues

- fixes #5436 

### Additional information

> **Note**
> This PR is part of a series of PRs aimed at closing [this
milestone](https://github.com/vmware-tanzu/kubeapps/milestone/27). I
have split the changes to ease the review process, but as there are many
interrelated changes, the tests will be performed in a separate PR (on
top of the branch containing all the changes).
>  PR 5 out of 6

#### `Top-level objects`:


![image](https://user-images.githubusercontent.com/11535726/197029146-c81a3001-4ca3-4251-87a1-d2eab55df864.png)

#### `Array<enum>`:


![image](https://user-images.githubusercontent.com/11535726/197028415-653f1e3d-f7b3-4d49-aca1-ac788ca2924f.png)

#### `Array<array>`:


![image](https://user-images.githubusercontent.com/11535726/197028515-a64aee32-5796-4dac-9eb3-8fc179435e71.png)

#### `Array<object>`:


![image](https://user-images.githubusercontent.com/11535726/197028310-fd815a03-4f5b-4004-bd0d-c3640f0dd023.png)

#### `YAML output`


![image](https://user-images.githubusercontent.com/11535726/197028679-83146445-7dc1-4516-9b37-d180e403ccee.png)

#### `Array max/min items`

Min items:

![image](https://user-images.githubusercontent.com/11535726/197030005-98e84cca-d78f-4fff-91ac-b02ec84c6a53.png)

Max items:

![image](https://user-images.githubusercontent.com/11535726/197029933-a162625e-da17-4846-be73-4339b97f9f1c.png)

Signed-off-by: Antonio Gamez Diaz <[email protected]>
  • Loading branch information
antgamdia authored Oct 21, 2022
1 parent 58a006e commit b94ee74
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 188 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@
// 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 { CdsInput } from "@cds/react/input";
import { CdsRange } from "@cds/react/range";
import { CdsSelect } from "@cds/react/select";
import { CdsToggle } from "@cds/react/toggle";
import Column from "components/js/Column";
import Row from "components/js/Row";
import { isEmpty } from "lodash";
import { useState } from "react";
import { IBasicFormParam } from "shared/types";
import { basicFormsDebounceTime } from "shared/utils";
import { validateValuesSchema } from "shared/schema";
import { IAjvValidateResult, IBasicFormParam } from "shared/types";
import { basicFormsDebounceTime, getStringValue, getValueFromString } from "shared/utils";

export interface IArrayParamProps {
id: string;
Expand All @@ -23,12 +27,41 @@ export interface IArrayParamProps {
) => (e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
}

const getDefaultDataFromType = (type: string) => {
switch (type) {
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "object":
return {};
case "array":
return [];
case "string":
default:
return "";
}
};

export default function ArrayParam(props: IArrayParamProps) {
const { id, label, type, param, step, handleBasicFormParamChange } = props;

const [currentArrayItems, setCurrentArrayItems] = useState<(string | number | boolean)[]>(
param.currentValue ? JSON.parse(param.currentValue) : [],
);
const initCurrentValue = () => {
const currentValueInit = [];
if (param.minItems) {
for (let index = 0; index < param.minItems; index++) {
currentValueInit[index] = getDefaultDataFromType(type);
}
}
return currentValueInit;
};

const [currentArrayItems, setCurrentArrayItems] = useState<
(string | number | boolean | object | Array<any>)[]
>(param.currentValue ? param.currentValue : initCurrentValue());
const [validated, setValidated] = useState<IAjvValidateResult>();

const [timeout, setThisTimeout] = useState({} as NodeJS.Timeout);

const setArrayChangesInParam = () => {
Expand All @@ -37,90 +70,164 @@ export default function ArrayParam(props: IArrayParamProps) {
// The reference to target get lost, so we need to keep a copy
const targetCopy = {
currentTarget: {
value: JSON.stringify(currentArrayItems),
type: "change",
value: getStringValue(currentArrayItems),
type: "array",
},
} as React.FormEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>;
setThisTimeout(setTimeout(() => func(targetCopy), basicFormsDebounceTime));
};

const onChangeArrayItem = (index: number, value: string | number | boolean) => {
const onChangeArrayItem = (
e: React.FormEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
index: number,
value: string | number | boolean | object | Array<any>,
) => {
currentArrayItems[index] = value;
setCurrentArrayItems([...currentArrayItems]);
setArrayChangesInParam();

// twofold validation: using the json schema (with ajv) and the html5 validation
setValidated(validateValuesSchema(getStringValue(currentArrayItems), param.schema));
e.currentTarget.reportValidity();
};

const renderControlMsg = () =>
!validated?.valid &&
!isEmpty(validated?.errors) && (
<>
<CdsControlMessage status="error">
{validated?.errors?.map((e: any) => e?.message).join(", ")}
</CdsControlMessage>
<br />
</>
);

const renderInput = (type: string, index: number) => {
switch (type) {
case "number":
case "integer":
return (
<>
<CdsInput className="self-center">
if (!isEmpty(param?.items?.enum)) {
return (
<>
<CdsSelect layout="horizontal">
<select
required={param.required}
disabled={param.readOnly}
aria-label={label}
id={id}
value={currentArrayItems[index] as string}
onChange={e => onChangeArrayItem(e, index, e.currentTarget.value)}
>
<option disabled={true} key={""}>
{""}
</option>
{param?.items?.enum?.map((enumValue: any) => (
<option value={getValueFromString(enumValue)} key={enumValue}>
{enumValue}
</option>
))}
</select>
{renderControlMsg()}
</CdsSelect>
</>
);
} else {
switch (type) {
case "number":
case "integer":
return (
<>
<CdsInput className="self-center">
<input
required={param.required}
disabled={param.readOnly}
min={Math.min(param.minimum, param.exclusiveMinimum) || undefined}
max={Math.min(param.maximum, param.exclusiveMaximum) || undefined}
aria-label={label}
id={`${id}-${index}_text`}
type="number"
onChange={e => onChangeArrayItem(e, index, Number(e.currentTarget.value))}
value={Number(currentArrayItems[index])}
step={step}
/>
</CdsInput>
<CdsRange>
<input
required={param.required}
disabled={param.readOnly}
min={param.minimum}
max={param.maximum}
aria-label={label}
id={`${id}-${index}_range`}
type="range"
onChange={e => onChangeArrayItem(e, index, Number(e.currentTarget.value))}
value={Number(currentArrayItems[index])}
step={step}
/>
</CdsRange>
</>
);
case "boolean":
return (
<CdsToggle>
<input
required={param.required}
disabled={param.readOnly}
aria-label={label}
id={`${id}-${index}_toggle`}
type="checkbox"
onChange={e => onChangeArrayItem(e, index, e.currentTarget.checked)}
checked={!!currentArrayItems[index]}
/>
</CdsToggle>
);
case "object":
return (
<CdsInput>
<input
required={param.required}
disabled={param.readOnly}
aria-label={label}
id={`${id}-${index}_text`}
type="number"
onChange={e => onChangeArrayItem(index, Number(e.currentTarget.value))}
value={Number(currentArrayItems[index])}
step={param.schema?.type === "integer" ? 1 : 0.1}
value={getStringValue(currentArrayItems[index])}
onChange={e =>
onChangeArrayItem(e, index, getValueFromString(e.currentTarget.value, "object"))
}
/>
</CdsInput>
<CdsRange>
);
case "array":
return (
<CdsInput>
<input
required={param.required}
disabled={param.readOnly}
aria-label={label}
id={`${id}-${index}_range`}
type="range"
onChange={e => onChangeArrayItem(index, Number(e.currentTarget.value))}
value={Number(currentArrayItems[index])}
step={param.schema?.type === "integer" ? 1 : 0.1}
value={getStringValue(currentArrayItems[index])}
onChange={e =>
onChangeArrayItem(e, index, getValueFromString(e.currentTarget.value, "array"))
}
/>
</CdsRange>
</>
);
case "boolean":
return (
<CdsToggle>
<input
required={param.required}
aria-label={label}
id={`${id}-${index}_toggle`}
type="checkbox"
onChange={e => onChangeArrayItem(index, e.currentTarget.checked)}
checked={!!currentArrayItems[index]}
/>
</CdsToggle>
);

// TODO(agamez): handle enums and objects in arrays
default:
return (
<CdsInput>
<input
required={param.required}
aria-label={label}
value={currentArrayItems[index] as string}
onChange={e => onChangeArrayItem(index, e.currentTarget.value)}
/>
</CdsInput>
);
</CdsInput>
);
case "string":
default:
return (
<CdsInput>
<input
required={param.required}
disabled={param.readOnly}
maxLength={param.maxLength}
minLength={param.minLength}
pattern={param.pattern}
aria-label={label}
value={currentArrayItems[index] as string}
onChange={e => onChangeArrayItem(e, index, e.currentTarget.value)}
/>
</CdsInput>
);
}
}
};

const onAddArrayItem = () => {
switch (type) {
case "number":
case "integer":
currentArrayItems.push(0);
break;
case "boolean":
currentArrayItems.push(false);
break;
default:
currentArrayItems.push("");
break;
}
const onAddArrayItem = (type: string) => {
currentArrayItems.push(getDefaultDataFromType(type));
setCurrentArrayItems([...currentArrayItems]);
setArrayChangesInParam();
};
Expand All @@ -135,7 +242,7 @@ export default function ArrayParam(props: IArrayParamProps) {
<CdsButton
title={"Add a new value"}
type="button"
onClick={onAddArrayItem}
onClick={() => onAddArrayItem(type)}
action="flat"
status="primary"
size="sm"
Expand All @@ -144,6 +251,7 @@ export default function ArrayParam(props: IArrayParamProps) {
<CdsIcon shape="plus" size="sm" solid={true} />
<span>Add</span>
</CdsButton>
{renderControlMsg()}
{currentArrayItems?.map((_, index) => (
<Row key={`${id}-${index}`}>
<Column span={9}>{renderInput(type, index)}</Column>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Column from "components/js/Column";
import Row from "components/js/Row";
import { useState } from "react";
import { IBasicFormParam } from "shared/types";
import { getStringValue } from "shared/utils";

export interface IBooleanParamProps {
id: string;
Expand All @@ -19,15 +20,15 @@ export interface IBooleanParamProps {
export default function BooleanParam(props: IBooleanParamProps) {
const { id, label, param, handleBasicFormParamChange } = props;

const [currentValue, setCurrentValue] = useState(param.currentValue);
const [currentValue, setCurrentValue] = useState(param.currentValue || false);
const [isValueModified, setIsValueModified] = useState(false);

const onChange = (e: React.FormEvent<HTMLInputElement>) => {
// 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(),
value: getStringValue(e.currentTarget?.checked),
type: "checkbox",
},
} as React.FormEvent<HTMLInputElement>;
Expand Down
Loading

0 comments on commit b94ee74

Please sign in to comment.