refactor form fields to enable usage with react-hook-form (#1656)

React Hook Form is a library that makes working with forms easier and reduces boilerplate. For it to be used in our project, some of the form fields had to be adjusted.
This commit is contained in:
Konstantin Schaper
2021-05-14 09:08:57 +02:00
committed by GitHub
parent 7286a62a80
commit 640a270e1d
21 changed files with 1926 additions and 306 deletions

View File

@@ -21,16 +21,83 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC, useRef, useState } from "react";
import { storiesOf } from "@storybook/react";
import Checkbox from "./Checkbox";
import styled from "styled-components";
import Button from "../buttons/Button";
import { useForm } from "react-hook-form";
import { SubmitButton } from "../buttons";
import { MemoryRouter } from "react-router-dom";
const Spacing = styled.div`
padding: 2em;
`;
const Ref: FC = () => {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<Checkbox label={"Ref Checkbox"} checked={false} ref={ref} />
<Button
action={() => {
if (ref.current) {
ref.current.checked = !ref.current.checked;
}
}}
color="primary"
>
Toggle Checkbox
</Button>
</>
);
};
type Settings = {
rememberMe: string;
scramblePassword: string;
};
const ReactHookForm: FC = () => {
const { register, handleSubmit } = useForm<Settings>();
const [stored, setStored] = useState<Settings>();
const onSubmit = (settings: Settings) => {
setStored(settings);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<Checkbox label="Remember Me" {...register("rememberMe")} />
<Checkbox label="Scramble Password" {...register("scramblePassword")} />
<div className="pt-2">
<SubmitButton>Submit</SubmitButton>
</div>
</form>
{stored ? (
<div className="mt-5">
<pre>
<code>{JSON.stringify(stored, null, 2)}</code>
</pre>
</div>
) : null}
</>
);
};
const LegacyEvents: FC = () => {
const [value, setValue] = useState<boolean>(false);
return (
<>
<Checkbox checked={value} onChange={setValue} />
<div className="mt-3">{JSON.stringify(value)}</div>
</>
);
};
storiesOf("Forms|Checkbox", module)
.addDecorator(storyFn => <MemoryRouter>{storyFn()}</MemoryRouter>)
.add("Default", () => (
<Spacing>
<Checkbox label="Not checked" checked={false} />
@@ -52,4 +119,7 @@ storiesOf("Forms|Checkbox", module)
helpText="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet."
/>
</Spacing>
));
))
.add("Ref", () => <Ref />)
.add("Legacy Events", () => <LegacyEvents />)
.add("ReactHookForm", () => <ReactHookForm />);

View File

