From b53f8bcf125379df69d4932e963864d2bf4b0d75 Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Mon, 3 Apr 2023 10:02:17 +0200 Subject: [PATCH] Improve diverse form features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - General responsiveness - Resize select component - Fix datepicker for dark themes - Make success notification configurable Committed-by: Eduard Heimbuch Co-authored-by: René Pfeuffer Reviewed-by: Rene Pfeuffer --- gradle/changelog/form_reset.yaml | 2 + scm-ui/ui-api/src/configLink.ts | 8 +- scm-ui/ui-forms/src/AddListEntryForm.tsx | 2 +- scm-ui/ui-forms/src/ConfigurationForm.tsx | 32 +++-- scm-ui/ui-forms/src/Form.stories.tsx | 133 ++++++++++++++++-- scm-ui/ui-forms/src/Form.tsx | 89 +++++++++--- scm-ui/ui-forms/src/FormRow.tsx | 17 +-- .../src/checkbox/ControlledCheckboxField.tsx | 5 +- scm-ui/ui-forms/src/helpers.ts | 24 ++++ .../src/input/ControlledInputField.tsx | 3 + .../ControlledSecretConfirmationField.tsx | 28 ++-- scm-ui/ui-forms/src/input/InputField.tsx | 3 +- .../src/select/ControlledSelectField.tsx | 3 + scm-ui/ui-forms/src/select/SelectField.tsx | 2 +- scm-ui/ui-styles/src/dark.scss | 4 + scm-ui/ui-styles/src/highcontrast.scss | 4 + .../ui-webapp/public/locales/de/commons.json | 2 + .../ui-webapp/public/locales/en/commons.json | 2 + 18 files changed, 285 insertions(+), 78 deletions(-) create mode 100644 gradle/changelog/form_reset.yaml diff --git a/gradle/changelog/form_reset.yaml b/gradle/changelog/form_reset.yaml new file mode 100644 index 0000000000..9571708ac4 --- /dev/null +++ b/gradle/changelog/form_reset.yaml @@ -0,0 +1,2 @@ +- type: added + description: Optional reset button for forms diff --git a/scm-ui/ui-api/src/configLink.ts b/scm-ui/ui-api/src/configLink.ts index 4ba3e50201..0dc1faa0d6 100644 --- a/scm-ui/ui-api/src/configLink.ts +++ b/scm-ui/ui-api/src/configLink.ts @@ -32,7 +32,7 @@ type Result = { configuration: C; }; -type MutationVariables = { +type MutationVariables = { configuration: C; contentType: string; link: string; @@ -53,8 +53,8 @@ export const useConfigLink = (link: string) => { error: mutationError, mutateAsync, data: updateResponse, - } = useMutation>( - (vars: MutationVariables) => apiClient.put(vars.link, vars.configuration, vars.contentType), + } = useMutation>>( + (vars) => apiClient.put(vars.link, vars.configuration, vars.contentType), { onSuccess: async () => { await queryClient.invalidateQueries(queryKey); @@ -65,7 +65,7 @@ export const useConfigLink = (link: string) => { const isReadOnly = !data?.configuration._links.update; const update = useCallback( - (configuration: C) => { + (configuration: Omit) => { if (data && !isReadOnly) { return mutateAsync({ configuration, diff --git a/scm-ui/ui-forms/src/AddListEntryForm.tsx b/scm-ui/ui-forms/src/AddListEntryForm.tsx index 9ba45a1dc4..51f69d9991 100644 --- a/scm-ui/ui-forms/src/AddListEntryForm.tsx +++ b/scm-ui/ui-forms/src/AddListEntryForm.tsx @@ -102,7 +102,7 @@ function AddListEntryForm, DefaultValue return ( -
+
{typeof children === "function" ? children(form) : children}
diff --git a/scm-ui/ui-forms/src/Form.tsx b/scm-ui/ui-forms/src/Form.tsx index 3c7ac748b9..e12c29a5ca 100644 --- a/scm-ui/ui-forms/src/Form.tsx +++ b/scm-ui/ui-forms/src/Form.tsx @@ -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> = Omit< UseFormReturn, "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, DefaultValues extends FormT children: ((renderProps: RenderProps) => React.ReactNode | React.ReactNode[]) | React.ReactNode; translationPath: [namespace: string, prefix: string]; onSubmit: SubmitHandler; - defaultValues: Omit; + 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, DefaultValues extends FormType>({ +function Form, DefaultValues extends FormType = FormType>({ children, onSubmit, defaultValues, translationPath, readOnly, + withResetTo, + withDiscardChanges, + successMessageFallback, submitButtonTestId, }: Props) { const form = useForm({ mode: "onChange", defaultValues: defaultValues as DeepPartial, }); - 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, DefaultValues extends Fo const [error, setError] = useState(); 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, DefaultValues extends Fo return ( - +
reset()} id={prefix} noValidate>
{showSuccessNotification ? ( setShowSuccessNotification(false)} /> ) : null} @@ -137,16 +182,28 @@ function Form, DefaultValues extends Fo {!readOnly ? ( - {submitButtonLabel} - + + + {withDiscardChanges ? ( + + ) : null} + {withResetTo ? ( + + ) : null} + } /> ) : null} diff --git a/scm-ui/ui-forms/src/FormRow.tsx b/scm-ui/ui-forms/src/FormRow.tsx index 18ed8418be..b385e988ad 100644 --- a/scm-ui/ui-forms/src/FormRow.tsx +++ b/scm-ui/ui-forms/src/FormRow.tsx @@ -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>( ({ className, children, hidden, ...rest }, ref) => hidden ? null : ( - +
{children} - +
) ); diff --git a/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx b/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx index b322ecbccb..f19e3eea95 100644 --- a/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx +++ b/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx @@ -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> = Omit< ComponentProps, @@ -46,6 +47,7 @@ function ControlledInputField>({ testId, defaultChecked, readOnly, + className, ...props }: Props) { const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); @@ -64,7 +66,8 @@ function ControlledInputField>({ *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(newValues: T, setValue: UseFormReturn["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 }); + } + } +} diff --git a/scm-ui/ui-forms/src/input/ControlledInputField.tsx b/scm-ui/ui-forms/src/input/ControlledInputField.tsx index f2b3bd355d..10b2bbc3a0 100644 --- a/scm-ui/ui-forms/src/input/ControlledInputField.tsx +++ b/scm-ui/ui-forms/src/input/ControlledInputField.tsx @@ -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> = Omit< ComponentProps, @@ -46,6 +47,7 @@ function ControlledInputField>({ testId, defaultValue, readOnly, + className, ...props }: Props) { const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); @@ -64,6 +66,7 @@ function ControlledInputField>({ > = Omit< ComponentProps, @@ -69,6 +70,16 @@ export default function ControlledSecretConfirmationField secretValue === value || confirmationErrorMessageTranslation, + [confirmationErrorMessageTranslation, secretValue] + ); + const confirmFieldRules = useMemo( + () => ({ + validate: validateConfirmField, + }), + [validateConfirmField] + ); return ( <> @@ -82,7 +93,7 @@ export default function ControlledSecretConfirmationField ( ( + render={({ field, fieldState: { error } }) => ( )} - rules={{ - validate: (value) => secretValue === value || confirmationErrorMessageTranslation, - }} + rules={confirmFieldRules} /> ); diff --git a/scm-ui/ui-forms/src/input/InputField.tsx b/scm-ui/ui-forms/src/input/InputField.tsx index 85387d24ad..138a62a422 100644 --- a/scm-ui/ui-forms/src/input/InputField.tsx +++ b/scm-ui/ui-forms/src/input/InputField.tsx @@ -35,8 +35,7 @@ type InputFieldProps = { label: string; helpText?: string; error?: string; - type?: "text" | "password" | "email" | "tel"; -} & Omit, "type">; +} & React.ComponentProps; /** * @see https://bulma.io/documentation/form/input/ diff --git a/scm-ui/ui-forms/src/select/ControlledSelectField.tsx b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx index 79e1176666..8ff5d2d618 100644 --- a/scm-ui/ui-forms/src/select/ControlledSelectField.tsx +++ b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx @@ -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> = Omit< ComponentProps, @@ -46,6 +47,7 @@ function ControlledSelectField>({ testId, defaultValue, readOnly, + className, ...props }: Props) { const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); @@ -65,6 +67,7 @@ function ControlledSelectField>({ form={formId} readOnly={readOnly ?? formReadonly} required={rules?.required as boolean} + className={classNames("column", className)} {...props} {...field} label={labelTranslation} diff --git a/scm-ui/ui-forms/src/select/SelectField.tsx b/scm-ui/ui-forms/src/select/SelectField.tsx index 0b8300428e..b94896c4fd 100644 --- a/scm-ui/ui-forms/src/select/SelectField.tsx +++ b/scm-ui/ui-forms/src/select/SelectField.tsx @@ -51,7 +51,7 @@ const SelectField = React.forwardRef( {helpText ? : null} - + {error ? {error} : null} diff --git a/scm-ui/ui-styles/src/dark.scss b/scm-ui/ui-styles/src/dark.scss index 1fae461050..960a585e26 100644 --- a/scm-ui/ui-styles/src/dark.scss +++ b/scm-ui/ui-styles/src/dark.scss @@ -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%); +} diff --git a/scm-ui/ui-styles/src/highcontrast.scss b/scm-ui/ui-styles/src/highcontrast.scss index 530706d4d0..a58f5fe163 100644 --- a/scm-ui/ui-styles/src/highcontrast.scss +++ b/scm-ui/ui-styles/src/highcontrast.scss @@ -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%); +} diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 3db527f54f..c2f8ed03c7 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -1,6 +1,8 @@ { "form": { "submit": "Speichern", + "reset": "Leeren", + "discardChanges": "Änderungen verwerfen", "submit-success-notification": "Einstellungen wurden erfolgreich geändert!", "table": { "headers": { diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index cd5378dc18..7feee33a18 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -1,6 +1,8 @@ { "form": { "submit": "Submit", + "reset": "Clear", + "discardChanges": "Discard changes", "submit-success-notification": "Configuration changed successfully!", "table": { "headers": {