diff --git a/gradle/changelog/form_list.yaml b/gradle/changelog/form_list.yaml new file mode 100644 index 0000000000..58bf900565 --- /dev/null +++ b/gradle/changelog/form_list.yaml @@ -0,0 +1,2 @@ +- type: added + description: Enable developers to manage array properties in forms diff --git a/scm-ui/ui-buttons/src/Button.tsx b/scm-ui/ui-buttons/src/Button.tsx index 12e384dc9f..a45721f5fd 100644 --- a/scm-ui/ui-buttons/src/Button.tsx +++ b/scm-ui/ui-buttons/src/Button.tsx @@ -66,8 +66,9 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes; * @since 2.41.0 */ export const Button = React.forwardRef( - ({ className, variant, isLoading, testId, children, ...props }, ref) => ( + ({ className, variant, isLoading, testId, type, children, ...props }, ref) => ( + + + + + ); +} + +export default AddListEntryForm; diff --git a/scm-ui/ui-forms/src/Form.stories.mdx b/scm-ui/ui-forms/src/Form.stories.mdx deleted file mode 100644 index e9982c6a65..0000000000 --- a/scm-ui/ui-forms/src/Form.stories.mdx +++ /dev/null @@ -1,160 +0,0 @@ -import { Meta, Story } from "@storybook/addon-docs"; -import Form from "./Form"; -import FormRow from "./FormRow"; -import ControlledInputField from "./input/ControlledInputField"; -import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField"; -import ControlledCheckboxField from "./checkbox/ControlledCheckboxField"; - - - - -
- - - - - - - - - -
-
- - -
- - - - - - - - - -
-
- - -
- {({ watch }) => ( - <> - - - - - - - - - - - - - - - - - - - - - )} -
-
- - -
- - - - - - - -
- - -
- - - - - - - - - -
-
diff --git a/scm-ui/ui-forms/src/Form.stories.tsx b/scm-ui/ui-forms/src/Form.stories.tsx new file mode 100644 index 0000000000..bac4fa97ab --- /dev/null +++ b/scm-ui/ui-forms/src/Form.stories.tsx @@ -0,0 +1,295 @@ +/* + * 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. + */ +/* eslint-disable no-console */ +import React from "react"; +import { storiesOf } from "@storybook/react"; +import Form from "./Form"; +import FormRow from "./FormRow"; +import ControlledInputField from "./input/ControlledInputField"; +import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField"; +import ControlledCheckboxField from "./checkbox/ControlledCheckboxField"; +import ControlledList from "./list/ControlledList"; +import ControlledSelectField from "./select/ControlledSelectField"; +import ControlledColumn from "./table/ControlledColumn"; +import ControlledTable from "./table/ControlledTable"; +import AddListEntryForm from "./AddListEntryForm"; +import { ScmFormListContextProvider } from "./ScmFormListContext"; + +export type SimpleWebHookConfiguration = { + urlPattern: string; + executeOnEveryCommit: boolean; + sendCommitData: boolean; + method: string; + headers: WebhookHeader[]; +}; + +export type WebhookHeader = { + key: string; + value: string; + concealed: boolean; +}; + +storiesOf("Forms", module) + .add("Creation", () => ( +
+ + + + + + + + + +
+ )) + .add("Editing", () => ( +
+ + + + + + + + + +
+ )) + .add("GlobalConfiguration", () => ( +
+ {({ watch }) => ( + <> + + + + + + + + + + + + + + + + )} +
+ )) + .add("RepoConfiguration", () => ( +
+ + + + + + + + )) + .add("ReadOnly", () => ( +
+ + + + + + + + + +
+ )) + .add("Nested", () => ( +
+ + + {({ value: webhook }) => ( + <> + + + {["post", "get", "put"].map((value) => ( + + ))} + + + + + + + + + +
+ Headers +
+ + + + + {(value) => (value ? Hallo : null)} + + + + !(webhook as SimpleWebHookConfiguration).headers.some(({ key }) => newKey === key), + required: true, + }} + /> + + + + +
+
+ + )} +
+
+
+ )); diff --git a/scm-ui/ui-forms/src/Form.tsx b/scm-ui/ui-forms/src/Form.tsx index 7c629e8072..3c7ac748b9 100644 --- a/scm-ui/ui-forms/src/Form.tsx +++ b/scm-ui/ui-forms/src/Form.tsx @@ -28,12 +28,7 @@ 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 FormRow from "./FormRow"; -import ControlledInputField from "./input/ControlledInputField"; -import ControlledCheckboxField from "./checkbox/ControlledCheckboxField"; -import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField"; import { HalRepresentation } from "@scm-manager/ui-types"; -import ControlledSelectField from "./select/ControlledSelectField"; type RenderProps> = Omit< UseFormReturn, @@ -81,9 +76,24 @@ function Form, DefaultValues extends Fo const { formState, handleSubmit, reset } = form; const [ns, prefix] = translationPath; const { t } = useTranslation(ns, { keyPrefix: prefix }); + const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form" }); + const translateWithFallback = useCallback( + (key, ...args) => { + const translation = t(key, ...(args as any)); + if (translation === `${prefix}.${key}`) { + return ""; + } + return translation; + }, + [prefix, t] + ); const { isDirty, isValid, isSubmitting, isSubmitSuccessful } = formState; const [error, setError] = useState(); const [showSuccessNotification, setShowSuccessNotification] = useState(false); + const submitButtonLabel = t("submit", { defaultValue: defaultTranslate("submit") }); + const successNotification = translateWithFallback("submit-success-notification", { + defaultValue: defaultTranslate("submit-success-notification"), + }); // See https://react-hook-form.com/api/useform/reset/ useEffect(() => { @@ -100,17 +110,6 @@ function Form, DefaultValues extends Fo } }, [isDirty]); - const translateWithFallback = useCallback( - (key, ...args) => { - const translation = t(key, ...(args as any)); - if (translation === `${prefix}.${key}`) { - return ""; - } - return translation; - }, - [prefix, t] - ); - const submit = useCallback( async (data) => { setError(null); @@ -128,40 +127,31 @@ function Form, DefaultValues extends Fo ); return ( - -
- {showSuccessNotification ? ( - setShowSuccessNotification(false)} - /> - ) : null} - {typeof children === "function" ? children(form) : children} - {error ? : null} - {!readOnly ? ( - - {t("submit")} - - } - /> - ) : null} - + +
+ {showSuccessNotification ? ( + setShowSuccessNotification(false)} /> + ) : null} + {typeof children === "function" ? children(form) : children} + {error ? : null} + {!readOnly ? ( + + {submitButtonLabel} + + } + /> + ) : null}
); } -export default Object.assign(Form, { - Row: FormRow, - Input: ControlledInputField, - Checkbox: ControlledCheckboxField, - SecretConfirmation: ControlledSecretConfirmationField, - Select: ControlledSelectField, -}); +export default Form; diff --git a/scm-ui/ui-forms/src/FormPathContext.tsx b/scm-ui/ui-forms/src/FormPathContext.tsx new file mode 100644 index 0000000000..0f80f72145 --- /dev/null +++ b/scm-ui/ui-forms/src/FormPathContext.tsx @@ -0,0 +1,73 @@ +/* + * 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, useContext, useMemo } from "react"; + +const ScmFormPathContext = React.createContext(""); + +export function useScmFormPathContext() { + return useContext(ScmFormPathContext); +} + +export const ScmFormPathContextProvider: FC<{ path: string }> = ({ children, path }) => ( + {children} +); + +/** + * This component removes redundancy by declaring a prefix that is shared by all enclosed form-related components. + * It might be helpful when the data structure of a list's items does not correspond with the individual list item's + * form structure. + * + * @beta + * @since 2.43.0 + * @example ``` + * // For data of structure + * { + * subForm: { foo: boolean, bar: string }; + * flag: boolean; + * tag: string; + * } + * // Because we use ConfigurationForm we get and update the whole object. + * // We only want to create a form for 'subForm' without having to repeat it for every subField + * // while keeping the original data structure for the whole form. * + * + * // Using this component we can write: + * + * + * + * + * + * // Instead of + * + * + * + * + * // This pattern becomes useful in complex or large forms. + * ``` + */ +export const ScmNestedFormPathContextProvider: FC<{ path: string }> = ({ children, path }) => { + const prefix = useScmFormPathContext(); + const pathWithPrefix = useMemo(() => (prefix ? `${prefix}.${path}` : path), [path, prefix]); + return {children}; +}; diff --git a/scm-ui/ui-forms/src/FormRow.tsx b/scm-ui/ui-forms/src/FormRow.tsx index b385e988ad..18ed8418be 100644 --- a/scm-ui/ui-forms/src/FormRow.tsx +++ b/scm-ui/ui-forms/src/FormRow.tsx @@ -24,13 +24,26 @@ 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/ScmFormContext.tsx b/scm-ui/ui-forms/src/ScmFormContext.tsx index 4d70876de7..ce23b6f3e5 100644 --- a/scm-ui/ui-forms/src/ScmFormContext.tsx +++ b/scm-ui/ui-forms/src/ScmFormContext.tsx @@ -29,6 +29,7 @@ import type { TFunction } from "i18next"; type ContextType = UseFormReturn & { t: TFunction; readOnly?: boolean; + formId: string; }; const ScmFormContext = React.createContext(null as unknown as ContextType); diff --git a/scm-ui/ui-forms/src/ScmFormListContext.tsx b/scm-ui/ui-forms/src/ScmFormListContext.tsx new file mode 100644 index 0000000000..8c6ec93c28 --- /dev/null +++ b/scm-ui/ui-forms/src/ScmFormListContext.tsx @@ -0,0 +1,65 @@ +/* + * 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, useContext, useMemo } from "react"; +import { FieldValues, useFieldArray, UseFieldArrayReturn } from "react-hook-form"; +import { ScmFormPathContextProvider, useScmFormPathContext } from "./FormPathContext"; +import { useScmFormContext } from "./ScmFormContext"; + +type ContextType = UseFieldArrayReturn & { isNested: boolean }; + +const ScmFormListContext = React.createContext(null as unknown as ContextType); + +type Props = { + name: string; +}; + +/** + * Sets up an array field to be displayed in an enclosed *Form.Table* or *Form.List*. + * A *Form.AddListEntryForm* can be used to implement a sub-form for adding new entries to the list. + * + * @beta + * @since 2.43.0 + */ +export const ScmFormListContextProvider: FC = ({ name, children }) => { + const { control } = useScmFormContext(); + const prefix = useScmFormPathContext(); + const parentForm = useScmFormListContext(); + const nameWithPrefix = useMemo(() => (prefix ? `${prefix}.${name}` : name), [name, prefix]); + const fieldArray = useFieldArray({ + control, + name: nameWithPrefix, + }); + const value = useMemo(() => ({ ...fieldArray, isNested: !!parentForm }), [fieldArray, parentForm]); + + return ( + + {children} + + ); +}; + +export function useScmFormListContext() { + return useContext(ScmFormListContext); +} diff --git a/scm-ui/ui-forms/src/base/help/Help.tsx b/scm-ui/ui-forms/src/base/help/Help.tsx index ad4e08619e..cd25fbde36 100644 --- a/scm-ui/ui-forms/src/base/help/Help.tsx +++ b/scm-ui/ui-forms/src/base/help/Help.tsx @@ -23,14 +23,12 @@ */ import React from "react"; -import classNames from "classnames"; +import { Tooltip } from "@scm-manager/ui-overlays"; type Props = { text?: string; className?: string }; - -/** - * TODO: Implement tooltip - */ const Help = ({ text, className }: Props) => ( - + + + ); export default Help; diff --git a/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx b/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx index ae20e877dc..b322ecbccb 100644 --- a/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx +++ b/scm-ui/ui-forms/src/checkbox/ControlledCheckboxField.tsx @@ -24,9 +24,10 @@ import React, { ComponentProps } from "react"; import { Controller, ControllerRenderProps, Path, RegisterOptions } from "react-hook-form"; -import classNames from "classnames"; import { useScmFormContext } from "../ScmFormContext"; import CheckboxField from "./CheckboxField"; +import { useScmFormPathContext } from "../FormPathContext"; +import { prefixWithoutIndices } from "../helpers"; type Props> = Omit< ComponentProps, @@ -42,31 +43,33 @@ function ControlledInputField>({ label, helpText, rules, - className, testId, defaultChecked, readOnly, ...props }: Props) { - const { control, t, readOnly: formReadonly } = useScmFormContext(); - const labelTranslation = label || t(`${name}.label`) || ""; - const helpTextTranslation = helpText || t(`${name}.helpText`); + const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); + const formPathPrefix = useScmFormPathContext(); + const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name; + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; + const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); return ( ( )} /> diff --git a/scm-ui/ui-forms/src/helpers.ts b/scm-ui/ui-forms/src/helpers.ts new file mode 100644 index 0000000000..ffb49e0771 --- /dev/null +++ b/scm-ui/ui-forms/src/helpers.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export function prefixWithoutIndices(path: string): string { + return path.replace(/(\.\d+)/g, ""); +} diff --git a/scm-ui/ui-forms/src/index.ts b/scm-ui/ui-forms/src/index.ts index bbfc8605ed..4b5c364453 100644 --- a/scm-ui/ui-forms/src/index.ts +++ b/scm-ui/ui-forms/src/index.ts @@ -22,6 +22,33 @@ * SOFTWARE. */ -export { default as Form } from "./Form"; +import FormCmp from "./Form"; +import FormRow from "./FormRow"; +import ControlledInputField from "./input/ControlledInputField"; +import ControlledCheckboxField from "./checkbox/ControlledCheckboxField"; +import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField"; +import ControlledSelectField from "./select/ControlledSelectField"; +import { ScmFormListContextProvider } from "./ScmFormListContext"; +import ControlledList from "./list/ControlledList"; +import ControlledTable from "./table/ControlledTable"; +import ControlledColumn from "./table/ControlledColumn"; +import AddListEntryForm from "./AddListEntryForm"; +import { ScmNestedFormPathContextProvider } from "./FormPathContext"; + export { default as ConfigurationForm } from "./ConfigurationForm"; export * from "./resourceHooks"; + +export const Form = Object.assign(FormCmp, { + Row: FormRow, + Input: ControlledInputField, + Checkbox: ControlledCheckboxField, + SecretConfirmation: ControlledSecretConfirmationField, + Select: ControlledSelectField, + PathContext: ScmNestedFormPathContextProvider, + ListContext: ScmFormListContextProvider, + List: ControlledList, + AddListEntryForm: AddListEntryForm, + Table: Object.assign(ControlledTable, { + Column: ControlledColumn, + }), +}); diff --git a/scm-ui/ui-forms/src/input/ControlledInputField.tsx b/scm-ui/ui-forms/src/input/ControlledInputField.tsx index d1503e03d4..f2b3bd355d 100644 --- a/scm-ui/ui-forms/src/input/ControlledInputField.tsx +++ b/scm-ui/ui-forms/src/input/ControlledInputField.tsx @@ -24,9 +24,10 @@ import React, { ComponentProps } from "react"; import { Controller, ControllerRenderProps, Path } from "react-hook-form"; -import classNames from "classnames"; import { useScmFormContext } from "../ScmFormContext"; import InputField from "./InputField"; +import { useScmFormPathContext } from "../FormPathContext"; +import { prefixWithoutIndices } from "../helpers"; type Props> = Omit< ComponentProps, @@ -42,32 +43,38 @@ function ControlledInputField>({ label, helpText, rules, - className, testId, defaultValue, readOnly, ...props }: Props) { - const { control, t, readOnly: formReadonly } = useScmFormContext(); - const labelTranslation = label || t(`${name}.label`) || ""; - const helpTextTranslation = helpText || t(`${name}.helpText`); + const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); + const formPathPrefix = useScmFormPathContext(); + const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name; + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; + const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); return ( ( )} /> diff --git a/scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx b/scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx index e571eaeb60..191446d50d 100644 --- a/scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx +++ b/scm-ui/ui-forms/src/input/ControlledSecretConfirmationField.tsx @@ -24,9 +24,10 @@ import React, { ComponentProps } from "react"; import { Controller, ControllerRenderProps, Path } from "react-hook-form"; -import classNames from "classnames"; import { useScmFormContext } from "../ScmFormContext"; import InputField from "./InputField"; +import { useScmFormPathContext } from "../FormPathContext"; +import { prefixWithoutIndices } from "../helpers"; type Props> = Omit< ComponentProps, @@ -56,60 +57,70 @@ export default function ControlledSecretConfirmationField) { - const { control, watch, t, readOnly: formReadonly } = useScmFormContext(); - const labelTranslation = label || t(`${name}.label`) || ""; - const helpTextTranslation = helpText || t(`${name}.helpText`); - const confirmationLabelTranslation = confirmationLabel || t(`${name}.confirmation.label`) || ""; - const confirmationHelpTextTranslation = confirmationHelpText || t(`${name}.confirmation.helpText`); - const confirmationErrorMessageTranslation = confirmationErrorMessage || t(`${name}.confirmation.errorMessage`); - const secretValue = watch(name); + const { control, watch, t, readOnly: formReadonly, formId } = useScmFormContext(); + const formPathPrefix = useScmFormPathContext(); + const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name; + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; + const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); + const confirmationLabelTranslation = confirmationLabel || t(`${prefixedNameWithoutIndices}.confirmation.label`) || ""; + const confirmationHelpTextTranslation = + confirmationHelpText || t(`${prefixedNameWithoutIndices}.confirmation.helpText`); + const confirmationErrorMessageTranslation = + confirmationErrorMessage || t(`${prefixedNameWithoutIndices}.confirmation.errorMessage`); + const secretValue = watch(nameWithPrefix); return ( <> ( )} /> ( )} rules={{ diff --git a/scm-ui/ui-forms/src/list/ControlledList.tsx b/scm-ui/ui-forms/src/list/ControlledList.tsx new file mode 100644 index 0000000000..c944e9b42e --- /dev/null +++ b/scm-ui/ui-forms/src/list/ControlledList.tsx @@ -0,0 +1,88 @@ +/* + * 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 from "react"; +import { Path, PathValue } from "react-hook-form"; +import { useScmFormContext } from "../ScmFormContext"; +import { ScmFormPathContextProvider, useScmFormPathContext } from "../FormPathContext"; +import { Button } from "@scm-manager/ui-buttons"; +import { prefixWithoutIndices } from "../helpers"; +import { useScmFormListContext } from "../ScmFormListContext"; +import { useTranslation } from "react-i18next"; + +type ArrayItemType = T extends Array ? U : unknown; + +type RenderProps, PATH extends Path> = { + value: ArrayItemType>; + index: number; + remove: () => void; +}; + +type Props, PATH extends Path> = { + withDelete?: boolean; + children: ((renderProps: RenderProps) => React.ReactNode | React.ReactNode[]) | React.ReactNode; +}; + +/** + * @beta + * @since 2.43.0 + */ +function ControlledList, PATH extends Path>({ + withDelete, + children, +}: Props) { + const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form" }); + const { readOnly, t } = useScmFormContext(); + const nameWithPrefix = useScmFormPathContext(); + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const { fields, remove } = useScmFormListContext(); + + const deleteButtonTranslation = t(`${prefixedNameWithoutIndices}.delete`, { + defaultValue: defaultTranslate("list.delete.label", { + entity: t(`${prefixedNameWithoutIndices}.entity`), + }), + }); + + return ( + <> + {fields.map((value, index) => ( + + {typeof children === "function" + ? children({ value: value as never, index, remove: () => remove(index) }) + : children} + {withDelete && !readOnly ? ( +
+ +
+ ) : null} +
+
+ ))} + + ); +} + +export default ControlledList; diff --git a/scm-ui/ui-forms/src/select/ControlledSelectField.tsx b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx index 1226ddd5dc..79e1176666 100644 --- a/scm-ui/ui-forms/src/select/ControlledSelectField.tsx +++ b/scm-ui/ui-forms/src/select/ControlledSelectField.tsx @@ -24,9 +24,10 @@ import React, { ComponentProps } from "react"; import { Controller, ControllerRenderProps, Path } from "react-hook-form"; -import classNames from "classnames"; import { useScmFormContext } from "../ScmFormContext"; import SelectField from "./SelectField"; +import { useScmFormPathContext } from "../FormPathContext"; +import { prefixWithoutIndices } from "../helpers"; type Props> = Omit< ComponentProps, @@ -42,32 +43,38 @@ function ControlledSelectField>({ label, helpText, rules, - className, testId, defaultValue, readOnly, ...props }: Props) { - const { control, t, readOnly: formReadonly } = useScmFormContext(); - const labelTranslation = label || t(`${name}.label`) || ""; - const helpTextTranslation = helpText || t(`${name}.helpText`); + const { control, t, readOnly: formReadonly, formId } = useScmFormContext(); + const formPathPrefix = useScmFormPathContext(); + const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name; + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || ""; + const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`); return ( ( )} /> diff --git a/scm-ui/ui-forms/src/select/Select.tsx b/scm-ui/ui-forms/src/select/Select.tsx index 1c832ac4ba..025eadf91f 100644 --- a/scm-ui/ui-forms/src/select/Select.tsx +++ b/scm-ui/ui-forms/src/select/Select.tsx @@ -22,22 +22,32 @@ * SOFTWARE. */ -import React, { InputHTMLAttributes } from "react"; +import React, { InputHTMLAttributes, Key, OptionHTMLAttributes } from "react"; import classNames from "classnames"; import { createVariantClass, Variant } from "../variants"; import { createAttributesForTesting } from "@scm-manager/ui-components"; type Props = { variant?: Variant; + options?: Array & { label: string }>; testId?: string; } & InputHTMLAttributes; -const Select = React.forwardRef(({ variant, children, className, testId, ...props }, ref) => ( -
- -
-)); +const Select = React.forwardRef( + ({ variant, children, className, options, testId, ...props }, ref) => ( +
+ +
+ ) +); export default Select; diff --git a/scm-ui/ui-forms/src/table/ControlledColumn.tsx b/scm-ui/ui-forms/src/table/ControlledColumn.tsx new file mode 100644 index 0000000000..ffa7fbf420 --- /dev/null +++ b/scm-ui/ui-forms/src/table/ControlledColumn.tsx @@ -0,0 +1,49 @@ +/* + * 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, HTMLAttributes, useMemo } from "react"; +import { useScmFormPathContext } from "../FormPathContext"; +import { useScmFormContext } from "../ScmFormContext"; +import { useWatch } from "react-hook-form"; + +type Props = { + name: string; + children?: (value: unknown) => React.ReactNode | React.ReactNode[]; +} & HTMLAttributes; + +/** + * @beta + * @since 2.43.0 + */ +const ControlledColumn: FC = ({ name, children, ...props }) => { + const { control } = useScmFormContext(); + const formPathPrefix = useScmFormPathContext(); + const nameWithPrefix = useMemo(() => (formPathPrefix ? `${formPathPrefix}.${name}` : name), [formPathPrefix, name]); + const value = useWatch({ control, name: nameWithPrefix, disabled: typeof children === "function" }); + const allValues = useWatch({ control, name: formPathPrefix, disabled: typeof children !== "function" }); + + return {typeof children === "function" ? children(allValues) : value}; +}; + +export default ControlledColumn; diff --git a/scm-ui/ui-forms/src/table/ControlledTable.tsx b/scm-ui/ui-forms/src/table/ControlledTable.tsx new file mode 100644 index 0000000000..7eb7e5fdc8 --- /dev/null +++ b/scm-ui/ui-forms/src/table/ControlledTable.tsx @@ -0,0 +1,100 @@ +/* + * 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, { ReactElement } from "react"; +import { Path, PathValue } from "react-hook-form"; +import { useScmFormContext } from "../ScmFormContext"; +import { ScmFormPathContextProvider, useScmFormPathContext } from "../FormPathContext"; +import { Button } from "@scm-manager/ui-buttons"; +import { prefixWithoutIndices } from "../helpers"; +import classNames from "classnames"; +import { useScmFormListContext } from "../ScmFormListContext"; +import { useTranslation } from "react-i18next"; + +type RenderProps, PATH extends Path> = { + value: PathValue; + index: number; + remove: () => void; +}; + +type Props, PATH extends Path> = { + withDelete?: boolean; + children: ((renderProps: RenderProps) => React.ReactNode | React.ReactNode[]) | React.ReactNode; + className?: string; +}; + +/** + * @beta + * @since 2.43.0 + */ +function ControlledTable, PATH extends Path>({ + withDelete, + children, + className, +}: Props) { + const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form.table" }); + const { readOnly, t } = useScmFormContext(); + const nameWithPrefix = useScmFormPathContext(); + const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix); + const { fields, remove } = useScmFormListContext(); + const deleteLabel = t(`${prefixedNameWithoutIndices}.delete`) || defaultTranslate("delete.label"); + const actionHeaderLabel = t(`${prefixedNameWithoutIndices}.action.label`) || defaultTranslate("headers.action.label"); + + if (!fields.length) { + return null; + } + + return ( + + + + {React.Children.map(children, (child) => ( + + ))} + {withDelete && !readOnly ? : null} + + + + {fields.map((value, index) => ( + + + {children} + {withDelete && !readOnly ? ( + + ) : null} + + + ))} + +
{t(`${prefixedNameWithoutIndices}.${(child as ReactElement).props.name}.label`)}{actionHeaderLabel}
+ +
+ ); +} + +export default ControlledTable; diff --git a/scm-ui/ui-styles/src/components/_main.scss b/scm-ui/ui-styles/src/components/_main.scss index c1c98fab36..dc9e29c795 100644 --- a/scm-ui/ui-styles/src/components/_main.scss +++ b/scm-ui/ui-styles/src/components/_main.scss @@ -45,6 +45,10 @@ --scm-popover-border-color: #{$popover-border-color}; } +details > * { + box-sizing: border-box; +} + // TODO split into multiple files .is-ellipsis-overflow { diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index fbb7a523b8..3db527f54f 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -1,4 +1,26 @@ { + "form": { + "submit": "Speichern", + "submit-success-notification": "Einstellungen wurden erfolgreich geändert!", + "table": { + "headers": { + "action": { + "label": "Aktion" + } + }, + "delete": { + "label": "Löschen" + } + }, + "list": { + "add": { + "label": "{{entity}} hinzufügen" + }, + "delete": { + "label": "{{entity}} löschen" + } + } + }, "login": { "title": "Anmeldung", "subtitle": "Bitte anmelden, um fortzufahren.", diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index e7ced45456..cd5378dc18 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -1,4 +1,26 @@ { + "form": { + "submit": "Submit", + "submit-success-notification": "Configuration changed successfully!", + "table": { + "headers": { + "action": { + "label": "Action" + } + }, + "delete": { + "label": "Delete" + } + }, + "list": { + "add": { + "label": "Add {{entity}}" + }, + "delete": { + "label": "Delete {{entity}}" + } + } + }, "login": { "title": "Login", "subtitle": "Please login to proceed", diff --git a/scm-ui/ui-webapp/src/containers/loadBundle.ts b/scm-ui/ui-webapp/src/containers/loadBundle.ts index 8044ab4c77..b48a45a84d 100644 --- a/scm-ui/ui-webapp/src/containers/loadBundle.ts +++ b/scm-ui/ui-webapp/src/containers/loadBundle.ts @@ -35,6 +35,9 @@ import * as ClassNames from "classnames"; import * as QueryString from "query-string"; import * as UIExtensions from "@scm-manager/ui-extensions"; import * as UIComponents from "@scm-manager/ui-components"; +import * as UIButtons from "@scm-manager/ui-buttons"; +import * as UIForms from "@scm-manager/ui-forms"; +import * as UIOverlays from "@scm-manager/ui-overlays"; import * as UIApi from "@scm-manager/ui-api"; declare global { @@ -56,6 +59,9 @@ defineStatic("classnames", ClassNames); defineStatic("query-string", QueryString); defineStatic("@scm-manager/ui-extensions", UIExtensions); defineStatic("@scm-manager/ui-components", UIComponents); +defineStatic("@scm-manager/ui-buttons", UIButtons); +defineStatic("@scm-manager/ui-forms", UIForms); +defineStatic("@scm-manager/ui-overlays", UIOverlays); defineStatic("@scm-manager/ui-api", UIApi); // redux is deprecated in favor of ui-api