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:
Konstantin Schaper
2023-03-07 11:29:10 +01:00
committed by SCM-Manager
parent 719f6c4c09
commit dda52b8400
27 changed files with 1066 additions and 283 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Enable developers to manage array properties in forms

View File

@@ -66,8 +66,9 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
* @since 2.41.0
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, isLoading, testId, children, ...props }, ref) => (
({ className, variant, isLoading, testId, type, children, ...props }, ref) => (
<button
type={type ?? "button"}
{...props}
className={classNames(createButtonClasses(variant, isLoading), className)}
ref={ref}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import React, { FC } from "react";
import classNames from "classnames";
type Props = {
@@ -29,16 +29,13 @@ type Props = {
className?: string;
};
class Subtitle extends React.Component<Props> {
render() {
const { subtitle, className, children } = this.props;
const Subtitle: FC<Props> = ({ subtitle, className, children }) => {
if (subtitle) {
return <h2 className={classNames("subtitle", className)}>{subtitle}</h2>;
} else if (children) {
return <h2 className={classNames("subtitle", className)}>{children}</h2>;
}
return null;
}
}
};
export default Subtitle;

View File

@@ -37,10 +37,12 @@
"react": "17",
"react-hook-form": "7",
"react-i18next": "11",
"react-query": "3"
"react-query": "3",
"styled-components": "5"
},
"dependencies": {
"@scm-manager/ui-buttons": "2.42.3-SNAPSHOT",
"@scm-manager/ui-overlays": "2.42.3-SNAPSHOT",
"@scm-manager/ui-api": "2.42.3-SNAPSHOT"
},
"prettier": "@scm-manager/prettier-config",

View 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;

View File

@@ -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>

View 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>
));

View File

@@ -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<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
@@ -81,9 +76,24 @@ function Form<FormType extends Record<string, unknown>, 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<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 [error, setError] = useState<Error | null | undefined>();
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<FormType extends Record<string, unknown>, DefaultValues extends Fo
}
}, [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(
async (data) => {
setError(null);
@@ -128,13 +127,10 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
);
return (
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback}>
<form onSubmit={handleSubmit(submit)}>
<ScmFormContextProvider {...form} readOnly={isSubmitting || readOnly} t={translateWithFallback} formId={prefix}>
<form onSubmit={handleSubmit(submit)} id={prefix}></form>
{showSuccessNotification ? (
<SuccessNotification
label={translateWithFallback("submit-success-notification")}
hide={() => setShowSuccessNotification(false)}
/>
<SuccessNotification label={successNotification} hide={() => setShowSuccessNotification(false)} />
) : null}
{typeof children === "function" ? children(form) : children}
{error ? <ErrorNotification error={error} /> : null}
@@ -147,21 +143,15 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
testId={submitButtonTestId ?? "submit-button"}
disabled={!isDirty || !isValid}
isLoading={isSubmitting}
form={prefix}
>
{t("submit")}
{submitButtonLabel}
</Button>
}
/>
) : null}
</form>
</ScmFormContextProvider>
);
}
export default Object.assign(Form, {
Row: FormRow,
Input: ControlledInputField,
Checkbox: ControlledCheckboxField,
SecretConfirmation: ControlledSecretConfirmationField,
Select: ControlledSelectField,
});
export default Form;

View 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>;
};

View File

@@ -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<HTMLDivElement, HTMLProps<HTMLDivElement>>(
({ className, children, hidden, ...rest }, ref) =>
hidden ? null : (
<div ref={ref} className={classNames("columns", className)} {...rest}>
<FormRowDiv ref={ref} className={classNames("is-flex is-flex-wrap-wrap", className)} {...rest}>
{children}
</div>
</FormRowDiv>
)
);

View File

@@ -29,6 +29,7 @@ import type { TFunction } from "i18next";
type ContextType<T = any> = UseFormReturn<T> & {
t: TFunction;
readOnly?: boolean;
formId: string;
};
const ScmFormContext = React.createContext<ContextType>(null as unknown as ContextType);