@@ -21,69 +21,108 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { Help } from "../index";
import React, { ChangeEvent, FC, FocusEvent, useEffect } from "react";
import { createAttributesForTesting, Help } from "../index";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import TriStateCheckbox from "./TriStateCheckbox";
import useInnerRef from "./useInnerRef";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import classNames from "classnames";
type Props = {
export interface CheckboxElement extends HTMLElement {
value: boolean;
}
type BaseProps = {
label?: string;
onChange?: (value: boolean, name?: string) => void;
checked: boolean;
checked?: boolean;
indeterminate?: boolean;
name?: string;
title?: string;
disabled?: boolean;
helpText?: string;
testId?: string;
className?: string;
};
export default class Checkbox extends React.Component<Props> {
onCheckboxChange = () => {
if (this.props.onChange) {
this.props.onChange(!this.props.checked, this.props.name);
const InnerCheckbox: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({
label,
name,
indeterminate,
disabled,
testId,
className,
...props
}) => {
const field = useInnerRef(props.innerRef);
useEffect(() => {
if (field.current) {
field.current.indeterminate = indeterminate || false;
}
}, [field, indeterminate]);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLInputElement, boolean>(props)) {
props.onChange(event);
} else if (isLegacy(props)) {
props.onChange(event.target.checked, name);
}
}
};
onKeyDown = (event: React.KeyboardEvent) => {
const SPACE = 32;
if (event.keyCode === SPACE) {
this.onCheckboxChange();
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (props.onBlur) {
if (isUsingRef<BaseProps, HTMLInputElement, boolean>(props)) {
props.onBlur(event);
} else if (isLegacy(props)) {
props.onBlur(event.target.checked, name);
}
}
};
renderHelp = () => {
const { title, helpText } = this.props;
const renderHelp = () => {
const { title, helpText } = props;
if (helpText && !title) {
return <Help message={helpText} />;
}
};
renderLabelWithHelp = () => {
const { title, helpText } = this.props;
const renderLabelWithHelp = () => {
const { title, helpText } = props;
if (title) {
return <LabelWithHelpIcon label={title} helpText={helpText} />;
}
};
render() {
const { label, checked, indeterminate, disabled, testId } = this.props;
return (
<div className="field">
{this.renderLabelWithHelp()}
<div className="control" onClick={this.onCheckboxChange} onKeyDown={this.onKeyDown}>
{/*
return (
<div className="field">
{renderLabelWithHelp()}
<div className="control">
{/*
we have to ignore the next line,
because jsx label does not the custom disabled attribute
but bulma does.
// @ts-ignore */}
<label className="checkbox" disabled={disabled}>
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
{label}
{this.renderHelp()}
</label>
</div>
<label className="checkbox" disabled={disabled}>
<input
type="checkbox"
name={name}
className={classNames("checkbox", className)}
onChange={handleChange}
onBlur={handleBlur}
ref={field}
checked={props.checked}
disabled={disabled}
{...createAttributesForTesting(testId)}
/>{" "}
{label}
{renderHelp()}
</label>
</div>
);
}
}
</div>
);
};
const Checkbox: FieldType<BaseProps, HTMLInputElement, boolean> = createFormFieldWrapper(InnerCheckbox);
export default Checkbox;

View File

@@ -38,6 +38,9 @@ const FullWidthSelect = styled.select`
width: 100%;
`;
/**
* @deprecated Use `Select` instead
*/
class DropDown extends React.Component<Props> {
render() {
const { options, optionValues, preselectedOption, className, disabled } = this.props;

View File

@@ -0,0 +1,81 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ChangeEvent, FC, FocusEvent } from "react";
export type MinimumBaseProps = {
name?: string;
};
export type LegacyProps<Base extends MinimumBaseProps, ValueType> = Base & {
onChange?: (value: ValueType, name?: string) => void;
onBlur?: (value: ValueType, name?: string) => void;
innerRef?: never;
};
export type RefProps<Base extends MinimumBaseProps, ElementType extends HTMLElement> = Base & {
onChange?: (event: ChangeEvent<ElementType>) => void;
onBlur?: (event: FocusEvent<ElementType>) => void;
};
export type InnerRefProps<Base extends MinimumBaseProps, ElementType extends HTMLElement> = RefProps<
Base,
ElementType
> & {
innerRef: React.ForwardedRef<ElementType>;
};
export const isUsingRef = <Base extends MinimumBaseProps, ElementType extends HTMLElement, ValueType>(
props: Partial<FieldProps<Base, ElementType, ValueType>>
): props is InnerRefProps<Base, ElementType> => {
return (props as Partial<InnerRefProps<Base, ElementType>>).innerRef !== undefined;
};
export const isLegacy = <Base extends MinimumBaseProps, ElementType extends HTMLElement, ValueType>(
props: FieldProps<Base, ElementType, ValueType>
): props is LegacyProps<Base, ValueType> => {
return (props as Partial<InnerRefProps<Base, ElementType>>).innerRef === undefined;
};
export type FieldProps<Base extends MinimumBaseProps, ElementType extends HTMLElement, ValueType> =
| LegacyProps<Base, ValueType>
| InnerRefProps<Base, ElementType>;
export type OuterProps<Base extends MinimumBaseProps, ElementType extends HTMLElement> = RefProps<Base, ElementType> & {
ref: React.Ref<ElementType>;
};
export type FieldType<Base extends MinimumBaseProps, ElementType extends HTMLElement, ValueType> = {
(props: OuterProps<Base, ElementType>): React.ReactElement<OuterProps<Base, ElementType>> | null;
(props: LegacyProps<Base, ValueType>): React.ReactElement<LegacyProps<Base, ValueType>> | null;
};
export const createFormFieldWrapper = <Base extends MinimumBaseProps, ElementType extends HTMLElement, ValueType>(
InnerType: FC<FieldProps<Base, ElementType, ValueType>>
) =>
React.forwardRef<ElementType, LegacyProps<Base, ValueType> | OuterProps<Base, ElementType>>((props, ref) => {
if (ref) {
return <InnerType innerRef={ref} {...(props as RefProps<Base, ElementType>)} />;
}
return <InnerType {...(props as LegacyProps<Base, ValueType>)} />;
});

View File

@@ -0,0 +1,124 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useRef, useState } from "react";
import { storiesOf } from "@storybook/react";
import styled from "styled-components";
import InputField from "./InputField";
import Button from "../buttons/Button";
import { MemoryRouter } from "react-router-dom";
import { useForm } from "react-hook-form";
import { SubmitButton } from "../buttons";
import { Person } from "@scm-manager/ui-types";
const Decorator = styled.div`
padding: 2rem;
max-width: 30rem;
`;
const Ref: FC = () => {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<InputField ref={ref} placeholder="Click the button to focus me" />
<Button action={() => ref.current?.focus()} color="primary">
Focus InputField
</Button>
</>
);
};
const AutoFocusAndRef: FC = () => {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<InputField ref={ref} autofocus={true} placeholder="Click the button to focus me" />
<InputField placeholder="Click me to switch focus" />
<Button action={() => ref.current?.focus()} color="primary">
Focus First InputField
</Button>
</>
);
};
type Name = {
firstName: string;
lastName: string;
};
const ReactHookForm: FC = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<Name>();
const [stored, setStored] = useState<Person>();
const onSubmit = (person: Person) => {
setStored(person);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<InputField label="First Name" autofocus={true} {...register("firstName")} />
<InputField
label="Last Name"
{...register("lastName", { required: true })}
validationError={!!errors.lastName}
errorMessage={"Last name is required"}
/>
<div className="pt-2">
<SubmitButton>Submit</SubmitButton>
</div>
</form>
{stored ? (
<div className="mt-5">
<pre>
<code>{JSON.stringify(stored, null, 2)}</code>
</pre>
</div>
) : null}
</>
);
};
const LegacyEvents: FC = () => {
const [value, setValue] = useState<string>("");
return (
<>
<InputField placeholder="Legacy onChange handler" value={value} onChange={e => setValue(e)} />
<div className="mt-3">{value}</div>
</>
);
};
storiesOf("Forms|InputField", module)
.addDecorator(storyFn => <Decorator>{storyFn()}</Decorator>)
.addDecorator(storyFn => <MemoryRouter>{storyFn()}</MemoryRouter>)
.add("AutoFocus", () => <InputField label="Field with AutoFocus" autofocus={true} />)
.add("Default Value", () => <InputField label="Field with Default Value" defaultValue={"I am a default value"} />)
.add("Ref", () => <Ref />)
.add("Legacy Events", () => <LegacyEvents />)
.add("AutoFocusAndRef", () => <AutoFocusAndRef />)
.add("React Hook Form", () => <ReactHookForm />);

