mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 14:35:45 +01:00
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:
committed by
SCM-Manager
parent
026ffa18fd
commit
b53f8bcf12
2
gradle/changelog/form_reset.yaml
Normal file
2
gradle/changelog/form_reset.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: added
|
||||||
|
description: Optional reset button for forms
|
||||||
@@ -32,7 +32,7 @@ type Result<C extends HalRepresentation> = {
|
|||||||
configuration: C;
|
configuration: C;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MutationVariables<C extends HalRepresentation> = {
|
type MutationVariables<C> = {
|
||||||
configuration: C;
|
configuration: C;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
link: string;
|
link: string;
|
||||||
@@ -53,8 +53,8 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
|
|||||||
error: mutationError,
|
error: mutationError,
|
||||||
mutateAsync,
|
mutateAsync,
|
||||||
data: updateResponse,
|
data: updateResponse,
|
||||||
} = useMutation<Response, Error, MutationVariables<C>>(
|
} = useMutation<Response, Error, MutationVariables<Omit<C, keyof HalRepresentation>>>(
|
||||||
(vars: MutationVariables<C>) => apiClient.put(vars.link, vars.configuration, vars.contentType),
|
(vars) => apiClient.put(vars.link, vars.configuration, vars.contentType),
|
||||||
{
|
{
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries(queryKey);
|
await queryClient.invalidateQueries(queryKey);
|
||||||
@@ -65,7 +65,7 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
|
|||||||
const isReadOnly = !data?.configuration._links.update;
|
const isReadOnly = !data?.configuration._links.update;
|
||||||
|
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(configuration: C) => {
|
(configuration: Omit<C, keyof HalRepresentation>) => {
|
||||||
if (data && !isReadOnly) {
|
if (data && !isReadOnly) {
|
||||||
return mutateAsync({
|
return mutateAsync({
|
||||||
configuration,
|
configuration,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function AddListEntryForm<FormType extends Record<string, unknown>, DefaultValue
|
|||||||
return (
|
return (
|
||||||
<ScmFormContextProvider {...form} t={translateWithExtraPrefix} formId={nameWithPrefix}>
|
<ScmFormContextProvider {...form} t={translateWithExtraPrefix} formId={nameWithPrefix}>
|
||||||
<ScmFormPathContextProvider path="">
|
<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}
|
{typeof children === "function" ? children(form) : children}
|
||||||
<div className="level-right">
|
<div className="level-right">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -21,13 +21,18 @@
|
|||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
* SOFTWARE.
|
* 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;
|
link: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,20 +40,17 @@ type Props<T extends HalRepresentation> = Pick<ComponentProps<typeof Form<T, T>>
|
|||||||
* @beta
|
* @beta
|
||||||
* @since 2.41.0
|
* @since 2.41.0
|
||||||
*/
|
*/
|
||||||
export function ConfigurationForm<T extends HalRepresentation>({link, translationPath, children}: Props<T>) {
|
export function ConfigurationForm<T extends HalRepresentation>({ link, children, ...formProps }: Props<T>) {
|
||||||
const {initialConfiguration, isReadOnly, update, isLoading} = useConfigLink<T>(link);
|
const { initialConfiguration, isReadOnly, update, isLoading } = useConfigLink<T>(link);
|
||||||
|
const [t] = useTranslation("commons", { keyPrefix: "form" });
|
||||||
|
|
||||||
if (isLoading || !initialConfiguration) {
|
if (isLoading || !initialConfiguration) {
|
||||||
return <Loading/>;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form<T, T>
|
<Form onSubmit={update} defaultValues={initialConfiguration} readOnly={isReadOnly}
|
||||||
onSubmit={update}
|
successMessageFallback={t("submit-success-notification")} {...formProps}>
|
||||||
translationPath={translationPath}
|
|
||||||
defaultValues={initialConfiguration}
|
|
||||||
readOnly={isReadOnly}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
import React from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { storiesOf } from "@storybook/react";
|
import { storiesOf } from "@storybook/react";
|
||||||
import Form from "./Form";
|
import Form from "./Form";
|
||||||
import FormRow from "./FormRow";
|
import FormRow from "./FormRow";
|
||||||
@@ -35,6 +35,7 @@ import ControlledColumn from "./table/ControlledColumn";
|
|||||||
import ControlledTable from "./table/ControlledTable";
|
import ControlledTable from "./table/ControlledTable";
|
||||||
import AddListEntryForm from "./AddListEntryForm";
|
import AddListEntryForm from "./AddListEntryForm";
|
||||||
import { ScmFormListContextProvider } from "./ScmFormListContext";
|
import { ScmFormListContextProvider } from "./ScmFormListContext";
|
||||||
|
import { HalRepresentation } from "@scm-manager/ui-types";
|
||||||
|
|
||||||
export type SimpleWebHookConfiguration = {
|
export type SimpleWebHookConfiguration = {
|
||||||
urlPattern: string;
|
urlPattern: string;
|
||||||
@@ -62,7 +63,7 @@ storiesOf("Forms", module)
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<ControlledInputField name="name" />
|
<ControlledInputField rules={{ required: true }} name="name" />
|
||||||
</FormRow>
|
</FormRow>
|
||||||
<FormRow>
|
<FormRow>
|
||||||
<ControlledSecretConfirmationField name="password" />
|
<ControlledSecretConfirmationField name="password" />
|
||||||
@@ -79,6 +80,7 @@ storiesOf("Forms", module)
|
|||||||
defaultValues={{
|
defaultValues={{
|
||||||
name: "trillian",
|
name: "trillian",
|
||||||
password: "secret",
|
password: "secret",
|
||||||
|
passwordConfirmation: "",
|
||||||
active: true,
|
active: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -93,6 +95,77 @@ storiesOf("Forms", module)
|
|||||||
</FormRow>
|
</FormRow>
|
||||||
</Form>
|
</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", () => (
|
.add("GlobalConfiguration", () => (
|
||||||
<Form
|
<Form
|
||||||
onSubmit={console.log}
|
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">
|
<ScmFormListContextProvider name="webhooks">
|
||||||
<ControlledList withDelete>
|
<ControlledList withDelete>
|
||||||
@@ -273,6 +376,7 @@ storiesOf("Forms", module)
|
|||||||
<ControlledColumn name="concealed">{(value) => (value ? <b>Hallo</b> : null)}</ControlledColumn>
|
<ControlledColumn name="concealed">{(value) => (value ? <b>Hallo</b> : null)}</ControlledColumn>
|
||||||
</ControlledTable>
|
</ControlledTable>
|
||||||
<AddListEntryForm defaultValues={{ key: "", value: "", concealed: false }}>
|
<AddListEntryForm defaultValues={{ key: "", value: "", concealed: false }}>
|
||||||
|
<FormRow>
|
||||||
<ControlledInputField
|
<ControlledInputField
|
||||||
name="key"
|
name="key"
|
||||||
rules={{
|
rules={{
|
||||||
@@ -281,8 +385,13 @@ storiesOf("Forms", module)
|
|||||||
required: true,
|
required: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
<ControlledInputField name="value" />
|
<ControlledInputField name="value" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
<ControlledCheckboxField name="concealed" />
|
<ControlledCheckboxField name="concealed" />
|
||||||
|
</FormRow>
|
||||||
</AddListEntryForm>
|
</AddListEntryForm>
|
||||||
</ScmFormListContextProvider>
|
</ScmFormListContextProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,13 +28,19 @@ import { ErrorNotification, Level } from "@scm-manager/ui-components";
|
|||||||
import { ScmFormContextProvider } from "./ScmFormContext";
|
import { ScmFormContextProvider } from "./ScmFormContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@scm-manager/ui-buttons";
|
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<
|
type RenderProps<T extends Record<string, unknown>> = Omit<
|
||||||
UseFormReturn<T>,
|
UseFormReturn<T>,
|
||||||
"register" | "unregister" | "handleSubmit" | "control"
|
"register" | "unregister" | "handleSubmit" | "control"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const ButtonsContainer = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
const SuccessNotification: FC<{ label?: string; hide: () => void }> = ({ label, hide }) => {
|
const SuccessNotification: FC<{ label?: string; hide: () => void }> = ({ label, hide }) => {
|
||||||
if (!label) {
|
if (!label) {
|
||||||
return null;
|
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;
|
children: ((renderProps: RenderProps<FormType>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
|
||||||
translationPath: [namespace: string, prefix: string];
|
translationPath: [namespace: string, prefix: string];
|
||||||
onSubmit: SubmitHandler<FormType>;
|
onSubmit: SubmitHandler<FormType>;
|
||||||
defaultValues: Omit<DefaultValues, keyof HalRepresentation>;
|
defaultValues: DefaultValues;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
submitButtonTestId?: string;
|
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
|
* @beta
|
||||||
* @since 2.41.0
|
* @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,
|
children,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
translationPath,
|
translationPath,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
withResetTo,
|
||||||
|
withDiscardChanges,
|
||||||
|
successMessageFallback,
|
||||||
submitButtonTestId,
|
submitButtonTestId,
|
||||||
}: Props<FormType, DefaultValues>) {
|
}: Props<FormType, DefaultValues>) {
|
||||||
const form = useForm<FormType>({
|
const form = useForm<FormType>({
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: defaultValues as DeepPartial<FormType>,
|
defaultValues: defaultValues as DeepPartial<FormType>,
|
||||||
});
|
});
|
||||||
const { formState, handleSubmit, reset } = form;
|
const { formState, handleSubmit, reset, setValue } = form;
|
||||||
const [ns, prefix] = translationPath;
|
const [ns, prefix] = translationPath;
|
||||||
const { t } = useTranslation(ns, { keyPrefix: prefix });
|
const { t } = useTranslation(ns, { keyPrefix: prefix });
|
||||||
const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form" });
|
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 [error, setError] = useState<Error | null | undefined>();
|
||||||
const [showSuccessNotification, setShowSuccessNotification] = useState(false);
|
const [showSuccessNotification, setShowSuccessNotification] = useState(false);
|
||||||
const submitButtonLabel = t("submit", { defaultValue: defaultTranslate("submit") });
|
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", {
|
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/
|
// See https://react-hook-form.com/api/useform/reset/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -128,7 +173,7 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback} formId={prefix}>
|
<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 ? (
|
{showSuccessNotification ? (
|
||||||
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
|
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -137,6 +182,7 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
{!readOnly ? (
|
{!readOnly ? (
|
||||||
<Level
|
<Level
|
||||||
right={
|
right={
|
||||||
|
<ButtonsContainer>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -147,6 +193,17 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
>
|
>
|
||||||
{submitButtonLabel}
|
{submitButtonLabel}
|
||||||
</Button>
|
</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}
|
) : null}
|
||||||
|
|||||||
@@ -24,26 +24,13 @@
|
|||||||
|
|
||||||
import React, { HTMLProps } from "react";
|
import React, { HTMLProps } from "react";
|
||||||
import classNames from "classnames";
|
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>>(
|
const FormRow = React.forwardRef<HTMLDivElement, HTMLProps<HTMLDivElement>>(
|
||||||
({ className, children, hidden, ...rest }, ref) =>
|
({ className, children, hidden, ...rest }, ref) =>
|
||||||
hidden ? null : (
|
hidden ? null : (
|
||||||
<FormRowDiv ref={ref} className={classNames("is-flex is-flex-wrap-wrap", className)} {...rest}>
|
<div ref={ref} className={classNames("columns", className)} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
</FormRowDiv>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
|
|||||||
import CheckboxField from "./CheckboxField";
|
import CheckboxField from "./CheckboxField";
|
||||||
import { useScmFormPathContext } from "../FormPathContext";
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
import { prefixWithoutIndices } from "../helpers";
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof CheckboxField>,
|
ComponentProps<typeof CheckboxField>,
|
||||||
@@ -46,6 +47,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
testId,
|
testId,
|
||||||
defaultChecked,
|
defaultChecked,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
@@ -64,7 +66,8 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
<CheckboxField
|
<CheckboxField
|
||||||
form={formId}
|
form={formId}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
defaultChecked={field.value}
|
checked={field.value}
|
||||||
|
className={classNames("column", className)}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
|
|||||||
@@ -22,6 +22,30 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
export function prefixWithoutIndices(path: string): string {
|
export function prefixWithoutIndices(path: string): string {
|
||||||
return path.replace(/(\.\d+)/g, "");
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
|
|||||||
import InputField from "./InputField";
|
import InputField from "./InputField";
|
||||||
import { useScmFormPathContext } from "../FormPathContext";
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
import { prefixWithoutIndices } from "../helpers";
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof InputField>,
|
ComponentProps<typeof InputField>,
|
||||||
@@ -46,6 +47,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
testId,
|
testId,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
@@ -64,6 +66,7 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
<InputField
|
<InputField
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
required={rules?.required as boolean}
|
required={rules?.required as boolean}
|
||||||
|
className={classNames("column", className)}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
form={formId}
|
form={formId}
|
||||||
|
|||||||
@@ -22,12 +22,13 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps, useCallback, useMemo } from "react";
|
||||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import InputField from "./InputField";
|
import InputField from "./InputField";
|
||||||
import { useScmFormPathContext } from "../FormPathContext";
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
import { prefixWithoutIndices } from "../helpers";
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof InputField>,
|
ComponentProps<typeof InputField>,
|
||||||
@@ -69,6 +70,16 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
|
|||||||
const confirmationErrorMessageTranslation =
|
const confirmationErrorMessageTranslation =
|
||||||
confirmationErrorMessage || t(`${prefixedNameWithoutIndices}.confirmation.errorMessage`);
|
confirmationErrorMessage || t(`${prefixedNameWithoutIndices}.confirmation.errorMessage`);
|
||||||
const secretValue = watch(nameWithPrefix);
|
const secretValue = watch(nameWithPrefix);
|
||||||
|
const validateConfirmField = useCallback(
|
||||||
|
(value) => secretValue === value || confirmationErrorMessageTranslation,
|
||||||
|
[confirmationErrorMessageTranslation, secretValue]
|
||||||
|
);
|
||||||
|
const confirmFieldRules = useMemo(
|
||||||
|
() => ({
|
||||||
|
validate: validateConfirmField,
|
||||||
|
}),
|
||||||
|
[validateConfirmField]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -82,7 +93,7 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
|
|||||||
}}
|
}}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<InputField
|
<InputField
|
||||||
className={className}
|
className={classNames("column", className)}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
@@ -104,9 +115,9 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
|
|||||||
control={control}
|
control={control}
|
||||||
name={`${nameWithPrefix}Confirmation`}
|
name={`${nameWithPrefix}Confirmation`}
|
||||||
defaultValue={defaultValue as never}
|
defaultValue={defaultValue as never}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<InputField
|
<InputField
|
||||||
className={className}
|
className={classNames("column", className)}
|
||||||
type="password"
|
type="password"
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
@@ -115,17 +126,12 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
|
|||||||
label={confirmationLabelTranslation}
|
label={confirmationLabelTranslation}
|
||||||
helpText={confirmationHelpTextTranslation}
|
helpText={confirmationHelpTextTranslation}
|
||||||
error={
|
error={
|
||||||
fieldState.error
|
error ? error.message || t(`${prefixedNameWithoutIndices}.confirmation.error.${error.type}`) : undefined
|
||||||
? fieldState.error.message ||
|
|
||||||
t(`${prefixedNameWithoutIndices}.confirmation.error.${fieldState.error.type}`)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
|
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
rules={{
|
rules={confirmFieldRules}
|
||||||
validate: (value) => secretValue === value || confirmationErrorMessageTranslation,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ type InputFieldProps = {
|
|||||||
label: string;
|
label: string;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
type?: "text" | "password" | "email" | "tel";
|
} & React.ComponentProps<typeof Input>;
|
||||||
} & Omit<React.ComponentProps<typeof Input>, "type">;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://bulma.io/documentation/form/input/
|
* @see https://bulma.io/documentation/form/input/
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useScmFormContext } from "../ScmFormContext";
|
|||||||
import SelectField from "./SelectField";
|
import SelectField from "./SelectField";
|
||||||
import { useScmFormPathContext } from "../FormPathContext";
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
import { prefixWithoutIndices } from "../helpers";
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof SelectField>,
|
ComponentProps<typeof SelectField>,
|
||||||
@@ -46,6 +47,7 @@ function ControlledSelectField<T extends Record<string, unknown>>({
|
|||||||
testId,
|
testId,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
@@ -65,6 +67,7 @@ function ControlledSelectField<T extends Record<string, unknown>>({
|
|||||||
form={formId}
|
form={formId}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
required={rules?.required as boolean}
|
required={rules?.required as boolean}
|
||||||
|
className={classNames("column", className)}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const SelectField = React.forwardRef<HTMLSelectElement, Props>(
|
|||||||
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
{helpText ? <Help className="ml-1" text={helpText} /> : null}
|
||||||
</Label>
|
</Label>
|
||||||
<Control>
|
<Control>
|
||||||
<Select id={selectId} variant={variant} ref={ref} {...props}></Select>
|
<Select id={selectId} variant={variant} ref={ref} className="is-full-width" {...props}></Select>
|
||||||
</Control>
|
</Control>
|
||||||
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -300,3 +300,7 @@ $danger-25: scale-color($danger, $lightness: -75%);
|
|||||||
.has-hover-visible:hover {
|
.has-hover-visible:hover {
|
||||||
color: $grey !important;
|
color: $grey !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="date"].input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,3 +372,7 @@ td:first-child.diff-gutter-conflict:before {
|
|||||||
border: 1px solid $info;
|
border: 1px solid $info;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="date"].input::-webkit-calendar-picker-indicator {
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"form": {
|
"form": {
|
||||||
"submit": "Speichern",
|
"submit": "Speichern",
|
||||||
|
"reset": "Leeren",
|
||||||
|
"discardChanges": "Änderungen verwerfen",
|
||||||
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
|
"submit-success-notification": "Einstellungen wurden erfolgreich geändert!",
|
||||||
"table": {
|
"table": {
|
||||||
"headers": {
|
"headers": {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"form": {
|
"form": {
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
|
"reset": "Clear",
|
||||||
|
"discardChanges": "Discard changes",
|
||||||
"submit-success-notification": "Configuration changed successfully!",
|
"submit-success-notification": "Configuration changed successfully!",
|
||||||
"table": {
|
"table": {
|
||||||
"headers": {
|
"headers": {
|
||||||
|
|||||||
Reference in New Issue
Block a user