зеркало из https://github.com/Azure/BatchExplorer.git
Add TabSelector form control (#2827)
This commit is contained in:
Родитель
0c452bc682
Коммит
8970f1aa6f
|
@ -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"),
|
||||
},
|
||||
{
|
||||
|
|
Загрузка…
Ссылка в новой задаче