mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 06:55:47 +01:00
Implement new form features
Extends existing functionality, provides new fallbacks for translations and adds capabilities to manage array properties in configurations. Committed-by: Florian Scholdei <florian.scholdei@cloudogu.com> Co-authored-by: Florian Scholdei <florian.scholdei@cloudogu.com>
This commit is contained in:
committed by
SCM-Manager
parent
719f6c4c09
commit
dda52b8400
2
gradle/changelog/form_list.yaml
Normal file
2
gradle/changelog/form_list.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: added
|
||||||
|
description: Enable developers to manage array properties in forms
|
||||||
@@ -66,8 +66,9 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
|
|||||||
* @since 2.41.0
|
* @since 2.41.0
|
||||||
*/
|
*/
|
||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, isLoading, testId, children, ...props }, ref) => (
|
({ className, variant, isLoading, testId, type, children, ...props }, ref) => (
|
||||||
<button
|
<button
|
||||||
|
type={type ?? "button"}
|
||||||
{...props}
|
{...props}
|
||||||
className={classNames(createButtonClasses(variant, isLoading), className)}
|
className={classNames(createButtonClasses(variant, isLoading), className)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* 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 React from "react";
|
import React, { FC } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -29,16 +29,13 @@ type Props = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class Subtitle extends React.Component<Props> {
|
const Subtitle: FC<Props> = ({ subtitle, className, children }) => {
|
||||||
render() {
|
|
||||||
const { subtitle, className, children } = this.props;
|
|
||||||
if (subtitle) {
|
if (subtitle) {
|
||||||
return <h2 className={classNames("subtitle", className)}>{subtitle}</h2>;
|
return <h2 className={classNames("subtitle", className)}>{subtitle}</h2>;
|
||||||
} else if (children) {
|
} else if (children) {
|
||||||
return <h2 className={classNames("subtitle", className)}>{children}</h2>;
|
return <h2 className={classNames("subtitle", className)}>{children}</h2>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default Subtitle;
|
export default Subtitle;
|
||||||
|
|||||||
@@ -37,10 +37,12 @@
|
|||||||
"react": "17",
|
"react": "17",
|
||||||
"react-hook-form": "7",
|
"react-hook-form": "7",
|
||||||
"react-i18next": "11",
|
"react-i18next": "11",
|
||||||
"react-query": "3"
|
"react-query": "3",
|
||||||
|
"styled-components": "5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@scm-manager/ui-buttons": "2.42.3-SNAPSHOT",
|
"@scm-manager/ui-buttons": "2.42.3-SNAPSHOT",
|
||||||
|
"@scm-manager/ui-overlays": "2.42.3-SNAPSHOT",
|
||||||
"@scm-manager/ui-api": "2.42.3-SNAPSHOT"
|
"@scm-manager/ui-api": "2.42.3-SNAPSHOT"
|
||||||
},
|
},
|
||||||
"prettier": "@scm-manager/prettier-config",
|
"prettier": "@scm-manager/prettier-config",
|
||||||
|
|||||||
123
scm-ui/ui-forms/src/AddListEntryForm.tsx
Normal file
123
scm-ui/ui-forms/src/AddListEntryForm.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* 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, { useCallback, useEffect } from "react";
|
||||||
|
import { ScmFormPathContextProvider, useScmFormPathContext } from "./FormPathContext";
|
||||||
|
import { ScmFormContextProvider, useScmFormContext } from "./ScmFormContext";
|
||||||
|
import { DeepPartial, UseFieldArrayReturn, useForm, UseFormReturn } from "react-hook-form";
|
||||||
|
import { Button } from "@scm-manager/ui-buttons";
|
||||||
|
import { prefixWithoutIndices } from "./helpers";
|
||||||
|
import { useScmFormListContext } from "./ScmFormListContext";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
const StyledHr = styled.hr`
|
||||||
|
&:last-child {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RenderProps<T extends Record<string, unknown>> = Omit<
|
||||||
|
UseFormReturn<T>,
|
||||||
|
"register" | "unregister" | "handleSubmit" | "control"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type Props<FormType extends Record<string, unknown>, DefaultValues extends FormType> = {
|
||||||
|
children: ((renderProps: RenderProps<FormType>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
|
||||||
|
defaultValues: DefaultValues;
|
||||||
|
submit?: (data: FormType, append: UseFieldArrayReturn["append"]) => unknown;
|
||||||
|
/**
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
disableSubmitWhenDirty?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.43.0
|
||||||
|
*/
|
||||||
|
function AddListEntryForm<FormType extends Record<string, unknown>, DefaultValues extends FormType>({
|
||||||
|
children,
|
||||||
|
defaultValues,
|
||||||
|
submit,
|
||||||
|
disableSubmitWhenDirty = true,
|
||||||
|
}: Props<FormType, DefaultValues>) {
|
||||||
|
const [defaultTranslate] = useTranslation("commons", { keyPrefix: "form" });
|
||||||
|
const { readOnly, t } = useScmFormContext();
|
||||||
|
const nameWithPrefix = useScmFormPathContext();
|
||||||
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
|
const { append, isNested } = useScmFormListContext();
|
||||||
|
const form = useForm<FormType, DefaultValues>({
|
||||||
|
mode: "onChange",
|
||||||
|
defaultValues: defaultValues as DeepPartial<FormType>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
reset,
|
||||||
|
formState: { isSubmitSuccessful, isDirty, isValid },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const translateWithExtraPrefix = useCallback<typeof t>(
|
||||||
|
(key, ...args) => t(`${prefixedNameWithoutIndices}.${key}`, ...(args as any)),
|
||||||
|
[prefixedNameWithoutIndices, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onSubmit = useCallback((data) => (submit ? submit(data, append) : append(data)), [append, submit]);
|
||||||
|
|
||||||
|
const submitButtonLabel = translateWithExtraPrefix("add", {
|
||||||
|
defaultValue: defaultTranslate("list.add.label", { entity: translateWithExtraPrefix("entity") }),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmitSuccessful) {
|
||||||
|
reset(defaultValues);
|
||||||
|
}
|
||||||
|
}, [defaultValues, isSubmitSuccessful, reset]);
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScmFormContextProvider {...form} t={translateWithExtraPrefix} formId={nameWithPrefix}>
|
||||||
|
<ScmFormPathContextProvider path="">
|
||||||
|
<form id={nameWithPrefix} onSubmit={form.handleSubmit(onSubmit)}></form>
|
||||||
|
{typeof children === "function" ? children(form) : children}
|
||||||
|
<div className="level-right">
|
||||||
|
<Button
|
||||||
|
form={nameWithPrefix}
|
||||||
|
type="submit"
|
||||||
|
variant={isNested ? undefined : "secondary"}
|
||||||
|
disabled={(disableSubmitWhenDirty && !isDirty) || !isValid}
|
||||||
|
>
|
||||||
|
{submitButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<StyledHr />
|
||||||
|
</ScmFormPathContextProvider>
|
||||||
|
</ScmFormContextProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddListEntryForm;
|
||||||
@@ -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";
|
|
||||||
|
|
||||||
<Meta title="Form" />
|
|
||||||
|
|
||||||
<Story name="Creation">
|
|
||||||
<Form
|
|
||||||
onSubmit={console.log}
|
|
||||||
translationPath={["sample", "form"]}
|
|
||||||
defaultValues={{
|
|
||||||
name: "",
|
|
||||||
password: "",
|
|
||||||
active: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledInputField name="name" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledSecretConfirmationField name="password" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledCheckboxField name="active" />
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="Editing">
|
|
||||||
<Form
|
|
||||||
onSubmit={console.log}
|
|
||||||
translationPath={["sample", "form"]}
|
|
||||||
defaultValues={{
|
|
||||||
name: "trillian",
|
|
||||||
password: "secret",
|
|
||||||
active: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledInputField name="name" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledSecretConfirmationField name="password" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledCheckboxField name="active" />
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="GlobalConfiguration">
|
|
||||||
<Form
|
|
||||||
onSubmit={console.log}
|
|
||||||
translationPath={["sample", "form"]}
|
|
||||||
defaultValues={{
|
|
||||||
url: "",
|
|
||||||
filter: "",
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
roleLevel: "",
|
|
||||||
updateIssues: false,
|
|
||||||
disableRepoConfig: false,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{({ watch }) => (
|
|
||||||
<>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledInputField name="url" label="URL" helpText="URL of Jira installation (with context path)." />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledInputField name="filter" label="Project Filter" helpText="Filters for jira project key." />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledCheckboxField
|
|
||||||
name="updateIssues"
|
|
||||||
label="Update Jira Issues"
|
|
||||||
helpText="Enable the automatic update function."
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow hide={watch("filter")}>
|
|
||||||
<ControlledInputField
|
|
||||||
name="username"
|
|
||||||
label="Username"
|
|
||||||
helpText="Jira username for connection."
|
|
||||||
className="is-half"
|
|
||||||
/>
|
|
||||||
<ControlledInputField
|
|
||||||
name="password"
|
|
||||||
label="Password"
|
|
||||||
helpText="Jira password for connection."
|
|
||||||
type="password"
|
|
||||||
className="is-half"
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow hide={watch("filter")}>
|
|
||||||
<ControlledInputField
|
|
||||||
name="roleLevel"
|
|
||||||
label="Role Visibility"
|
|
||||||
helpText="Defines for which Project Role the comments are visible."
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledCheckboxField
|
|
||||||
name="disableRepoConfig"
|
|
||||||
label="Do not allow repository configuration"
|
|
||||||
helpText="Do not allow repository owners to configure jira instances."
|
|
||||||
/>
|
|
||||||
</FormRow>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="RepoConfiguration">
|
|
||||||
<Form
|
|
||||||
onSubmit={console.log}
|
|
||||||
translationPath={["sample", "form"]}
|
|
||||||
defaultValues={{
|
|
||||||
url: "",
|
|
||||||
option: "",
|
|
||||||
anotherOption: "",
|
|
||||||
disableA: false,
|
|
||||||
disableB: false,
|
|
||||||
disableC: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ControlledInputField name="url" />
|
|
||||||
<ControlledInputField name="option" />
|
|
||||||
<ControlledInputField name="anotherOption" />
|
|
||||||
<ControlledCheckboxField name="disableA" />
|
|
||||||
<ControlledCheckboxField name="disableB" />
|
|
||||||
<ControlledCheckboxField name="disableC" />
|
|
||||||
</Form>
|
|
||||||
</Story>
|
|
||||||
|
|
||||||
<Story name="ReadOnly">
|
|
||||||
<Form
|
|
||||||
onSubmit={console.log}
|
|
||||||
translationPath={["sample", "form"]}
|
|
||||||
defaultValues={{
|
|
||||||
name: "trillian",
|
|
||||||
password: "secret",
|
|
||||||
active: true,
|
|
||||||
}}
|
|
||||||
readOnly
|
|
||||||
>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledInputField name="name" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledSecretConfirmationField name="password" />
|
|
||||||
</FormRow>
|
|
||||||
<FormRow>
|
|
||||||
<ControlledCheckboxField name="active" />
|
|
||||||
</FormRow>
|
|
||||||
</Form>
|
|
||||||
</Story>
|
|
||||||
295
scm-ui/ui-forms/src/Form.stories.tsx
Normal file
295
scm-ui/ui-forms/src/Form.stories.tsx
Normal file
@@ -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", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
name: "",
|
||||||
|
password: "",
|
||||||
|
active: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledInputField name="name" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledSecretConfirmationField name="password" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField name="active" />
|
||||||
|
</FormRow>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.add("Editing", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
name: "trillian",
|
||||||
|
password: "secret",
|
||||||
|
active: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledInputField name="name" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledSecretConfirmationField name="password" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField name="active" />
|
||||||
|
</FormRow>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.add("GlobalConfiguration", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
url: "",
|
||||||
|
filter: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
roleLevel: "",
|
||||||
|
updateIssues: false,
|
||||||
|
disableRepoConfig: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ watch }) => (
|
||||||
|
<>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledInputField name="url" label="URL" helpText="URL of Jira installation (with context path)." />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledInputField name="filter" label="Project Filter" helpText="Filters for jira project key." />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField
|
||||||
|
name="updateIssues"
|
||||||
|
label="Update Jira Issues"
|
||||||
|
helpText="Enable the automatic update function."
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow hidden={watch("filter")}>
|
||||||
|
<ControlledInputField
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
helpText="Jira username for connection."
|
||||||
|
className="is-half"
|
||||||
|
/>
|
||||||
|
<ControlledInputField
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
helpText="Jira password for connection."
|
||||||
|
type="password"
|
||||||
|
className="is-half"
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow hidden={watch("filter")}>
|
||||||
|
<ControlledInputField
|
||||||
|
name="roleLevel"
|
||||||
|
label="Role Visibility"
|
||||||
|
helpText="Defines for which Project Role the comments are visible."
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField
|
||||||
|
name="disableRepoConfig"
|
||||||
|
label="Do not allow repository configuration"
|
||||||
|
helpText="Do not allow repository owners to configure jira instances."
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.add("RepoConfiguration", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
url: "",
|
||||||
|
option: "",
|
||||||
|
anotherOption: "",
|
||||||
|
disableA: false,
|
||||||
|
disableB: false,
|
||||||
|
disableC: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ControlledInputField name="url" />
|
||||||
|
<ControlledInputField name="option" />
|
||||||
|
<ControlledInputField name="anotherOption" />
|
||||||
|
<ControlledCheckboxField name="disableA" />
|
||||||
|
<ControlledCheckboxField name="disableB" />
|
||||||
|
<ControlledCheckboxField name="disableC" />
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.add("ReadOnly", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
name: "trillian",
|
||||||
|
password: "secret",
|
||||||
|
active: true,
|
||||||
|
}}
|
||||||
|
readOnly
|
||||||
|
>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledInputField name="name" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledSecretConfirmationField name="password" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField name="active" />
|
||||||
|
</FormRow>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.add("Nested", () => (
|
||||||
|
<Form
|
||||||
|
onSubmit={console.log}
|
||||||
|
translationPath={["sample", "form"]}
|
||||||
|
defaultValues={{
|
||||||
|
webhooks: [
|
||||||
|
{
|
||||||
|
urlPattern: "https://hitchhiker.com",
|
||||||
|
executeOnEveryCommit: false,
|
||||||
|
sendCommitData: false,
|
||||||
|
method: "post",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "test",
|
||||||
|
value: "val",
|
||||||
|
concealed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "secret",
|
||||||
|
value: "password",
|
||||||
|
concealed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: "http://test.com",
|
||||||
|
executeOnEveryCommit: false,
|
||||||
|
sendCommitData: false,
|
||||||
|
method: "get",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "other",
|
||||||
|
value: "thing",
|
||||||
|
concealed: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "stuff",
|
||||||
|
value: "haha",
|
||||||
|
concealed: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScmFormListContextProvider name="webhooks">
|
||||||
|
<ControlledList withDelete>
|
||||||
|
{({ value: webhook }) => (
|
||||||
|
<>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledSelectField name="method">
|
||||||
|
{["post", "get", "put"].map((value) => (
|
||||||
|
<option value={value} key={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</ControlledSelectField>
|
||||||
|
<ControlledInputField name="urlPattern" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField name="executeOnEveryCommit" />
|
||||||
|
</FormRow>
|
||||||
|
<FormRow>
|
||||||
|
<ControlledCheckboxField name="sendCommitData" />
|
||||||
|
</FormRow>
|
||||||
|
<details className="has-background-dark-25 mb-2 p-2">
|
||||||
|
<summary className="is-clickable">Headers</summary>
|
||||||
|
<div>
|
||||||
|
<ScmFormListContextProvider name="headers">
|
||||||
|
<ControlledTable withDelete>
|
||||||
|
<ControlledColumn name="key" />
|
||||||
|
<ControlledColumn name="value" />
|
||||||
|
<ControlledColumn name="concealed">{(value) => (value ? <b>Hallo</b> : null)}</ControlledColumn>
|
||||||
|
</ControlledTable>
|
||||||
|
<AddListEntryForm defaultValues={{ key: "", value: "", concealed: false }}>
|
||||||
|
<ControlledInputField
|
||||||
|
name="key"
|
||||||
|
rules={{
|
||||||
|
validate: (newKey) =>
|
||||||
|
!(webhook as SimpleWebHookConfiguration).headers.some(({ key }) => newKey === key),
|
||||||
|
required: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ControlledInputField name="value" />
|
||||||
|
<ControlledCheckboxField name="concealed" />
|
||||||
|
</AddListEntryForm>
|
||||||
|
</ScmFormListContextProvider>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ControlledList>
|
||||||
|
</ScmFormListContextProvider>
|
||||||
|
</Form>
|
||||||
|
));
|
||||||
@@ -28,12 +28,7 @@ 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 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 { HalRepresentation } from "@scm-manager/ui-types";
|
||||||
import ControlledSelectField from "./select/ControlledSelectField";
|
|
||||||
|
|
||||||
type RenderProps<T extends Record<string, unknown>> = Omit<
|
type RenderProps<T extends Record<string, unknown>> = Omit<
|
||||||
UseFormReturn<T>,
|
UseFormReturn<T>,
|
||||||
@@ -81,9 +76,24 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
const { formState, handleSubmit, reset } = form;
|
const { formState, handleSubmit, reset } = 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 translateWithFallback = useCallback<typeof t>(
|
||||||
|
(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 { isDirty, isValid, isSubmitting, isSubmitSuccessful } = formState;
|
||||||
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 successNotification = translateWithFallback("submit-success-notification", {
|
||||||
|
defaultValue: defaultTranslate("submit-success-notification"),
|
||||||
|
});
|
||||||
|
|
||||||
// See https://react-hook-form.com/api/useform/reset/
|
// See https://react-hook-form.com/api/useform/reset/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,17 +110,6 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
}
|
}
|
||||||
}, [isDirty]);
|
}, [isDirty]);
|
||||||
|
|
||||||
const translateWithFallback = useCallback<typeof t>(
|
|
||||||
(key, ...args) => {
|
|
||||||
const translation = t(key, ...(args as any));
|
|
||||||
if (translation === `${prefix}.${key}`) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return translation;
|
|
||||||
},
|
|
||||||
[prefix, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const submit = useCallback(
|
const submit = useCallback(
|
||||||
async (data) => {
|
async (data) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -128,13 +127,10 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback}>
|
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback} formId={prefix}>
|
||||||
<form onSubmit={handleSubmit(submit)}>
|
<form onSubmit={handleSubmit(submit)} id={prefix}></form>
|
||||||
{showSuccessNotification ? (
|
{showSuccessNotification ? (
|
||||||
<SuccessNotification
|
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
|
||||||
label={translateWithFallback("submit-success-notification")}
|
|
||||||
hide={() => setShowSuccessNotification(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
{typeof children === "function" ? children(form) : children}
|
{typeof children === "function" ? children(form) : children}
|
||||||
{error ? <ErrorNotification error={error} /> : null}
|
{error ? <ErrorNotification error={error} /> : null}
|
||||||
@@ -147,21 +143,15 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
|
|||||||
testId={submitButtonTestId ?? "submit-button"}
|
testId={submitButtonTestId ?? "submit-button"}
|
||||||
disabled={!isDirty || !isValid}
|
disabled={!isDirty || !isValid}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
|
form={prefix}
|
||||||
>
|
>
|
||||||
{t("submit")}
|
{submitButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
|
||||||
</ScmFormContextProvider>
|
</ScmFormContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Object.assign(Form, {
|
export default Form;
|
||||||
Row: FormRow,
|
|
||||||
Input: ControlledInputField,
|
|
||||||
Checkbox: ControlledCheckboxField,
|
|
||||||
SecretConfirmation: ControlledSecretConfirmationField,
|
|
||||||
Select: ControlledSelectField,
|
|
||||||
});
|
|
||||||
|
|||||||
73
scm-ui/ui-forms/src/FormPathContext.tsx
Normal file
73
scm-ui/ui-forms/src/FormPathContext.tsx
Normal file
@@ -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<string>("");
|
||||||
|
|
||||||
|
export function useScmFormPathContext() {
|
||||||
|
return useContext(ScmFormPathContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScmFormPathContextProvider: FC<{ path: string }> = ({ children, path }) => (
|
||||||
|
<ScmFormPathContext.Provider value={path}>{children}</ScmFormPathContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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:
|
||||||
|
* <Form.PathContext path="subForm">
|
||||||
|
* <Form.Input name="foo" />
|
||||||
|
* <Form.Checkbox name="bar" />
|
||||||
|
* </Form.PathContext>
|
||||||
|
*
|
||||||
|
* // Instead of
|
||||||
|
*
|
||||||
|
* <Form.Input name="subForm.foo" />
|
||||||
|
* <Form.Checkbox name="subForm.bar" />
|
||||||
|
*
|
||||||
|
* // 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 <ScmFormPathContext.Provider value={pathWithPrefix}>{children}</ScmFormPathContext.Provider>;
|
||||||
|
};
|
||||||
@@ -24,13 +24,26 @@
|
|||||||
|
|
||||||
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 : (
|
||||||
<div ref={ref} className={classNames("columns", className)} {...rest}>
|
<FormRowDiv ref={ref} className={classNames("is-flex is-flex-wrap-wrap", className)} {...rest}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</FormRowDiv>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { TFunction } from "i18next";
|
|||||||
type ContextType<T = any> = UseFormReturn<T> & {
|
type ContextType<T = any> = UseFormReturn<T> & {
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
formId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScmFormContext = React.createContext<ContextType>(null as unknown as ContextType);
|
const ScmFormContext = React.createContext<ContextType>(null as unknown as ContextType);
|
||||||
|
|||||||
65
scm-ui/ui-forms/src/ScmFormListContext.tsx
Normal file
65
scm-ui/ui-forms/src/ScmFormListContext.tsx
Normal file
@@ -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<T extends FieldValues = any> = UseFieldArrayReturn<T> & { isNested: boolean };
|
||||||
|
|
||||||
|
const ScmFormListContext = React.createContext<ContextType>(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<Props> = ({ 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 (
|
||||||
|
<ScmFormPathContextProvider path={nameWithPrefix}>
|
||||||
|
<ScmFormListContext.Provider value={value}>{children}</ScmFormListContext.Provider>
|
||||||
|
</ScmFormPathContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useScmFormListContext() {
|
||||||
|
return useContext(ScmFormListContext);
|
||||||
|
}
|
||||||
@@ -23,14 +23,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import classNames from "classnames";
|
import { Tooltip } from "@scm-manager/ui-overlays";
|
||||||
|
|
||||||
type Props = { text?: string; className?: string };
|
type Props = { text?: string; className?: string };
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Implement tooltip
|
|
||||||
*/
|
|
||||||
const Help = ({ text, className }: Props) => (
|
const Help = ({ text, className }: Props) => (
|
||||||
<span className={classNames("fas fa-fw fa-question-circle has-text-blue-light", className)} title={text} />
|
<Tooltip className={className} message={text}>
|
||||||
|
<i className="fas fa-fw fa-question-circle has-text-blue-light" />
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
export default Help;
|
export default Help;
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
import { Controller, ControllerRenderProps, Path, RegisterOptions } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path, RegisterOptions } from "react-hook-form";
|
||||||
import classNames from "classnames";
|
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import CheckboxField from "./CheckboxField";
|
import CheckboxField from "./CheckboxField";
|
||||||
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof CheckboxField>,
|
ComponentProps<typeof CheckboxField>,
|
||||||
@@ -42,31 +43,33 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
label,
|
label,
|
||||||
helpText,
|
helpText,
|
||||||
rules,
|
rules,
|
||||||
className,
|
|
||||||
testId,
|
testId,
|
||||||
defaultChecked,
|
defaultChecked,
|
||||||
readOnly,
|
readOnly,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
const labelTranslation = label || t(`${name}.label`) || "";
|
const formPathPrefix = useScmFormPathContext();
|
||||||
const helpTextTranslation = helpText || t(`${name}.helpText`);
|
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||||
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||||
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={nameWithPrefix}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
defaultValue={defaultChecked as never}
|
defaultValue={defaultChecked as never}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<CheckboxField
|
<CheckboxField
|
||||||
className={classNames("column", className)}
|
form={formId}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
defaultChecked={field.value}
|
defaultChecked={field.value}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
helpText={helpTextTranslation}
|
helpText={helpTextTranslation}
|
||||||
testId={testId ?? `checkbox-${name}`}
|
testId={testId ?? `checkbox-${nameWithPrefix}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
27
scm-ui/ui-forms/src/helpers.ts
Normal file
27
scm-ui/ui-forms/src/helpers.ts
Normal file
@@ -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, "");
|
||||||
|
}
|
||||||
@@ -22,6 +22,33 @@
|
|||||||
* SOFTWARE.
|
* 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 { default as ConfigurationForm } from "./ConfigurationForm";
|
||||||
export * from "./resourceHooks";
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
import classNames from "classnames";
|
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import InputField from "./InputField";
|
import InputField from "./InputField";
|
||||||
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof InputField>,
|
ComponentProps<typeof InputField>,
|
||||||
@@ -42,32 +43,38 @@ function ControlledInputField<T extends Record<string, unknown>>({
|
|||||||
label,
|
label,
|
||||||
helpText,
|
helpText,
|
||||||
rules,
|
rules,
|
||||||
className,
|
|
||||||
testId,
|
testId,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly,
|
readOnly,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
const labelTranslation = label || t(`${name}.label`) || "";
|
const formPathPrefix = useScmFormPathContext();
|
||||||
const helpTextTranslation = helpText || t(`${name}.helpText`);
|
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||||
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||||
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={nameWithPrefix}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
defaultValue={defaultValue as never}
|
defaultValue={defaultValue as never}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<InputField
|
<InputField
|
||||||
className={classNames("column", className)}
|
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
required={rules?.required as boolean}
|
required={rules?.required as boolean}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
|
form={formId}
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
helpText={helpTextTranslation}
|
helpText={helpTextTranslation}
|
||||||
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
|
error={
|
||||||
testId={testId ?? `input-${name}`}
|
fieldState.error
|
||||||
|
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
testId={testId ?? `input-${nameWithPrefix}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
import classNames from "classnames";
|
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import InputField from "./InputField";
|
import InputField from "./InputField";
|
||||||
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof InputField>,
|
ComponentProps<typeof InputField>,
|
||||||
@@ -56,60 +57,70 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
|
|||||||
readOnly,
|
readOnly,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, watch, t, readOnly: formReadonly } = useScmFormContext();
|
const { control, watch, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
const labelTranslation = label || t(`${name}.label`) || "";
|
const formPathPrefix = useScmFormPathContext();
|
||||||
const helpTextTranslation = helpText || t(`${name}.helpText`);
|
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||||
const confirmationLabelTranslation = confirmationLabel || t(`${name}.confirmation.label`) || "";
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
const confirmationHelpTextTranslation = confirmationHelpText || t(`${name}.confirmation.helpText`);
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||||
const confirmationErrorMessageTranslation = confirmationErrorMessage || t(`${name}.confirmation.errorMessage`);
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||||
const secretValue = watch(name);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={nameWithPrefix}
|
||||||
defaultValue={defaultValue as never}
|
defaultValue={defaultValue as never}
|
||||||
rules={{
|
rules={{
|
||||||
...rules,
|
...rules,
|
||||||
deps: [`${name}Confirmation`],
|
deps: [`${nameWithPrefix}Confirmation`],
|
||||||
}}
|
}}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<InputField
|
<InputField
|
||||||
className={classNames("column", className)}
|
className={className}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
|
form={formId}
|
||||||
required={rules?.required as boolean}
|
required={rules?.required as boolean}
|
||||||
type="password"
|
type="password"
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
helpText={helpTextTranslation}
|
helpText={helpTextTranslation}
|
||||||
error={
|
error={
|
||||||
fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined
|
fieldState.error
|
||||||
|
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
testId={testId ?? `input-${name}`}
|
testId={testId ?? `input-${nameWithPrefix}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={`${name}Confirmation`}
|
name={`${nameWithPrefix}Confirmation`}
|
||||||
defaultValue={defaultValue as never}
|
defaultValue={defaultValue as never}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<InputField
|
<InputField
|
||||||
className={classNames("column", className)}
|
className={className}
|
||||||
type="password"
|
type="password"
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
{...field}
|
{...field}
|
||||||
|
form={formId}
|
||||||
label={confirmationLabelTranslation}
|
label={confirmationLabelTranslation}
|
||||||
helpText={confirmationHelpTextTranslation}
|
helpText={confirmationHelpTextTranslation}
|
||||||
error={
|
error={
|
||||||
fieldState.error
|
fieldState.error
|
||||||
? fieldState.error.message || t(`${name}.confirmation.error.${fieldState.error.type}`)
|
? fieldState.error.message ||
|
||||||
|
t(`${prefixedNameWithoutIndices}.confirmation.error.${fieldState.error.type}`)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
testId={confirmationTestId ?? `input-${name}-confirmation`}
|
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
rules={{
|
rules={{
|
||||||
|
|||||||
88
scm-ui/ui-forms/src/list/ControlledList.tsx
Normal file
88
scm-ui/ui-forms/src/list/ControlledList.tsx
Normal file
@@ -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> = T extends Array<infer U> ? U : unknown;
|
||||||
|
|
||||||
|
type RenderProps<T extends Record<string, unknown>, PATH extends Path<T>> = {
|
||||||
|
value: ArrayItemType<PathValue<T, PATH>>;
|
||||||
|
index: number;
|
||||||
|
remove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props<T extends Record<string, unknown>, PATH extends Path<T>> = {
|
||||||
|
withDelete?: boolean;
|
||||||
|
children: ((renderProps: RenderProps<T, PATH>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.43.0
|
||||||
|
*/
|
||||||
|
function ControlledList<T extends Record<string, unknown>, PATH extends Path<T>>({
|
||||||
|
withDelete,
|
||||||
|
children,
|
||||||
|
}: Props<T, PATH>) {
|
||||||
|
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) => (
|
||||||
|
<ScmFormPathContextProvider key={value.id} path={`${nameWithPrefix}.${index}`}>
|
||||||
|
{typeof children === "function"
|
||||||
|
? children({ value: value as never, index, remove: () => remove(index) })
|
||||||
|
: children}
|
||||||
|
{withDelete && !readOnly ? (
|
||||||
|
<div className="level-right">
|
||||||
|
<Button variant="signal" onClick={() => remove(index)}>
|
||||||
|
{deleteButtonTranslation}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<hr />
|
||||||
|
</ScmFormPathContextProvider>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlledList;
|
||||||
@@ -24,9 +24,10 @@
|
|||||||
|
|
||||||
import React, { ComponentProps } from "react";
|
import React, { ComponentProps } from "react";
|
||||||
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
|
||||||
import classNames from "classnames";
|
|
||||||
import { useScmFormContext } from "../ScmFormContext";
|
import { useScmFormContext } from "../ScmFormContext";
|
||||||
import SelectField from "./SelectField";
|
import SelectField from "./SelectField";
|
||||||
|
import { useScmFormPathContext } from "../FormPathContext";
|
||||||
|
import { prefixWithoutIndices } from "../helpers";
|
||||||
|
|
||||||
type Props<T extends Record<string, unknown>> = Omit<
|
type Props<T extends Record<string, unknown>> = Omit<
|
||||||
ComponentProps<typeof SelectField>,
|
ComponentProps<typeof SelectField>,
|
||||||
@@ -42,32 +43,38 @@ function ControlledSelectField<T extends Record<string, unknown>>({
|
|||||||
label,
|
label,
|
||||||
helpText,
|
helpText,
|
||||||
rules,
|
rules,
|
||||||
className,
|
|
||||||
testId,
|
testId,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
readOnly,
|
readOnly,
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const { control, t, readOnly: formReadonly } = useScmFormContext();
|
const { control, t, readOnly: formReadonly, formId } = useScmFormContext();
|
||||||
const labelTranslation = label || t(`${name}.label`) || "";
|
const formPathPrefix = useScmFormPathContext();
|
||||||
const helpTextTranslation = helpText || t(`${name}.helpText`);
|
const nameWithPrefix = formPathPrefix ? `${formPathPrefix}.${name}` : name;
|
||||||
|
const prefixedNameWithoutIndices = prefixWithoutIndices(nameWithPrefix);
|
||||||
|
const labelTranslation = label || t(`${prefixedNameWithoutIndices}.label`) || "";
|
||||||
|
const helpTextTranslation = helpText || t(`${prefixedNameWithoutIndices}.helpText`);
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name={name}
|
name={nameWithPrefix}
|
||||||
rules={rules}
|
rules={rules}
|
||||||
defaultValue={defaultValue as never}
|
defaultValue={defaultValue as never}
|
||||||
render={({ field, fieldState }) => (
|
render={({ field, fieldState }) => (
|
||||||
<SelectField
|
<SelectField
|
||||||
className={classNames("column", className)}
|
form={formId}
|
||||||
readOnly={readOnly ?? formReadonly}
|
readOnly={readOnly ?? formReadonly}
|
||||||
required={rules?.required as boolean}
|
required={rules?.required as boolean}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
label={labelTranslation}
|
label={labelTranslation}
|
||||||
helpText={helpTextTranslation}
|
helpText={helpTextTranslation}
|
||||||
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
|
error={
|
||||||
testId={testId ?? `select-${name}`}
|
fieldState.error
|
||||||
|
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
testId={testId ?? `select-${nameWithPrefix}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,22 +22,32 @@
|
|||||||
* SOFTWARE.
|
* SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { InputHTMLAttributes } from "react";
|
import React, { InputHTMLAttributes, Key, OptionHTMLAttributes } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { createVariantClass, Variant } from "../variants";
|
import { createVariantClass, Variant } from "../variants";
|
||||||
import { createAttributesForTesting } from "@scm-manager/ui-components";
|
import { createAttributesForTesting } from "@scm-manager/ui-components";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
|
options?: Array<OptionHTMLAttributes<HTMLOptionElement> & { label: string }>;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
} & InputHTMLAttributes<HTMLSelectElement>;
|
} & InputHTMLAttributes<HTMLSelectElement>;
|
||||||
|
|
||||||
const Select = React.forwardRef<HTMLSelectElement, Props>(({ variant, children, className, testId, ...props }, ref) => (
|
const Select = React.forwardRef<HTMLSelectElement, Props>(
|
||||||
|
({ variant, children, className, options, testId, ...props }, ref) => (
|
||||||
<div className={classNames("select", { "is-multiple": props.multiple }, createVariantClass(variant), className)}>
|
<div className={classNames("select", { "is-multiple": props.multiple }, createVariantClass(variant), className)}>
|
||||||
<select ref={ref} {...props} {...createAttributesForTesting(testId)}>
|
<select ref={ref} {...props} {...createAttributesForTesting(testId)}>
|
||||||
{children}
|
{options
|
||||||
|
? options.map((option) => (
|
||||||
|
<option {...option} key={option.value as Key}>
|
||||||
|
{option.label}
|
||||||
|
{option.children}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
: children}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
));
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export default Select;
|
export default Select;
|
||||||
|
|||||||
49
scm-ui/ui-forms/src/table/ControlledColumn.tsx
Normal file
49
scm-ui/ui-forms/src/table/ControlledColumn.tsx
Normal file
@@ -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<HTMLTableCellElement>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.43.0
|
||||||
|
*/
|
||||||
|
const ControlledColumn: FC<Props> = ({ 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 <td {...props}>{typeof children === "function" ? children(allValues) : value}</td>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlledColumn;
|
||||||
100
scm-ui/ui-forms/src/table/ControlledTable.tsx
Normal file
100
scm-ui/ui-forms/src/table/ControlledTable.tsx
Normal file
@@ -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<T extends Record<string, unknown>, PATH extends Path<T>> = {
|
||||||
|
value: PathValue<T, PATH>;
|
||||||
|
index: number;
|
||||||
|
remove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props<T extends Record<string, unknown>, PATH extends Path<T>> = {
|
||||||
|
withDelete?: boolean;
|
||||||
|
children: ((renderProps: RenderProps<T, PATH>) => React.ReactNode | React.ReactNode[]) | React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @beta
|
||||||
|
* @since 2.43.0
|
||||||
|
*/
|
||||||
|
function ControlledTable<T extends Record<string, unknown>, PATH extends Path<T>>({
|
||||||
|
withDelete,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: Props<T, PATH>) {
|
||||||
|
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 (
|
||||||
|
<table className={classNames("table content is-hoverable", className)}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{React.Children.map(children, (child) => (
|
||||||
|
<th>{t(`${prefixedNameWithoutIndices}.${(child as ReactElement).props.name}.label`)}</th>
|
||||||
|
))}
|
||||||
|
{withDelete && !readOnly ? <th className="has-text-right">{actionHeaderLabel}</th> : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{fields.map((value, index) => (
|
||||||
|
<ScmFormPathContextProvider key={value.id} path={`${nameWithPrefix}.${index}`}>
|
||||||
|
<tr>
|
||||||
|
{children}
|
||||||
|
{withDelete && !readOnly ? (
|
||||||
|
<td className="has-text-right">
|
||||||
|
<Button className="px-4" onClick={() => remove(index)} aria-label={deleteLabel}>
|
||||||
|
<span className="icon is-small">
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
</ScmFormPathContextProvider>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ControlledTable;
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
--scm-popover-border-color: #{$popover-border-color};
|
--scm-popover-border-color: #{$popover-border-color};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details > * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO split into multiple files
|
// TODO split into multiple files
|
||||||
|
|
||||||
.is-ellipsis-overflow {
|
.is-ellipsis-overflow {
|
||||||
|
|||||||
@@ -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": {
|
"login": {
|
||||||
"title": "Anmeldung",
|
"title": "Anmeldung",
|
||||||
"subtitle": "Bitte anmelden, um fortzufahren.",
|
"subtitle": "Bitte anmelden, um fortzufahren.",
|
||||||
|
|||||||
@@ -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": {
|
"login": {
|
||||||
"title": "Login",
|
"title": "Login",
|
||||||
"subtitle": "Please login to proceed",
|
"subtitle": "Please login to proceed",
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import * as ClassNames from "classnames";
|
|||||||
import * as QueryString from "query-string";
|
import * as QueryString from "query-string";
|
||||||
import * as UIExtensions from "@scm-manager/ui-extensions";
|
import * as UIExtensions from "@scm-manager/ui-extensions";
|
||||||
import * as UIComponents from "@scm-manager/ui-components";
|
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";
|
import * as UIApi from "@scm-manager/ui-api";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -56,6 +59,9 @@ defineStatic("classnames", ClassNames);
|
|||||||
defineStatic("query-string", QueryString);
|
defineStatic("query-string", QueryString);
|
||||||
defineStatic("@scm-manager/ui-extensions", UIExtensions);
|
defineStatic("@scm-manager/ui-extensions", UIExtensions);
|
||||||
defineStatic("@scm-manager/ui-components", UIComponents);
|
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);
|
defineStatic("@scm-manager/ui-api", UIApi);
|
||||||
|
|
||||||
// redux is deprecated in favor of ui-api
|
// redux is deprecated in favor of ui-api
|
||||||
|
|||||||
Reference in New Issue
Block a user