View File

@@ -21,109 +21,109 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ChangeEvent, KeyboardEvent, FocusEvent } from "react";
import React, { ChangeEvent, FC, FocusEvent, KeyboardEvent } from "react";
import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createAttributesForTesting } from "../devBuild";
import useAutofocus from "./useAutofocus";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
type Props = {
type BaseProps = {
label?: string;
name?: string;
placeholder?: string;
value?: string;
type?: string;
autofocus?: boolean;
onChange: (value: string, name?: string) => void;
onReturnPressed?: () => void;
validationError?: boolean;
errorMessage?: string;
errorMessage?: string | string[];
informationMessage?: string;
disabled?: boolean;
helpText?: string;
className?: string;
testId?: string;
onBlur?: (value: string, name?: string) => void;
defaultValue?: string;
};
class InputField extends React.Component<Props> {
static defaultProps = {
type: "text",
placeholder: ""
};
export const InnerInputField: FC<FieldProps<BaseProps, HTMLInputElement, string>> = ({
name,
onReturnPressed,
type,
placeholder,
value,
validationError,
errorMessage,
informationMessage,
disabled,
label,
helpText,
className,
testId,
autofocus,
defaultValue,
...props
}) => {
const field = useAutofocus<HTMLInputElement>(autofocus, props.innerRef);
field: HTMLInputElement | null | undefined;
componentDidMount() {
if (this.props.autofocus && this.field) {
this.field.focus();
}
}
handleInput = (event: ChangeEvent<HTMLInputElement>) => {
this.props.onChange(event.target.value, this.props.name);
};
handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (this.props.onBlur) {
this.props.onBlur(event.target.value, this.props.name);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLInputElement, string>(props)) {
props.onChange(event);
} else if (isLegacy(props)) {
props.onChange(event.target.value, name);
}
}
};
handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
const onReturnPressed = this.props.onReturnPressed;
if (!onReturnPressed) {
return;
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (props.onBlur) {
if (isUsingRef<BaseProps, HTMLInputElement, string>(props)) {
props.onBlur(event);
} else if (isLegacy(props)) {
props.onBlur(event.target.value, name);
}
}
if (event.key === "Enter") {
};
const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
if (onReturnPressed && event.key === "Enter") {
event.preventDefault();
onReturnPressed();
}
};
render() {
const {
type,
placeholder,
value,
validationError,
errorMessage,
informationMessage,
disabled,
label,
helpText,
className,
testId
} = this.props;
const errorView = validationError ? "is-danger" : "";
let helper;
if (validationError) {
helper = <p className="help is-danger">{errorMessage}</p>;
} else if (informationMessage) {
helper = <p className="help is-info">{informationMessage}</p>;
}
return (
<div className={classNames("field", className)}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<input
ref={input => {
this.field = input;
}}
className={classNames("input", errorView)}
type={type}
placeholder={placeholder}
value={value}
onChange={this.handleInput}
onKeyPress={this.handleKeyPress}
disabled={disabled}
onBlur={this.handleBlur}
{...createAttributesForTesting(testId)}
/>
</div>
{helper}
</div>
);
const errorView = validationError ? "is-danger" : "";
let helper;
if (validationError) {
helper = <p className="help is-danger">{errorMessage}</p>;
} else if (informationMessage) {
helper = <p className="help is-info">{informationMessage}</p>;
}
}
return (
<div className={classNames("field", className)}>
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<input
ref={field}
name={name}
className={classNames("input", errorView)}
type={type}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={handleChange}
onKeyPress={handleKeyPress}
onBlur={handleBlur}
defaultValue={defaultValue}
{...createAttributesForTesting(testId)}
/>
</div>
{helper}
</div>
);
};
const InputField: FieldType<BaseProps, HTMLInputElement, string> = createFormFieldWrapper(InnerInputField);
export default InputField;

