Skip to content

Commit 21e7614

Browse files
wanghoppehoppe
andauthored
add validation details and string-list control (#2825)
add validation details and string-list control --------- Co-authored-by: hoppe <hoppewang@microsoft.com>
1 parent 1b4b4e6 commit 21e7614

14 files changed

Lines changed: 311 additions & 18 deletions

File tree

packages/bonito-core/src/form/string-list-parameter.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,44 @@ import { FormValues } from "./form";
33
import { AbstractParameter, ParameterName } from "./parameter";
44
import { ValidationStatus } from "./validation-status";
55

6+
export interface StringListValidationDetails {
7+
[key: number]: string;
8+
}
9+
610
/**
711
* A parameter with a value that is a list of strings
812
*/
913
export class StringListParameter<
1014
V extends FormValues,
1115
K extends ParameterName<V>
1216
> extends AbstractParameter<V, K> {
13-
validateSync(): ValidationStatus {
17+
validateSync() {
1418
let status = super.validateSync();
1519
if (status.level === "ok") {
1620
status = this._validate();
1721
}
1822
return status;
1923
}
20-
2124
private _validate(): ValidationStatus {
25+
let hasError = false;
26+
const vData: StringListValidationDetails = {};
2227
if (this.value != null && Array.isArray(this.value)) {
23-
for (const v of this.value) {
28+
for (const [i, v] of this.value.entries()) {
2429
if (typeof v !== "string") {
25-
// Found a non-string value - early out
26-
return new ValidationStatus(
27-
"error",
28-
translate(
29-
"bonito.core.form.validation.stringListValueError"
30-
)
30+
hasError = true;
31+
// Found a non-string value
32+
vData[i] = translate(
33+
"bonito.core.form.validation.stringValueError"
3134
);
3235
}
3336
}
3437
}
35-
return new ValidationStatus("ok");
38+
return hasError
39+
? new ValidationStatus(
40+
"error",
41+
translate("bonito.core.form.validation.stringListValueError"),
42+
vData
43+
)
44+
: new ValidationStatus("ok");
3645
}
3746
}

packages/bonito-core/src/form/validation-status.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
* Represents the result of a given validation
33
*/
44
export class ValidationStatus {
5-
level: "ok" | "warn" | "error" | "canceled";
6-
message?: string;
75
forced?: boolean = false;
86

9-
constructor(level: "ok" | "warn" | "error" | "canceled", message?: string) {
10-
this.level = level;
11-
this.message = message;
12-
}
7+
constructor(
8+
public level: "ok" | "warn" | "error" | "canceled",
9+
public message?: string,
10+
// TODO: Make this a generic type
11+
public details?: unknown
12+
) {}
1313
}

packages/bonito-ui/i18n/resources.resjson

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"bonito.ui.dataGrid.noResults": "No results found",
33
"bonito.ui.form.buttons.apply": "Apply",
44
"bonito.ui.form.buttons.discardChanges": "Discard changes",
5+
"bonito.ui.form.delete": "Delete",
56
"bonito.ui.form.showPassword": "Show password"
67
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { StringListParameter } from "@azure/bonito-core/lib/form";
2+
import { initMockBrowserEnvironment } from "../../../environment";
3+
import { createParam } from "../../../form";
4+
import { StringList } from "../string-list";
5+
import { render, screen } from "@testing-library/react";
6+
import React from "react";
7+
import userEvent from "@testing-library/user-event";
8+
import { runAxe } from "../../../test-util";
9+
10+
describe("StringList form control", () => {
11+
beforeEach(() => initMockBrowserEnvironment());
12+
13+
test("renders a list of strings", async () => {
14+
const { container } = render(
15+
<>
16+
<StringList
17+
param={createStringListParam()}
18+
id="StringList"
19+
></StringList>
20+
</>
21+
);
22+
const inputs = screen.getAllByRole("textbox");
23+
expect(inputs.length).toBe(3);
24+
expect((inputs[0] as HTMLInputElement).value).toBe("foo");
25+
expect((inputs[1] as HTMLInputElement).value).toBe("bar");
26+
expect((inputs[2] as HTMLInputElement).value).toBe("");
27+
28+
const deleteButtons = screen.getAllByRole("button");
29+
expect(deleteButtons.length).toBe(2);
30+
expect(await runAxe(container)).toHaveNoViolations();
31+
});
32+
33+
it("adds a new string when the last one is edited", async () => {
34+
const onChange = jest.fn();
35+
render(
36+
<StringList param={createStringListParam()} onChange={onChange} />
37+
);
38+
const inputs = screen.getAllByRole("textbox");
39+
const input = inputs[inputs.length - 1];
40+
input.focus();
41+
await userEvent.type(input, "baz");
42+
expect(onChange).toHaveBeenCalledWith(null, ["foo", "bar", "baz"]);
43+
});
44+
45+
it("deletes a string when the delete button is clicked", async () => {
46+
const onChange = jest.fn();
47+
render(
48+
<StringList param={createStringListParam()} onChange={onChange} />
49+
);
50+
const deleteButton = screen.getAllByRole("button")[1];
51+
await userEvent.click(deleteButton);
52+
expect(onChange).toHaveBeenCalledWith(null, ["foo"]);
53+
});
54+
});
55+
56+
function createStringListParam() {
57+
return createParam(StringListParameter, {
58+
label: "String List",
59+
value: ["foo", "bar"],
60+
});
61+
}

packages/bonito-ui/src/components/form/default-form-control-resolver.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { FormControlResolver } from "./form-control-resolver";
2121
import { LocationDropdown } from "./location-dropdown";
2222
import { ResourceGroupDropdown } from "./resource-group-dropdown";
2323
import { StorageAccountDropdown } from "./storage-account-dropdown";
24+
import { StringList } from "./string-list";
2425
import { SubscriptionDropdown } from "./subscription-dropdown";
2526
import { TextField } from "./text-field";
2627

@@ -43,7 +44,7 @@ export class DefaultFormControlResolver implements FormControlResolver {
4344
);
4445
} else if (param instanceof StringListParameter) {
4546
return (
46-
<TextField
47+
<StringList
4748
id={id}
4849
key={param.name}
4950
param={param}

packages/bonito-ui/src/components/form/form-control.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,5 @@ export interface FormControlProps<
6363
/**
6464
* Callback for when the value of the control changes
6565
*/
66-
onChange?: (event: React.FormEvent, value: V[K]) => void;
66+
onChange?: (event: React.FormEvent | null, value: V[K]) => void;
6767
}

packages/bonito-ui/src/components/form/form.i18n.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
form:
2+
delete: Delete
23
buttons:
34
apply: Apply
45
discardChanges: Discard changes

packages/bonito-ui/src/components/form/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from "./tab-selector";
1313
export * from "./text-field";
1414
export * from "./storage-account-dropdown";
1515
export * from "./subscription-dropdown";
16+
export * from "./string-list";
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { FormValues, ParameterName } from "@azure/bonito-core/lib/form";
2+
import { IconButton } from "@fluentui/react/lib/Button";
3+
import { Stack } from "@fluentui/react/lib/Stack";
4+
import { TextField } from "@fluentui/react/lib/TextField";
5+
import * as React from "react";
6+
import { useCallback, useMemo } from "react";
7+
import { useFormParameter, useUniqueId } from "../../hooks";
8+
import { FormControlProps } from "./form-control";
9+
import { useAppTheme } from "../../theme";
10+
import { translate } from "@azure/bonito-core";
11+
12+
export interface StringListValidationDetails {
13+
[key: number]: string;
14+
}
15+
16+
export function StringList<V extends FormValues, K extends ParameterName<V>>(
17+
props: FormControlProps<V, K>
18+
): JSX.Element {
19+
const { className, style, param, onChange } = props;
20+
21+
const id = useUniqueId("form-control", props.id);
22+
const validationDetails = useFormParameter(param)
23+
.validationDetails as StringListValidationDetails;
24+
25+
const items = useMemo<string[]>(() => {
26+
const items: string[] = [];
27+
if (param.value && Array.isArray(param.value)) {
28+
for (const item of param.value) {
29+
items.push(item);
30+
}
31+
}
32+
// Add an empty item at the end
33+
items.push("");
34+
return items;
35+
}, [param.value]);
36+
37+
const onItemChange = useCallback(
38+
(index: number, value: string) => {
39+
const newItems = [...items];
40+
if (index === items.length - 1) {
41+
// Last item, add a new one
42+
newItems.push("");
43+
}
44+
newItems[index] = value;
45+
param.value = newItems.slice(0, newItems.length - 1) as V[K];
46+
onChange?.(null, param.value);
47+
},
48+
[items, param, onChange]
49+
);
50+
51+
const onItemDelete = useCallback(
52+
(index: number) => {
53+
const newItems = [...items];
54+
newItems.splice(index, 1);
55+
param.value = newItems.slice(0, newItems.length - 1) as V[K];
56+
onChange?.(null, param.value);
57+
},
58+
[items, param, onChange]
59+
);
60+
61+
return (
62+
<Stack key={id} style={style} className={className}>
63+
{items.map((item, index) => {
64+
const errorMsg = validationDetails?.[index];
65+
return (
66+
<StringListItem
67+
key={index}
68+
index={index}
69+
value={item}
70+
label={param.label}
71+
errorMsg={errorMsg}
72+
placeholder={param.placeholder}
73+
onChange={onItemChange}
74+
onDelete={onItemDelete}
75+
disableDelete={index === items.length - 1}
76+
></StringListItem>
77+
);
78+
})}
79+
</Stack>
80+
);
81+
}
82+
83+
interface StringListItemProps {
84+
index: number;
85+
value: string;
86+
label?: string;
87+
onChange: (index: number, value: string) => void;
88+
onDelete: (index: number) => void;
89+
placeholder?: string;
90+
disableDelete?: boolean;
91+
errorMsg?: string;
92+
}
93+
94+
function StringListItem(props: StringListItemProps) {
95+
const {
96+
index,
97+
value,
98+
label,
99+
onChange,
100+
onDelete,
101+
disableDelete,
102+
errorMsg,
103+
placeholder,
104+
} = props;
105+
const styles = useStringListItemStyles(props);
106+
const ariaLabel = `${label || ""} ${index + 1}`;
107+
return (
108+
<Stack
109+
key={index}
110+
horizontal
111+
verticalAlign="center"
112+
styles={styles.container}
113+
>
114+
<Stack.Item grow={1}>
115+
<TextField
116+
styles={styles.input}
117+
value={value}
118+
ariaLabel={ariaLabel}
119+
placeholder={placeholder}
120+
errorMessage={errorMsg}
121+
onChange={(_, newValue) => {
122+
onChange(index, newValue || "");
123+
}}
124+
/>
125+
</Stack.Item>
126+
<IconButton
127+
styles={styles.button}
128+
iconProps={{ iconName: "Delete" }}
129+
ariaLabel={`${translate("bonito.ui.form.delete")} ${ariaLabel}`}
130+
onClick={() => {
131+
onDelete(index);
132+
}}
133+
disabled={disableDelete}
134+
/>
135+
</Stack>
136+
);
137+
}
138+
139+
function useStringListItemStyles(props: StringListItemProps) {
140+
const theme = useAppTheme();
141+
const { disableDelete } = props;
142+
143+
return React.useMemo(() => {
144+
const itemPadding = {
145+
padding: "11px 8px 11px 12px",
146+
};
147+
return {
148+
container: {
149+
root: {
150+
":hover": {
151+
backgroundColor: theme.palette.neutralLighter,
152+
},
153+
},
154+
},
155+
input: {
156+
root: {
157+
...itemPadding,
158+
},
159+
field: {
160+
height: "24px",
161+
},
162+
fieldGroup: {
163+
height: "24px",
164+
"box-sizing": "content-box",
165+
},
166+
},
167+
button: {
168+
root: {
169+
...itemPadding,
170+
visibility: disableDelete ? "hidden" : "initial",
171+
},
172+
},
173+
};
174+
}, [theme.palette.neutralLighter, disableDelete]);
175+
}

packages/bonito-ui/src/hooks/use-form-parameter.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ export function useFormParameter<
7878
const [validationError, setValidationError] = useState<
7979
string | undefined
8080
>();
81+
const [validationDetails, setValidationDetails] =
82+
useState<unknown>(undefined);
8183
const [validationStatus, setValidationStatus] = useState<
8284
ValidationStatus | undefined
8385
>();
@@ -178,15 +180,18 @@ export function useFormParameter<
178180
setValidationStatus(snapshot.entryStatus[param.name]);
179181
if (dirty || param.validationStatus?.forced) {
180182
const msg = param.validationStatus?.message;
183+
const details = param.validationStatus?.details;
181184
// Only set a visible validation error if the user has
182185
// interacted with the form control (ie: the parameter is
183186
// dirty) or validation is forced (usually the result of
184187
// clicking a submit button and validating the entire
185188
// form)
186189
if (param.validationStatus?.level === "error") {
187190
setValidationError(msg);
191+
setValidationDetails(details);
188192
} else {
189193
setValidationError(undefined);
194+
setValidationDetails(undefined);
190195
}
191196
setDirty(true);
192197
}
@@ -213,5 +218,6 @@ export function useFormParameter<
213218
loadingPromise,
214219
validationError,
215220
validationStatus,
221+
validationDetails,
216222
};
217223
}

0 commit comments

Comments
 (0)