This commit is contained in:
David Watrous 2023-11-02 15:41:45 -04:00 коммит произвёл Shiran Pasternak
Родитель 0c452bc682
Коммит 8970f1aa6f
11 изменённых файлов: 400 добавлений и 7 удалений

Просмотреть файл

@ -38,6 +38,7 @@ export interface ParameterInit<
hideLabel?: boolean;
required?: boolean;
placeholder?: string;
standalone?: boolean;
dependencies?: D;
dynamic?: DynamicParameterProperties<V, K>;
onValidateSync?(value: V[K]): ValidationStatus;
@ -146,6 +147,13 @@ export interface Parameter<
*/
placeholder?: string;
/**
* If true, this parameter is being rendered by itself outside of a
* form layout component, and should be responsible for rendering its own
* label, description, etc.
*/
standalone?: boolean;
dynamic?: DynamicParameterProperties<V, K>;
/**
@ -219,6 +227,7 @@ export abstract class AbstractParameter<
description?: string;
placeholder?: string;
dependencies?: D;
standalone?: boolean;
dynamic?: DynamicParameterProperties<V, K>;
private _disabled?: boolean;
@ -288,6 +297,7 @@ export abstract class AbstractParameter<
this.hidden = init?.hidden;
this.hideLabel = init?.hideLabel;
this.required = init?.required;
this.standalone = init?.standalone;
this.dynamic = init?.dynamic;
if (init?.value !== undefined) {

Просмотреть файл

@ -0,0 +1,81 @@
import { StringParameter } from "@azure/bonito-core/lib/form";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as React from "react";
import { initMockBrowserEnvironment } from "../../../environment";
import { createParam } from "../../../form";
import { runAxe } from "../../../test-util/a11y";
import { TabSelector } from "../tab-selector";
describe("Dropdown form control", () => {
beforeEach(() => initMockBrowserEnvironment());
test("Render simple tab selector", async () => {
const onChange = jest.fn();
const { container } = render(
<TabSelector
param={createParam<string>(StringParameter, {
label: "Card",
placeholder: "Pick a card",
})}
options={[
{
label: "Ace",
value: "ace-of-spades",
},
{
label: "King",
value: "king-of-hearts",
},
{
label: "Queen",
value: "queen-of-diamonds",
},
]}
onChange={onChange}
></TabSelector>
);
expect(onChange).toBeCalledTimes(0);
const tabListEl = screen.getByRole("tablist");
expect(tabListEl).toBeDefined();
const tabs: HTMLButtonElement[] = screen.getAllByRole("tab");
expect(tabs.length).toEqual(3);
expect(tabs.map((tabs) => tabs.name)).toEqual(["Ace", "King", "Queen"]);
const selectedTab: HTMLButtonElement = screen.getByRole("tab", {
selected: true,
});
expect(selectedTab.name).toBe("Ace");
expect(selectedTab.getAttribute("aria-selected")).toBe("true");
const kingTab: HTMLButtonElement = screen.getByRole("tab", {
name: "King",
});
expect(kingTab.name).toBe("King");
expect(kingTab.getAttribute("aria-selected")).toBe("false");
const user = userEvent.setup();
await user.click(kingTab);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Element),
}),
"king-of-hearts"
);
expect(kingTab.getAttribute("aria-selected")).toBe("true");
expect(
await runAxe(container, {
rules: {
// See: https://github.com/microsoft/fluentui/issues/27052
"aria-required-children": { enabled: false },
},
})
).toHaveNoViolations();
});
});

Просмотреть файл

@ -9,6 +9,7 @@ export * from "./form-layout-provider";
export * from "./checkbox";
export * from "./dropdown";
export * from "./radio-button";
export * from "./tab-selector";
export * from "./text-field";
export * from "./storage-account-dropdown";
export * from "./subscription-dropdown";

Просмотреть файл

@ -0,0 +1,160 @@
import {
FormValues,
ParameterDependencies,
ParameterName,
} from "@azure/bonito-core/lib/form";
import { delayedCallback } from "@azure/bonito-core/lib/util";
import { Pivot, PivotItem } from "@fluentui/react/lib/Pivot";
import * as React from "react";
import { useFormParameter, useUniqueId } from "../../hooks";
import { FormControlProps } from "./form-control";
export interface TabSelectorProps<
V extends FormValues,
K extends ParameterName<V>,
D extends ParameterDependencies<V> = ParameterDependencies<V>
> extends FormControlProps<V, K, D> {
overflowBehavior?: "none" | "menu";
options: TabOption<V, K>[];
valueToKey?: (value?: V[K]) => string;
}
export interface TabOption<V extends FormValues, K extends ParameterName<V>> {
key?: string;
value: V[K];
label?: string;
}
const undefinedKey = "<<<No selection>>>";
const nullKey = "<<<None>>>";
/**
* A tab selection form control supporting single selection
*/
export function TabSelector<
V extends FormValues,
K extends ParameterName<V>,
D extends ParameterDependencies<V> = ParameterDependencies<V>
>(props: TabSelectorProps<V, K, D>): JSX.Element {
const {
ariaLabel,
className,
// disabled,
onFocus,
onBlur,
onChange,
options,
param,
style,
valueToKey,
overflowBehavior,
} = props;
const id = useUniqueId("tab-selector", props.id);
const { setDirty } = useFormParameter(param);
const [hasFocused, setHasFocused] = React.useState<boolean>(false);
// Default to first option if the parameter is required
if (param.required && param.value == null && options.length > 0) {
// Do this asynchronously so that the current render finishes first
delayedCallback(() => {
param.value = options[0].value;
});
}
const transformedOptions = _transformOptions(options, valueToKey);
const indexByKey: Record<string, number> = {};
let idx = 0;
const pivotItems = transformedOptions.map((opt) => {
if (!opt.key) {
console.warn(`Tab option ${opt} has no key`);
return <></>;
}
indexByKey[opt.key] = idx++;
return (
<PivotItem
key={opt.key}
itemKey={opt.key}
headerText={opt.label ?? opt.key}
/>
);
});
const toKey = valueToKey ?? defaultValueToKey;
return (
<Pivot
id={id}
aria-label={ariaLabel ?? param.label}
className={className}
style={style}
overflowBehavior={overflowBehavior ?? "menu"}
// TODO: Support disabled, errorMessage
// disabled={disabled || param.disabled}
// errorMessage={validationError}
selectedKey={param.value == null ? undefined : toKey(param.value)}
onFocus={(event) => {
setHasFocused(true);
if (onFocus) {
onFocus(event);
}
}}
onBlur={onBlur}
onLinkClick={(item, event) => {
if (hasFocused) {
setDirty(true);
}
const itemKey = item?.props.itemKey;
if (!itemKey) {
console.warn("PivotItem does not have an itemKey property");
return;
}
const selectionIndex = indexByKey[itemKey];
param.value = transformedOptions[selectionIndex].value;
if (onChange) {
// KLUDGE: Revisit what event type onChange needs
// to take for the portal form API.
// Maybe just use SyntheticEvent instead?
onChange(event as React.FormEvent, param.value);
}
}}
>
{pivotItems}
</Pivot>
);
}
function defaultValueToKey<V>(value?: V): string {
if (value === undefined) {
return undefinedKey;
}
if (value === null) {
return nullKey;
}
const stringValue = String(value);
if (stringValue === undefinedKey || stringValue === nullKey) {
throw new Error(
`Invalid key "${stringValue}". Cannot use a key which is reserved for null or undefined values.`
);
}
return stringValue;
}
function _transformOptions<V extends FormValues, K extends ParameterName<V>>(
options: TabOption<V, K>[],
valueToKey?: (value?: V[K]) => string
): TabOption<V, K>[] {
const toKey = valueToKey ?? defaultValueToKey;
return options.map((option) => {
const key = toKey(option.value);
return {
key: key,
label: option.label ?? key,
value: option.value,
};
});
}

