Skip to content

Commit

Permalink
add validation details and string-list control (#2825)
Browse files Browse the repository at this point in the history
add validation details and string-list control


---------

Co-authored-by: hoppe <hoppewang@microsoft.com>
  • Loading branch information
wanghoppe and hoppe committed Nov 7, 2023
1 parent 1b4b4e6 commit 21e7614
Show file tree
Hide file tree
Showing 14 changed files with 311 additions and 18 deletions.
29 changes: 19 additions & 10 deletions packages/bonito-core/src/form/string-list-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,44 @@ import { FormValues } from "./form";
import { AbstractParameter, ParameterName } from "./parameter";
import { ValidationStatus } from "./validation-status";

export interface StringListValidationDetails {
[key: number]: string;
}

/**
* A parameter with a value that is a list of strings
*/
export class StringListParameter<
V extends FormValues,
K extends ParameterName<V>
> extends AbstractParameter<V, K> {
validateSync(): ValidationStatus {
validateSync() {
let status = super.validateSync();
if (status.level === "ok") {
status = this._validate();
}
return status;
}

private _validate(): ValidationStatus {
let hasError = false;
const vData: StringListValidationDetails = {};
if (this.value != null && Array.isArray(this.value)) {
for (const v of this.value) {
for (const [i, v] of this.value.entries()) {
if (typeof v !== "string") {
// Found a non-string value - early out
return new ValidationStatus(
"error",
translate(
"bonito.core.form.validation.stringListValueError"
)
hasError = true;
// Found a non-string value
vData[i] = translate(
"bonito.core.form.validation.stringValueError"
);
}
}
}
return new ValidationStatus("ok");
return hasError
? new ValidationStatus(
"error",
translate("bonito.core.form.validation.stringListValueError"),
vData
)
: new ValidationStatus("ok");
}
}
12 changes: 6 additions & 6 deletions packages/bonito-core/src/form/validation-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
* Represents the result of a given validation
*/
export class ValidationStatus {
level: "ok" | "warn" | "error" | "canceled";
message?: string;
forced?: boolean = false;

constructor(level: "ok" | "warn" | "error" | "canceled", message?: string) {
this.level = level;
this.message = message;
}
constructor(
public level: "ok" | "warn" | "error" | "canceled",
public message?: string,
// TODO: Make this a generic type
public details?: unknown
) {}
}
1 change: 1 addition & 0 deletions packages/bonito-ui/i18n/resources.resjson
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"bonito.ui.dataGrid.noResults": "No results found",
"bonito.ui.form.buttons.apply": "Apply",
"bonito.ui.form.buttons.discardChanges": "Discard changes",
"bonito.ui.form.delete": "Delete",
"bonito.ui.form.showPassword": "Show password"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { StringListParameter } from "@azure/bonito-core/lib/form";
import { initMockBrowserEnvironment } from "../../../environment";
import { createParam } from "../../../form";
import { StringList } from "../string-list";
import { render, screen } from "@testing-library/react";
import React from "react";
import userEvent from "@testing-library/user-event";
import { runAxe } from "../../../test-util";

describe("StringList form control", () => {
beforeEach(() => initMockBrowserEnvironment());

test("renders a list of strings", async () => {
const { container } = render(
<>
<StringList
param={createStringListParam()}
id="StringList"
></StringList>
</>
);
const inputs = screen.getAllByRole("textbox");
expect(inputs.length).toBe(3);
expect((inputs[0] as HTMLInputElement).value).toBe("foo");
expect((inputs[1] as HTMLInputElement).value).toBe("bar");
expect((inputs[2] as HTMLInputElement).value).toBe("");

const deleteButtons = screen.getAllByRole("button");
expect(deleteButtons.length).toBe(2);
expect(await runAxe(container)).toHaveNoViolations();
});

it("adds a new string when the last one is edited", async () => {
const onChange = jest.fn();
render(
<StringList param={createStringListParam()} onChange={onChange} />
);
const inputs = screen.getAllByRole("textbox");
const input = inputs[inputs.length - 1];
input.focus();
await userEvent.type(input, "baz");
expect(onChange).toHaveBeenCalledWith(null, ["foo", "bar", "baz"]);
});

it("deletes a string when the delete button is clicked", async () => {
const onChange = jest.fn();
render(
<StringList param={createStringListParam()} onChange={onChange} />
);
const deleteButton = screen.getAllByRole("button")[1];
await userEvent.click(deleteButton);
expect(onChange).toHaveBeenCalledWith(null, ["foo"]);
});
});

function createStringListParam() {
return createParam(StringListParameter, {
label: "String List",
value: ["foo", "bar"],
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { FormControlResolver } from "./form-control-resolver";
import { LocationDropdown } from "./location-dropdown";
import { ResourceGroupDropdown } from "./resource-group-dropdown";
import { StorageAccountDropdown } from "./storage-account-dropdown";
import { StringList } from "./string-list";
import { SubscriptionDropdown } from "./subscription-dropdown";
import { TextField } from "./text-field";

Expand All @@ -43,7 +44,7 @@ export class DefaultFormControlResolver implements FormControlResolver {
);
} else if (param instanceof StringListParameter) {
return (
<TextField
<StringList
id={id}
key={param.name}
param={param}
Expand Down
2 changes: 1 addition & 1 deletion packages/bonito-ui/src/components/form/form-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,5 +63,5 @@ export interface FormControlProps<
/**
* Callback for when the value of the control changes
*/
onChange?: (event: React.FormEvent, value: V[K]) => void;
onChange?: (event: React.FormEvent | null, value: V[K]) => void;
}
1 change: 1 addition & 0 deletions packages/bonito-ui/src/components/form/form.i18n.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
form:
delete: Delete
buttons:
apply: Apply
discardChanges: Discard changes
Expand Down
1 change: 1 addition & 0 deletions packages/bonito-ui/src/components/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from "./tab-selector";
export * from "./text-field";
export * from "./storage-account-dropdown";
export * from "./subscription-dropdown";
export * from "./string-list";
175 changes: 175 additions & 0 deletions packages/bonito-ui/src/components/form/string-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { FormValues, ParameterName } from "@azure/bonito-core/lib/form";
import { IconButton } from "@fluentui/react/lib/Button";
import { Stack } from "@fluentui/react/lib/Stack";
import { TextField } from "@fluentui/react/lib/TextField";
import * as React from "react";
import { useCallback, useMemo } from "react";
import { useFormParameter, useUniqueId } from "../../hooks";
import { FormControlProps } from "./form-control";
import { useAppTheme } from "../../theme";
import { translate } from "@azure/bonito-core";

export interface StringListValidationDetails {
[key: number]: string;
}

export function StringList<V extends FormValues, K extends ParameterName<V>>(
props: FormControlProps<V, K>
): JSX.Element {
const { className, style, param, onChange } = props;

const id = useUniqueId("form-control", props.id);
const validationDetails = useFormParameter(param)
.validationDetails as StringListValidationDetails;

const items = useMemo<string[]>(() => {
const items: string[] = [];
if (param.value && Array.isArray(param.value)) {
for (const item of param.value) {
items.push(item);
}
}
// Add an empty item at the end
items.push("");
return items;
}, [param.value]);

const onItemChange = useCallback(
(index: number, value: string) => {
const newItems = [...items];
if (index === items.length - 1) {
// Last item, add a new one
newItems.push("");
}
newItems[index] = value;
param.value = newItems.slice(0, newItems.length - 1) as V[K];
onChange?.(null, param.value);
},
[items, param, onChange]
);

const onItemDelete = useCallback(
(index: number) => {
const newItems = [...items];
newItems.splice(index, 1);
param.value = newItems.slice(0, newItems.length - 1) as V[K];
onChange?.(null, param.value);
},
[items, param, onChange]
);

return (
<Stack key={id} style={style} className={className}>
{items.map((item, index) => {
const errorMsg = validationDetails?.[index];
return (
<StringListItem
key={index}
index={index}
value={item}
label={param.label}
errorMsg={errorMsg}
placeholder={param.placeholder}
onChange={onItemChange}
onDelete={onItemDelete}
disableDelete={index === items.length - 1}
></StringListItem>
);
})}
</Stack>
);
}

interface StringListItemProps {
index: number;
value: string;
label?: string;
onChange: (index: number, value: string) => void;
onDelete: (index: number) => void;
placeholder?: string;
disableDelete?: boolean;
errorMsg?: string;
}

function StringListItem(props: StringListItemProps) {
const {
index,
value,
label,
onChange,
onDelete,
disableDelete,
errorMsg,
placeholder,
} = props;
const styles = useStringListItemStyles(props);
const ariaLabel = `${label || ""} ${index + 1}`;
return (
<Stack
key={index}
horizontal
verticalAlign="center"
styles={styles.container}
>
<Stack.Item grow={1}>
<TextField
styles={styles.input}
value={value}
ariaLabel={ariaLabel}
placeholder={placeholder}
errorMessage={errorMsg}
onChange={(_, newValue) => {
onChange(index, newValue || "");
}}
/>
</Stack.Item>
<IconButton
styles={styles.button}
iconProps={{ iconName: "Delete" }}
ariaLabel={`${translate("bonito.ui.form.delete")} ${ariaLabel}`}
onClick={() => {
onDelete(index);
}}
disabled={disableDelete}
/>
</Stack>
);
}

function useStringListItemStyles(props: StringListItemProps) {
const theme = useAppTheme();
const { disableDelete } = props;

return React.useMemo(() => {
const itemPadding = {
padding: "11px 8px 11px 12px",
};
return {
container: {
root: {
":hover": {
backgroundColor: theme.palette.neutralLighter,
},
},
},
input: {
root: {
...itemPadding,
},
field: {
height: "24px",
},
fieldGroup: {
height: "24px",
"box-sizing": "content-box",
},
},
button: {
root: {
...itemPadding,
visibility: disableDelete ? "hidden" : "initial",
},
},
};
}, [theme.palette.neutralLighter, disableDelete]);
}
6 changes: 6 additions & 0 deletions packages/bonito-ui/src/hooks/use-form-parameter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ export function useFormParameter<
const [validationError, setValidationError] = useState<
string | undefined
>();
const [validationDetails, setValidationDetails] =
useState<unknown>(undefined);
const [validationStatus, setValidationStatus] = useState<
ValidationStatus | undefined
>();
Expand Down Expand Up @@ -178,15 +180,18 @@ export function useFormParameter<
setValidationStatus(snapshot.entryStatus[param.name]);
if (dirty || param.validationStatus?.forced) {
const msg = param.validationStatus?.message;
const details = param.validationStatus?.details;
// Only set a visible validation error if the user has
// interacted with the form control (ie: the parameter is
// dirty) or validation is forced (usually the result of
// clicking a submit button and validating the entire
// form)
if (param.validationStatus?.level === "error") {
setValidationError(msg);
setValidationDetails(details);
} else {
setValidationError(undefined);
setValidationDetails(undefined);
}
setDirty(true);
}
Expand All @@ -213,5 +218,6 @@ export function useFormParameter<
loadingPromise,
validationError,
validationStatus,
validationDetails,
};
}
Loading

0 comments on commit 21e7614

Please sign in to comment.