Improve diverse form features

- General responsiveness
- Resize select component
- Fix datepicker for dark themes
- Make success notification configurable

Committed-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>

Reviewed-by: Rene Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2023-04-03 10:02:17 +02:00
committed by SCM-Manager
parent 026ffa18fd
commit b53f8bcf12
18 changed files with 285 additions and 78 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Optional reset button for forms

View File

@@ -32,7 +32,7 @@ type Result<C extends HalRepresentation> = {
configuration: C;
};
type MutationVariables<C extends HalRepresentation> = {
type MutationVariables<C> = {
configuration: C;
contentType: string;
link: string;
@@ -53,8 +53,8 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
error: mutationError,
mutateAsync,
data: updateResponse,
} = useMutation<Response, Error, MutationVariables<C>>(
(vars: MutationVariables<C>) => apiClient.put(vars.link, vars.configuration, vars.contentType),
} = useMutation<Response, Error, MutationVariables<Omit<C, keyof HalRepresentation>>>(
(vars) => apiClient.put(vars.link, vars.configuration, vars.contentType),
{
onSuccess: async () => {
await queryClient.invalidateQueries(queryKey);
@@ -65,7 +65,7 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
const isReadOnly = !data?.configuration._links.update;
const update = useCallback(
(configuration: C) => {
(configuration: Omit<C, keyof HalRepresentation>) => {
if (data && !isReadOnly) {
return mutateAsync({
configuration,

View File

@@ -102,7 +102,7 @@ function AddListEntryForm<FormType extends Record<string, unknown>, DefaultValue
return (
<ScmFormContextProvider {...form} t={translateWithExtraPrefix} formId={nameWithPrefix}>
<ScmFormPathContextProvider path="">
<form id={nameWithPrefix} onSubmit={form.handleSubmit(onSubmit)}></form>
<form id={nameWithPrefix} onSubmit={form.handleSubmit(onSubmit)} noValidate></form>
{typeof children === "function" ? children(form) : children}
<div className="level-right">
<Button

View File

@@ -21,13 +21,18 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import {useConfigLink} from "@scm-manager/ui-api";
import {Loading} from "@scm-manager/ui-components";
import React, {ComponentProps} from "react";
import {HalRepresentation} from "@scm-manager/ui-types";
import Form from "./Form";
type Props<T extends HalRepresentation> = Pick<ComponentProps<typeof Form<T, T>>, "translationPath" | "children"> & {
import { useConfigLink } from "@scm-manager/ui-api";
import { Loading } from "@scm-manager/ui-components";
import React, { ComponentProps } from "react";
import { HalRepresentation } from "@scm-manager/ui-types";
import Form from "./Form";
import { useTranslation } from "react-i18next";
type Props<T extends HalRepresentation> =
// eslint-disable-next-line prettier/prettier
Omit<ComponentProps<typeof Form<T>>, "onSubmit" | "defaultValues" | "readOnly" | "successMessageFallback">
& {
link: string;
};
@@ -35,20 +40,17 @@ type Props<T extends HalRepresentation> = Pick<ComponentProps<typeof Form<T, T>>
* @beta
* @since 2.41.0
*/
export function ConfigurationForm<T extends HalRepresentation>({link, translationPath, children}: Props<T>) {
const {initialConfiguration, isReadOnly, update, isLoading} = useConfigLink<T>(link);
export function ConfigurationForm<T extends HalRepresentation>({ link, children, ...formProps }: Props<T>) {
const { initialConfiguration, isReadOnly, update, isLoading } = useConfigLink<T>(link);
const [t] = useTranslation("commons", { keyPrefix: "form" });
if (isLoading || !initialConfiguration) {
return <Loading/>;
return <Loading />;
}
return (
<Form<T, T>
onSubmit={update}
translationPath={translationPath}
defaultValues={initialConfiguration}
readOnly={isReadOnly}
>
<Form onSubmit={update} defaultValues={initialConfiguration} readOnly={isReadOnly}
successMessageFallback={t("submit-success-notification")} {...formProps}>
{children}
</Form>
);

View File

@@ -22,7 +22,7 @@
* SOFTWARE.
*/
/* eslint-disable no-console */
import React from "react";
import React, { useRef, useState } from "react";
import { storiesOf } from "@storybook/react";
import Form from "./Form";
import FormRow from "./FormRow";
@@ -35,6 +35,7 @@ import ControlledColumn from "./table/ControlledColumn";
import ControlledTable from "./table/ControlledTable";
import AddListEntryForm from "./AddListEntryForm";
import { ScmFormListContextProvider } from "./ScmFormListContext";
import { HalRepresentation } from "@scm-manager/ui-types";
export type SimpleWebHookConfiguration = {
urlPattern: string;
@@ -62,7 +63,7 @@ storiesOf("Forms", module)
}}
>
<FormRow>
<ControlledInputField name="name" />
<ControlledInputField rules={{ required: true }} name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" />
@@ -79,6 +80,7 @@ storiesOf("Forms", module)
defaultValues={{
name: "trillian",
password: "secret",
passwordConfirmation: "",
active: true,
}}
>
@@ -93,6 +95,77 @@ storiesOf("Forms", module)
</FormRow>
</Form>
))
.add("Reset", () => {
type FormType = {
name: string;
password: string;
passwordConfirmation: string;
active: boolean;
date: string;
};
const [defaultValues, setValues] = useState({
name: "trillian",
password: "secret",
passwordConfirmation: "secret",
active: true,
date: "",
});
const resetValues = useRef({
name: "",
password: "",
passwordConfirmation: "",
active: false,
date: "",
});
return (
<Form<FormType>
onSubmit={(vals) => {
console.log(vals);
setValues(vals);
}}
translationPath={["sample", "form"]}
defaultValues={defaultValues as HalRepresentation & FormType}
withResetTo={resetValues.current}
withDiscardChanges
>
<FormRow>
<ControlledInputField name="name" />
</FormRow>
<FormRow>
<ControlledSecretConfirmationField name="password" rules={{ required: true }} />
</FormRow>
<FormRow>
<ControlledCheckboxField name="active" />
</FormRow>
<FormRow>
<ControlledInputField name="date" type="date" />
</FormRow>
</Form>
);
})
.add("Layout", () => (
<Form
onSubmit={console.log}
translationPath={["sample", "form"]}
defaultValues={{
name: "",
password: "",
method: "",
}}
>
<FormRow>
<ControlledInputField name="name" />
<ControlledSelectField name="method">
{["", "post", "get", "put"].map((value) => (
<option value={value} key={value}>
{value}
</option>
))}
</ControlledSelectField>
<ControlledSecretConfirmationField name="password" />
</FormRow>
</Form>
))
.add("GlobalConfiguration", () => (
<Form
onSubmit={console.log}
@@ -242,6 +315,36 @@ storiesOf("Forms", module)
},
],
}}
withResetTo={{
webhooks: [
{
urlPattern: "https://other.com",
executeOnEveryCommit: true,
sendCommitData: true,
method: "get",
headers: [
{
key: "change",
value: "new",
concealed: true,
},
],
},
{
urlPattern: "http://things.com",
executeOnEveryCommit: false,
sendCommitData: true,
method: "put",
headers: [
{
key: "stuff",
value: "haha",
concealed: false,
},
],
},
],
}}
>
<ScmFormListContextProvider name="webhooks">
<ControlledList withDelete>
@@ -273,6 +376,7 @@ storiesOf("Forms", module)
<ControlledColumn name="concealed">{(value) => (value ? <b>Hallo</b> : null)}</ControlledColumn>
</ControlledTable>
<AddListEntryForm defaultValues={{ key: "", value: "", concealed: false }}>
<FormRow>
<ControlledInputField
name="key"
rules={{
@@ -281,8 +385,13 @@ storiesOf("Forms", module)
required: true,
}}
/>
</FormRow>
<FormRow>
<ControlledInputField name="value" />
</FormRow>
<FormRow>
<ControlledCheckboxField name="concealed" />
</FormRow>
</AddListEntryForm>
</ScmFormListContextProvider>
</div>

View File

@@ -28,13 +28,19 @@ import { ErrorNotification, Level } from "@scm-manager/ui-components";
import { ScmFormContextProvider } from "./ScmFormContext";
import { useTranslation } from "react-i18next";
import { Button } from "@scm-manager/ui-buttons";
import { HalRepresentation } from "@scm-manager/ui-types";
import styled from "styled-components";
import { setValues } from "./helpers";
type RenderProps<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
"register" | "unregister" | "handleSubmit" | "control"
>;
const ButtonsContainer = styled.div`
display: flex;
gap: 0.75rem;
`;
const SuccessNotification: FC<{ label?: string; hide: () => void }> = ({ label, hide }) => {
if (!label) {
return null;
@@ -52,28 +58,60 @@ type Props<FormType extends Record<string, unknown>, DefaultValues extends FormT
children: ((renderProps: RenderProps<FormType>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
translationPath: [namespace: string, prefix: string];
onSubmit: SubmitHandler<FormType>;
defaultValues: Omit<DefaultValues, keyof HalRepresentation>;
defaultValues: DefaultValues;
readOnly?: boolean;
submitButtonTestId?: string;
/**
* Renders a button which resets the form to its default.
* This reflects the default browser behavior for a form *reset*.
*
* @since 2.43.0
*/
withDiscardChanges?: boolean;
/**
* Renders a button which acts as if a user manually updated all fields to supplied values.
* The default use-case for this is to clear forms and this is also how the button is labelled.
* You can also use it to reset the form to an original state, but it is then advised to change the button label
* to *Reset to Defaults* by defining the *reset* translation in the form's translation object's root.
*
* > *Important Note:* This mechanism cannot be used to change the number of items in lists,
* > neither on the root level nor nested.
* > It is therefore advised not to use this property when lists or nested forms are involved.
*
* @since 2.43.0
*/
withResetTo?: DefaultValues;
/**
* Message to display after a successful submit if no translation key is defined.
*
* If this is not supplied and the root level `submit-success-notification` translation key is not set,
* no message is displayed at all.
*
* @since 2.43.0
*/
successMessageFallback?: string;
};
/**
* @beta
* @since 2.41.0
*/
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType>({
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType = FormType>({
children,
onSubmit,
defaultValues,
translationPath,
readOnly,
withResetTo,
withDiscardChanges,
successMessageFallback,
submitButtonTestId,
}: Props<FormType, DefaultValues>) {
const form = useForm<FormType>({
mode: "onChange",
defaultValues: defaultValues as DeepPartial<FormType>,
});
const { formState, handleSubmit, reset } = form;
const { formState, handleSubmit, reset, setValue } = form;
const [ns, prefix] = translationPath;
const { t } = useTranslation(ns, { keyPrefix: prefix });
const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form" });
@@ -91,9 +129,16 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
const [error, setError] = useState<Error | null | undefined>();
const [showSuccessNotification, setShowSuccessNotification] = useState(false);
const submitButtonLabel = t("submit", { defaultValue: defaultTranslate("submit") });
const resetButtonLabel = t("reset", { defaultValue: defaultTranslate("reset") });
const discardChangesButtonLabel = t("discardChanges", { defaultValue: defaultTranslate("discardChanges") });
const successNotification = translateWithFallback("submit-success-notification", {
defaultValue: defaultTranslate("submit-success-notification"),
defaultValue: successMessageFallback,
});
const overwriteValues = useCallback(() => {
if (withResetTo) {
setValues(withResetTo, setValue);
}
}, [setValue, withResetTo]);
// See https://react-hook-form.com/api/useform/reset/
useEffect(() => {
@@ -128,7 +173,7 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
return (
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback} formId={prefix}>
<form onSubmit={handleSubmit(submit)} id={prefix}></form>
<form onSubmit={handleSubmit(submit)} onReset={() => reset()} id={prefix} noValidate></form>
{showSuccessNotification ? (
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
) : null}
@@ -137,6 +182,7 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
{!readOnly ? (
<Level
right={
<ButtonsContainer>
<Button
type="submit"
variant="primary"
@@ -147,6 +193,17 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
>
{submitButtonLabel}
</Button>
{withDiscardChanges ? (
<Button type="reset" form={prefix} testId={`${prefix}-discard-changes-button`}>
{discardChangesButtonLabel}
</Button>
) : null}
{withResetTo ? (
<Button form={prefix} onClick={overwriteValues} testId={`${prefix}-reset-button`}>
{resetButtonLabel}
</Button>
) : null}
</ButtonsContainer>
}
/>
) : null}

View File

@@ -24,26 +24,13 @@
import React, { HTMLProps } from "react";
import classNames from "classnames";
import styled from "styled-components";
const FormRowDiv = styled.div`
.field {
margin-left: 0;
}
gap: 1rem;
&:not(:last-child) {
margin-bottom: 1rem;
}
`;
const FormRow = React.forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
({ className, children, hidden, ...rest }, ref) =>
hidden ? null : (
<FormRowDiv ref={ref} className={classNames("is-flex is-flex-wrap-wrap", className)} {...rest}>
<div ref={ref} className={classNames("columns", className)} {...rest}>
{children}
</FormRowDiv>
</div>
)
);

View File

@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
import CheckboxField from "./CheckboxField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof CheckboxField>,
@@ -46,6 +47,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
testId,
defaultChecked,
readOnly,
className,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
@@ -64,7 +66,8 @@ function ControlledInputField<T extends Record<string, unknown>>({
<CheckboxField
form={formId}
readOnly={readOnly ?? formReadonly}
defaultChecked={field.value}
checked={field.value}
className={classNames("column", className)}
{...props}
{...field}
label={labelTranslation}

View File

@@ -22,6 +22,30 @@
* SOFTWARE.
*/
import { UseFormReturn } from "react-hook-form";
export function prefixWithoutIndices(path: string): string {
return path.replace(/(\.\d+)/g, "");
}
/**
* Works like {@link setValue} but recursively applies the whole {@link newValues} object.
*
* > *Important Note:* This deeply overwrites input values of fields in arrays,
* > but does **NOT** add or remove items to existing arrays.
* > This can therefore not be used to clear lists.
*/
export function setValues<T>(newValues: T, setValue: UseFormReturn<T>["setValue"], path = "") {
for (const [key, val] of Object.entries(newValues)) {
if (val !== null && typeof val === "object") {
if (Array.isArray(val)) {
val.forEach((subVal, idx) => setValues(subVal, setValue, path ? `${path}.${key}.${idx}` : `${key}.${idx}`));
} else {
setValues(val, setValue, path ? `${path}.${key}` : key);
}
} else {
const fullPath = path ? `${path}.${key}` : key;
setValue(fullPath as any, val, { shouldValidate: !fullPath.endsWith("Confirmation"), shouldDirty: true });
}
}
}

View File

@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
@@ -46,6 +47,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
testId,
defaultValue,
readOnly,
className,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
@@ -64,6 +66,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
<InputField
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
className={classNames("column", className)}
{...props}
{...field}
form={formId}

View File

@@ -22,12 +22,13 @@
* SOFTWARE.
*/
import React, { ComponentProps } from "react";
import React, { ComponentProps, useCallback, useMemo } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import { useScmFormContext } from "../ScmFormContext";
import InputField from "./InputField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
@@ -69,6 +70,16 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
const confirmationErrorMessageTranslation =
confirmationErrorMessage || t(`${prefixedNameWithoutIndices}.confirmation.errorMessage`);
const secretValue = watch(nameWithPrefix);
const validateConfirmField = useCallback(
(value) => secretValue === value || confirmationErrorMessageTranslation,
[confirmationErrorMessageTranslation, secretValue]
);
const confirmFieldRules = useMemo(
() => ({
validate: validateConfirmField,
}),
[validateConfirmField]
);
return (
<>
@@ -82,7 +93,7 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
}}
render={({ field, fieldState }) => (
<InputField
className={className}
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
{...props}
{...field}
@@ -104,9 +115,9 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
control={control}
name={`${nameWithPrefix}Confirmation`}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
render={({ field, fieldState: { error } }) => (
<InputField
className={className}
className={classNames("column", className)}
type="password"
readOnly={readOnly ?? formReadonly}
disabled={props.disabled}
@@ -115,17 +126,12 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
label={confirmationLabelTranslation}
helpText={confirmationHelpTextTranslation}
error={
fieldState.error
? fieldState.error.message ||
t(`${prefixedNameWithoutIndices}.confirmation.error.${fieldState.error.type}`)
: undefined
error ? error.message || t(`${prefixedNameWithoutIndices}.confirmation.error.${error.type}`) : undefined
}
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
/>
)}
rules={{
validate: (value) => secretValue === value || confirmationErrorMessageTranslation,
}}
rules={confirmFieldRules}
/>
</>
);

View File

@@ -35,8 +35,7 @@ type InputFieldProps = {
label: string;
helpText?: string;
error?: string;
type?: "text" | "password" | "email" | "tel";
} & Omit<React.ComponentProps<typeof Input>, "type">;
} & React.ComponentProps<typeof Input>;
/**
* @see https://bulma.io/documentation/form/input/

View File

@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
import SelectField from "./SelectField";
import { useScmFormPathContext } from "../FormPathContext";
import { prefixWithoutIndices } from "../helpers";
import classNames from "classnames";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof SelectField>,
@@ -46,6 +47,7 @@ function ControlledSelectField<T extends Record<string, unknown>>({
testId,
defaultValue,
readOnly,
className,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
@@ -65,6 +67,7 @@ function ControlledSelectField<T extends Record<string, unknown>>({
form={formId}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
className={classNames("column", className)}
{...props}
{...field}
label={labelTranslation}

View File

@@ -51,7 +51,7 @@ const SelectField = React.forwardRef<HTMLSelectElement, Props>(
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Select id={selectId} variant={variant} ref={ref} {...props}></Select>
<Select id={selectId} variant={variant} ref={ref} className="is-full-width" {...props}></Select>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>

View File

@@ -300,3 +300,7 @@ $danger-25: scale-color($danger, $lightness: -75%);
.has-hover-visible:hover {
color: $grey !important;
}
input[type="date"].input::-webkit-calendar-picker-indicator {
filter: invert(100%);
}

View File

@@ -372,3 +372,7 @@ td:first-child.diff-gutter-conflict:before {
border: 1px solid $info;
box-shadow: none;
}
input[type="date"].input::-webkit-calendar-picker-indicator {
filter: invert(100%);
}

View File

@@ -1,6 +1,8 @@
{
"form": {
"submit": "Speichern",
"reset": "Leeren",
"discardChanges": "Änderungen verwerfen",
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
"table": {
"headers": {

View File

@@ -1,6 +1,8 @@
{
"form": {
"submit": "Submit",
"reset": "Clear",
"discardChanges": "Discard changes",
"submit-success-notification": "Configuration changed successfully!",
"table": {
"headers": {