Просмотреть файл

@ -50,6 +50,14 @@ export function TextField<V extends FormValues, K extends ParameterName<V>>(
);
}
// Properties to use only if this control is being rendered
// by itself outside of a form layout
const standaloneProps: ITextFieldProps = {};
if (param.standalone) {
standaloneProps.label = param.label;
standaloneProps.description = param.description;
}
return (
<FluentTextField
id={id}
@ -78,6 +86,7 @@ export function TextField<V extends FormValues, K extends ParameterName<V>>(
}
}}
{...typeSpecificProps}
{...standaloneProps}
></FluentTextField>
);
}

Просмотреть файл

@ -48,6 +48,9 @@ describe("ReactForm tests", () => {
expect(screen.getByTestId("custom-item-render")).toBeDefined();
expect(bannerEl).toBeDefined();
expect(bannerEl.textContent).toEqual("Message was Hello world!");
// Parameters inside forms have standalone set to undefined by default
expect(form.getParam("message").standalone).toBeUndefined();
});
test("Standalone parameter creation", () => {
@ -59,5 +62,8 @@ describe("ReactForm tests", () => {
expect(param.parentForm.values).toStrictEqual({
[param.name]: "testing 1 2 3",
});
// Always set to true when using createParam()
expect(param.standalone).toBe(true);
});
});