View 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);
}

View File

@@ -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) => (
<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;

View File

@@ -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<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof CheckboxField>,
@@ -42,31 +43,33 @@ function ControlledInputField<T extends Record<string, unknown>>({
label,
helpText,
rules,
className,
testId,
defaultChecked,
readOnly,
...props
}: Props<T>) {
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 (
<Controller
control={control}
name={name}
name={nameWithPrefix}
rules={rules}
defaultValue={defaultChecked as never}
render={({ field }) => (
<CheckboxField
className={classNames("column", className)}
form={formId}
readOnly={readOnly ?? formReadonly}
defaultChecked={field.value}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
testId={testId ?? `checkbox-${name}`}
testId={testId ?? `checkbox-${nameWithPrefix}`}
/>
)}
/>

View 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, "");
}

View File

@@ -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,
}),
});

View File

@@ -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<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
@@ -42,32 +43,38 @@ function ControlledInputField<T extends Record<string, unknown>>({
label,
helpText,
rules,
className,
testId,
defaultValue,
readOnly,
...props
}: Props<T>) {
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 (
<Controller
control={control}
name={name}
name={nameWithPrefix}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
{...props}
{...field}
form={formId}
label={labelTranslation}
helpText={helpTextTranslation}
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
testId={testId ?? `input-${name}`}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `input-${nameWithPrefix}`}
/>
)}
/>

View File

@@ -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<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof InputField>,
@@ -56,60 +57,70 @@ export default function ControlledSecretConfirmationField<T extends Record<strin
readOnly,
...props
}: Props<T>) {
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 (
<>
<Controller
control={control}
name={name}
name={nameWithPrefix}
defaultValue={defaultValue as never}
rules={{
...rules,
deps: [`${name}Confirmation`],
deps: [`${nameWithPrefix}Confirmation`],
}}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
className={className}
readOnly={readOnly ?? formReadonly}
{...props}
{...field}
form={formId}
required={rules?.required as boolean}
type="password"
label={labelTranslation}
helpText={helpTextTranslation}
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
control={control}
name={`${name}Confirmation`}
name={`${nameWithPrefix}Confirmation`}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<InputField
className={classNames("column", className)}
className={className}
type="password"
readOnly={readOnly ?? formReadonly}
disabled={props.disabled}
{...field}
form={formId}
label={confirmationLabelTranslation}
helpText={confirmationHelpTextTranslation}
error={
fieldState.error
? fieldState.error.message || t(`${name}.confirmation.error.${fieldState.error.type}`)
? fieldState.error.message ||
t(`${prefixedNameWithoutIndices}.confirmation.error.${fieldState.error.type}`)
: undefined
}
testId={confirmationTestId ?? `input-${name}-confirmation`}
testId={confirmationTestId ?? `input-${nameWithPrefix}-confirmation`}
/>
)}
rules={{

View 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;

View File

@@ -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<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof SelectField>,
@@ -42,32 +43,38 @@ function ControlledSelectField<T extends Record<string, unknown>>({
label,
helpText,
rules,
className,
testId,
defaultValue,
readOnly,
...props
}: Props<T>) {
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 (
<Controller
control={control}
name={name}
name={nameWithPrefix}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<SelectField
className={classNames("column", className)}
form={formId}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
testId={testId ?? `select-${name}`}
error={
fieldState.error
? fieldState.error.message || t(`${prefixedNameWithoutIndices}.error.${fieldState.error.type}`)
: undefined
}
testId={testId ?? `select-${nameWithPrefix}`}
/>
)}
/>

View File

@@ -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<OptionHTMLAttributes<HTMLOptionElement> & { label: string }>;
testId?: string;
} & 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)}>
<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>
</div>
));
)
);
export default Select;

View 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;

View 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;

View File

@@ -45,6 +45,10 @@
--scm-popover-border-color: #{$popover-border-color};
}
details > * {
box-sizing: border-box;
}
// TODO split into multiple files
.is-ellipsis-overflow {

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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