View File

@@ -21,10 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC, useRef, useState } from "react";
import { storiesOf } from "@storybook/react";
import Radio from "./Radio";
import styled from "styled-components";
import Button from "../buttons/Button";
import { useForm } from "react-hook-form";
import { SubmitButton } from "../buttons";
import { MemoryRouter } from "react-router-dom";
const Spacing = styled.div`
padding: 2em;
@@ -39,7 +43,73 @@ const RadioList = styled.div`
padding: 2em;
`;
const Ref: FC = () => {
const ref = useRef<HTMLInputElement>(null);
return (
<>
<Radio label={"Ref Radio Button"} checked={false} ref={ref} />
<Button
action={() => {
if (ref.current) {
ref.current.checked = true;
}
}}
color="primary"
>
Check InputField
</Button>
</>
);
};
type Settings = {
rememberMe: string;
scramblePassword: string;
};
const ReactHookForm: FC = () => {
const { register, handleSubmit } = useForm<Settings>();
const [stored, setStored] = useState<Settings>();
const onSubmit = (settings: Settings) => {
setStored(settings);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<RadioList>
<Radio defaultChecked={true} value={"true"} label="Remember Me" {...register("rememberMe")} />
<Radio value={"false"} label="Dont Remember Me" {...register("rememberMe")} />
</RadioList>
<Radio className="ml-2" value={"false"} label="Scramble Password" {...register("scramblePassword")} />
<div className="pt-2">
<SubmitButton>Submit</SubmitButton>
</div>
</form>
{stored ? (
<div className="mt-5">
<pre>
<code>{JSON.stringify(stored, null, 2)}</code>
</pre>
</div>
) : null}
</>
);
};
const LegacyEvents: FC = () => {
const [value, setValue] = useState<boolean>(false);
return (
<>
<Radio checked={value} onChange={setValue} />
<div className="mt-3">{JSON.stringify(value)}</div>
</>
);
};
storiesOf("Forms|Radio", module)
.addDecorator(storyFn => <MemoryRouter>{storyFn()}</MemoryRouter>)
.add("Default", () => (
<Spacing>
<Radio label="Not checked" checked={false} />
@@ -60,4 +130,7 @@ storiesOf("Forms|Radio", module)
helpText="Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet."
/>
</RadioList>
));
))
.add("Ref", () => <Ref />)
.add("Legacy Events", () => <LegacyEvents />)
.add("ReactHookForm", () => <ReactHookForm />);

View File

@@ -21,61 +21,81 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ChangeEvent } from "react";
import React, { ChangeEvent, FC, FocusEvent } from "react";
import { Help } from "../index";
import styled from "styled-components";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
import classNames from "classnames";
const StyledRadio = styled.label`
margin-right: 0.5em;
`;
type Props = {
type BaseProps = {
label?: string;
name?: string;
value?: string;
checked: boolean;
onChange?: (value: boolean, name?: string) => void;
checked?: boolean;
disabled?: boolean;
helpText?: string;
defaultChecked?: boolean;
className?: string;
};
class Radio extends React.Component<Props> {
renderHelp = () => {
const helpText = this.props.helpText;
const InnerRadio: FC<FieldProps<BaseProps, HTMLInputElement, boolean>> = ({ name, defaultChecked, ...props }) => {
const renderHelp = () => {
const helpText = props.helpText;
if (helpText) {
return <Help message={helpText} />;
}
};
onValueChange = (event: ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) {
this.props.onChange(event.target.checked, this.props.name);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLInputElement, boolean>(props)) {
props.onChange(event);
} else if (isLegacy(props)) {
props.onChange(Boolean(event.target.checked), name);
}
}
};
render() {
return (
<>
{/*
we have to ignore the next line,
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
if (props.onBlur) {
if (isUsingRef<BaseProps, HTMLInputElement, boolean>(props)) {
props.onBlur(event);
} else if (isLegacy(props)) {
props.onBlur(Boolean(event.target.checked), name);
}
}
};
return (
<>
{/*
we have to ignore the next line,
because jsx label does not the custom disabled attribute
but bulma does.
// @ts-ignore */}
<StyledRadio className="radio" disabled={this.props.disabled}>
<input
type="radio"
name={this.props.name}
value={this.props.value}
checked={this.props.checked}
onChange={this.onValueChange}
disabled={this.props.disabled}
/>{" "}
{this.props.label}
{this.renderHelp()}
</StyledRadio>
</>
);
}
}
<StyledRadio className={classNames("radio", props.className)} disabled={props.disabled}>
<input
type="radio"
name={name}
value={props.value}
checked={props.checked}
onChange={handleChange}
onBlur={handleBlur}
disabled={props.disabled}
ref={props.innerRef}
defaultChecked={defaultChecked}
/>{" "}
{props.label}
{renderHelp()}
</StyledRadio>
</>
);
};
const Radio: FieldType<BaseProps, HTMLInputElement, boolean> = createFormFieldWrapper(InnerRadio);
export default Radio;

View File

@@ -0,0 +1,130 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, useRef, useState } from "react";
import Button from "../buttons/Button";
import { useForm } from "react-hook-form";
import { SubmitButton } from "../buttons";
import { storiesOf } from "@storybook/react";
import { MemoryRouter } from "react-router-dom";
import Select from "./Select";
const Ref: FC = () => {
const ref = useRef<HTMLSelectElement>(null);
const [selected, setSelected] = useState("false");
return (
<>
<Select
options={[
{ label: "foo", value: "true" },
{ label: "bar", value: "false" }
]}
value={selected}
label={"Ref Radio Button"}
onChange={e => setSelected(e.target.value)}
ref={ref}
/>
<Button
action={() => {
if (ref.current) {
ref.current.focus();
}
}}
color="primary"
>
Focus Select Field
</Button>
{selected}
</>
);
};
type Settings = {
rememberMe: string;
scramblePassword: string;
};
const ReactHookForm: FC = () => {
const { register, handleSubmit } = useForm<Settings>();
const [stored, setStored] = useState<Settings>();
const onSubmit = (settings: Settings) => {
setStored(settings);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<Select
options={[
{ label: "Yes", value: "true" },
{ label: "No", value: "false" }
]}
label="Remember Me"
{...register("rememberMe")}
/>
<Select
options={[
{ label: "Yes", value: "true" },
{ label: "No", value: "false" }
]}
label="Scramble Password"
{...register("scramblePassword")}
/>
<div className="pt-2">
<SubmitButton>Submit</SubmitButton>
</div>
</form>
{stored ? (
<div className="mt-5">
<pre>
<code>{JSON.stringify(stored, null, 2)}</code>
</pre>
</div>
) : null}
</>
);
};
const LegacyEvents: FC = () => {
const [value, setValue] = useState<string>();
return (
<>
<Select
options={[
{ label: "Yes", value: "true" },
{ label: "No", value: "false" }
]}
onChange={setValue}
/>
<div className="mt-3">{JSON.stringify(value)}</div>
</>
);
};
storiesOf("Forms|Select", module)
.addDecorator(storyFn => <MemoryRouter>{storyFn()}</MemoryRouter>)
.add("Ref", () => <Ref />)
.add("Legacy Events", () => <LegacyEvents />)
.add("ReactHookForm", () => <ReactHookForm />);

View File

@@ -21,72 +21,107 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ChangeEvent } from "react";
import React, { ChangeEvent, FC, FocusEvent, useEffect } from "react";
import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import {createAttributesForTesting} from "../devBuild";
import { createAttributesForTesting } from "../devBuild";
import useInnerRef from "./useInnerRef";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
export type SelectItem = {
value: string;
label: string;
};
type Props = {
type BaseProps = {
name?: string;
label?: string;
options: SelectItem[];
value?: string;
onChange: (value: string, name?: string) => void;
loading?: boolean;
helpText?: string;
disabled?: boolean;
testId?: string;
defaultValue?: string;
};
class Select extends React.Component<Props> {
field: HTMLSelectElement | null | undefined;
const InnerSelect: FC<FieldProps<BaseProps, HTMLSelectElement, string>> = ({
value,
defaultValue,
name,
label,
helpText,
loading,
disabled,
testId,
...props
}) => {
const field = useInnerRef(props.innerRef);
componentDidMount() {
// trigger change after render, if value is null to set it to the first value
// of the given options.
if (!this.props.value && this.field && this.field.value) {
this.props.onChange(this.field.value);
const handleInput = (event: ChangeEvent<HTMLSelectElement>) => {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLSelectElement, string>(props)) {
props.onChange(event);
} else if (isLegacy(props)) {
props.onChange(event.target.value, name);
}
}
}
handleInput = (event: ChangeEvent<HTMLSelectElement>) => {
this.props.onChange(event.target.value, this.props.name);
};
render() {
const { options, value, label, helpText, loading, disabled, testId } = this.props;
const loadingClass = loading ? "is-loading" : "";
const handleBlur = (event: FocusEvent<HTMLSelectElement>) => {
if (props.onBlur) {
if (isUsingRef<BaseProps, HTMLSelectElement, string>(props)) {
props.onBlur(event);
} else if (isLegacy(props)) {
props.onBlur(event.target.value, name);
}
}
};
return (
<div className="field">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className={classNames("control select", loadingClass)}>
<select
ref={input => {
this.field = input;
}}
value={value}
onChange={this.handleInput}
disabled={disabled}
{...createAttributesForTesting(testId)}
>
{options.map(opt => {
return (
<option value={opt.value} key={"KEY_" + opt.value}>
{opt.label}
</option>
);
})}
</select>
</div>
useEffect(() => {
// trigger change after render, if value is null to set it to the first value
// of the given options.
if (!value && field.current?.value) {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLSelectElement, string>(props)) {
const event = new Event("change");
field.current?.dispatchEvent(event);
} else if (isLegacy(props)) {
props.onChange(field.current?.value, name);
}
}
}
}, [field, name, props, value]);
const loadingClass = loading ? "is-loading" : "";
return (
<div className="field">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className={classNames("control select", loadingClass)}>
<select
name={name}
ref={field}
value={value}
defaultValue={defaultValue}
onChange={handleInput}
onBlur={handleBlur}
disabled={disabled}
{...createAttributesForTesting(testId)}
>
{props.options.map(opt => {
return (
<option value={opt.value} key={"KEY_" + opt.value}>
{opt.label}
</option>
);
})}
</select>
</div>
);
}
}
</div>
);
};
const Select: FieldType<BaseProps, HTMLSelectElement, string> = createFormFieldWrapper(InnerSelect);
export default Select;

View File

@@ -21,10 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useState } from "react";
import React, { FC, useRef, useState } from "react";
import { storiesOf } from "@storybook/react";
import styled from "styled-components";
import Textarea from "./Textarea";
import Button from "../buttons/Button";
import { useForm } from "react-hook-form";
import { SubmitButton } from "../buttons";
import { MemoryRouter } from "react-router-dom";
const Spacing = styled.div`
padding: 2em;
@@ -59,7 +63,7 @@ const OnSubmitTextare = () => {
);
};
const OnCancelTextare = () => {
const OnCancelTextarea = () => {
const [value, setValue] = useState("Use the escape key to clear the textarea");
const cancel = () => {
@@ -73,7 +77,97 @@ const OnCancelTextare = () => {
);
};
const Ref: FC = () => {
const ref = useRef<HTMLTextAreaElement>(null);
return (
<>
<Textarea ref={ref} />
<Button action={() => ref.current?.focus()} color="primary">
Focus InputField
</Button>
</>
);
};
const AutoFocusAndRef: FC = () => {
const ref = useRef<HTMLTextAreaElement>(null);
return (
<>
<Textarea ref={ref} autofocus={true} placeholder="Click the button to focus me" />
<Textarea placeholder="Click me to switch focus" />
<Button action={() => ref.current?.focus()} color="primary">
Focus First InputField
</Button>
</>
);
};
type Commit = {
message: string;
footer: string;
};
const ReactHookForm: FC = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<Commit>();
const [stored, setStored] = useState<Commit>();
const onSubmit = (commit: Commit) => {
setStored(commit);
};
return (
<>
<form onSubmit={handleSubmit(onSubmit)}>
<Textarea
autofocus={true}
label="Message"
{...register("message", { required: true })}
validationError={!!errors.message}
errorMessage={"Message is required"}
/>
<Textarea label="Footer" {...register("footer")} />
<div className="pt-2">
<SubmitButton>Submit</SubmitButton>
</div>
</form>
{stored ? (
<div className="mt-5">
<pre>
<code>{JSON.stringify(stored, null, 2)}</code>
</pre>
</div>
) : null}
</>
);
};
const LegacyEvents: FC = () => {
const [value, setValue] = useState<string>("");
return (
<>
<Textarea placeholder="Legacy onChange handler" value={value} onChange={e => setValue(e)} />
<div className="mt-3">{value}</div>
</>
);
};
storiesOf("Forms|Textarea", module)
.addDecorator(storyFn => <MemoryRouter>{storyFn()}</MemoryRouter>)
.add("OnChange", () => <OnChangeTextarea />)
.add("OnSubmit", () => <OnSubmitTextare />)
.add("OnCancel", () => <OnCancelTextare />);
.add("OnCancel", () => <OnCancelTextarea />)
.add("AutoFocus", () => <Textarea label="Field with AutoFocus" autofocus={true} />)
.add("Default Value", () => (
<Textarea
label="Field with Default Value"
defaultValue={"I am a text area with so much default value its crazy!"}
/>
))
.add("Ref", () => <Ref />)
.add("Legacy Events", () => <LegacyEvents />)
.add("AutoFocusAndRef", () => <AutoFocusAndRef />)
.add("ReactHookForm", () => <ReactHookForm />);

View File

@@ -21,70 +21,107 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { ChangeEvent, KeyboardEvent } from "react";
import React, { ChangeEvent, FC, FocusEvent, KeyboardEvent } from "react";
import LabelWithHelpIcon from "./LabelWithHelpIcon";
import useAutofocus from "./useAutofocus";
import classNames from "classnames";
import { createFormFieldWrapper, FieldProps, FieldType, isLegacy, isUsingRef } from "./FormFieldTypes";
type Props = {
type BaseProps = {
name?: string;
label?: string;
placeholder?: string;
value?: string;
autofocus?: boolean;
onChange: (value: string, name?: string) => void;
helpText?: string;
disabled?: boolean;
onSubmit?: () => void;
onCancel?: () => void;
validationError?: boolean;
errorMessage?: string | string[];
informationMessage?: string;
defaultValue?: string;
};
class Textarea extends React.Component<Props> {
field: HTMLTextAreaElement | null | undefined;
const InnerTextarea: FC<FieldProps<BaseProps, HTMLTextAreaElement, string>> = ({
placeholder,
value,
autofocus,
name,
label,
helpText,
disabled,
onSubmit,
onCancel,
errorMessage,
validationError,
informationMessage,
defaultValue,
...props
}) => {
const ref = useAutofocus<HTMLTextAreaElement>(autofocus, props.innerRef);
componentDidMount() {
if (this.props.autofocus && this.field) {
this.field.focus();
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
if (props.onChange) {
if (isUsingRef<BaseProps, HTMLTextAreaElement, string>(props)) {
props.onChange(event);
} else if (isLegacy(props)) {
props.onChange(event.target.value, name);
}
}
}
handleInput = (event: ChangeEvent<HTMLTextAreaElement>) => {
this.props.onChange(event.target.value, this.props.name);
};
onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const { onCancel } = this.props;
const handleBlur = (event: FocusEvent<HTMLTextAreaElement>) => {
if (props.onBlur) {
if (isUsingRef<BaseProps, HTMLTextAreaElement, string>(props)) {
props.onBlur(event);
} else if (isLegacy(props)) {
props.onBlur(event.target.value, name);
}
}
};
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (onCancel && event.key === "Escape") {
onCancel();
return;
}
const { onSubmit } = this.props;
if (onSubmit && event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
onSubmit();
}
};
render() {
const { placeholder, value, label, helpText, disabled } = this.props;
return (
<div className="field">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<textarea
className="textarea"
ref={input => {
this.field = input;
}}
placeholder={placeholder}
onChange={this.handleInput}
value={value}
disabled={!!disabled}
onKeyDown={this.onKeyDown}
/>
</div>
</div>
);
const errorView = validationError ? "is-danger" : "";
let helper;
if (validationError) {
helper = <p className="help is-danger">{errorMessage}</p>;
} else if (informationMessage) {
helper = <p className="help is-info">{informationMessage}</p>;
}
}
return (
<div className="field">
<LabelWithHelpIcon label={label} helpText={helpText} />
<div className="control">
<textarea
className={classNames("textarea", errorView)}
ref={ref}
name={name}
placeholder={placeholder}
onChange={handleChange}
onBlur={handleBlur}
value={value}
disabled={disabled}
onKeyDown={onKeyDown}
defaultValue={defaultValue}
/>
</div>
{helper}
</div>
);
};
const Textarea: FieldType<BaseProps, HTMLTextAreaElement, string> = createFormFieldWrapper(InnerTextarea);
export default Textarea;

View File

@@ -0,0 +1,50 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useEffect } from "react";
import { useRef } from "react";
const useAutofocus = <T extends HTMLElement | null>(enabled?: boolean, innerRef?: React.ForwardedRef<T>) => {
const ref = useRef<T>(null);
useEffect(() => {
if (enabled && ref.current) {
ref.current.focus();
}
}, [enabled, ref]);
useEffect(() => {
if (innerRef) {
if (typeof innerRef === "function") {
innerRef(ref.current);
} else {
innerRef.current = ref.current;
}
}
}, [ref, innerRef]);
return ref;
};
export default useAutofocus;

View File

@@ -0,0 +1,43 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { useEffect, useRef } from "react";
const useInnerRef = <T extends HTMLElement | null>(innerRef?: React.ForwardedRef<T>) => {
const ref = useRef<T>(null);
useEffect(() => {
if (innerRef) {
if (typeof innerRef === "function") {
innerRef(ref.current);
} else {
innerRef.current = ref.current;
}
}
}, [ref, innerRef]);
return ref;
};
export default useInnerRef;