Просмотреть файл

@ -64,6 +64,9 @@ export function createParam<
>,
init?: ReactParameterInit<StandaloneForm<S>, "_standaloneParam", D>
) {
init = init ?? {};
init.standalone = true;
const form = createReactForm<StandaloneForm<S>>({
values: {
_standaloneParam: init?.value,

Просмотреть файл

@ -10,6 +10,7 @@ import { RadioButtonDemo } from "./demo/form/radiobutton/radiobutton-demo";
import { CheckboxDemo } from "./demo/form/checkbox/checkbox-demo";
import { NotificationDemo } from "./demo/form/notification-demo";
import { DataGridLoadMoreDemo } from "./demo/display/task-grid/task-data-grid";
import { TabSelectorDemo } from "./demo/form/tab-selector-demo";
export const DEMO_MAP = {
default: () => <DefaultPane />,
@ -20,6 +21,7 @@ export const DEMO_MAP = {
combobox: () => <ComboBoxDemo />,
dropdown: () => <DropdownDemo />,
searchbox: () => <SearchBoxDemo />,
tabselector: () => <TabSelectorDemo />,
textfield: () => <TextFieldDemo />,
notification: () => <NotificationDemo />,
certificatedisplay: () => <CertificateDisplayDemo />,

Просмотреть файл

@ -1,6 +1,7 @@
import { AbstractAction, Action } from "@azure/bonito-core/lib/action";
import {
Form,
NumberParameter,
StringParameter,
ValidationStatus,
} from "@azure/bonito-core/lib/form";
@ -19,14 +20,18 @@ import * as React from "react";
import { DemoComponentContainer } from "../../layout/demo-component-container";
import { DemoControlContainer } from "../../layout/demo-control-container";
import { DemoPane } from "../../layout/demo-pane";
import { ActionForm, Dropdown } from "@azure/bonito-ui/lib/components/form";
import {
ActionForm,
Dropdown,
TabSelector,
} from "@azure/bonito-ui/lib/components/form";
type CarFormValues = {
subscriptionId?: string;
make?: string;
model?: string;
description?: string;
milesPerChange?: number;
milesPerCharge?: number;
};
class CreateOrUpdateCarAction extends AbstractAction<CarFormValues> {
@ -34,7 +39,7 @@ class CreateOrUpdateCarAction extends AbstractAction<CarFormValues> {
async onInitialize(): Promise<CarFormValues> {
return {
milesPerChange: 300,
milesPerCharge: 300,
};
}
@ -44,6 +49,23 @@ class CreateOrUpdateCarAction extends AbstractAction<CarFormValues> {
values: initialValues,
});
form.param("milesPerCharge", NumberParameter, {
label: "Miles per charge",
render: (props) => {
return (
<TabSelector
options={[
{ value: 100 },
{ value: 200 },
{ value: 300 },
{ value: 400 },
]}
{...props}
/>
);
},
});
form.param("subscriptionId", SubscriptionParameter, {
label: "Subscription",
});

Просмотреть файл

@ -0,0 +1,94 @@
import { StringParameter } from "@azure/bonito-core/lib/form";
import { createParam } from "@azure/bonito-ui";
import { TabSelector, TextField } from "@azure/bonito-ui/lib/components/form";
import { Toggle } from "@fluentui/react/lib/Toggle";
import * as React from "react";
import { DemoComponentContainer } from "../../layout/demo-component-container";
import { DemoControlContainer } from "../../layout/demo-control-container";
import { DemoPane } from "../../layout/demo-pane";
export const TabSelectorDemo: React.FC = () => {
const [disabled, setDisabled] = React.useState(false);
const [labelPrefix, setLabelPrefix] = React.useState("Tab");
const [selectedValue, setSelectedValue] = React.useState("");
const labelParam = React.useMemo(
() =>
createParam<string>(StringParameter, {
label: "Label prefix",
value: "Tab",
}),
[]
);
const tabSelectorParam = React.useMemo(
() => createParam<string>(StringParameter),
[]
);
return (
<DemoPane title="Tab Selector">
<DemoControlContainer>
<Toggle
label="Disabled"
inlineLabel
onChange={(_, checked?: boolean) => setDisabled(!!checked)}
checked={disabled}
/>
<TextField
param={labelParam}
onChange={(_, value) => {
setLabelPrefix(value ? String(value) : "Tab");
}}
/>
<div>
<strong>Selected value: </strong>
{selectedValue}
</div>
</DemoControlContainer>
<DemoComponentContainer minWidth="400px">
<TabSelector
param={tabSelectorParam}
disabled={disabled}
onChange={(event, value) => {
setSelectedValue(value as string);
}}
options={[
{
value: "value1",
label: `${labelPrefix} One`,
},
{
value: "value2",
label: `${labelPrefix} Two`,
},
{
value: "value3",
label: `${labelPrefix} Three`,
},
{
value: "value4",
label: `${labelPrefix} Four`,
},
{
value: "value5",
label: `${labelPrefix} Five`,
},
{
value: "value6",
label: `${labelPrefix} Six`,
},
{
value: "value7",
label: `${labelPrefix} Seven`,
},
{
value: "value8",
label: `${labelPrefix} Eight`,
},
]}
/>
</DemoComponentContainer>
</DemoPane>
);
};

Просмотреть файл

@ -15,7 +15,7 @@ export const DemoNavMenu: React.FC = () => {
links: [
{
key: "ActionForm",
name: "ActionForm",
name: "Action Form",
url: getDemoHash("actionform"),
},
{
@ -35,7 +35,7 @@ export const DemoNavMenu: React.FC = () => {
},
{
key: "ComboBox",
name: "ComboBox",
name: "Combo Box",
url: getDemoHash("combobox"),
},
{
@ -45,12 +45,17 @@ export const DemoNavMenu: React.FC = () => {
},
{
key: "SearchBox",
name: "SearchBox",
name: "Search Box",
url: getDemoHash("searchbox"),
},
{
key: "TabSelector",
name: "Tab Selector",
url: getDemoHash("tabselector"),
},
{
key: "TextField",
name: "TextField",
name: "Text Field",
url: getDemoHash("textfield"),
},
{